Fixes the portaled terminal workspace picker menu appearing invisible behind the floating terminal modal by z-layering it above the panel's floatingZ and hiding it until it is positioned. - Compute terminalWorkspaceMenuFloatingZ one layer above the floating modal's floatingZ (min 5000) so the portaled listbox always renders above the floating terminal stack. - Position the workspace picker menu synchronously via useLayoutEffect before paint, instead of relying only on the async rAF-driven position update. - Keep the menu invisible and non-interactive (visibility: hidden, pointer-events: none) until computed trigger-relative coordinates are applied, avoiding a flash at stale/fallback CSS coordinates. - Add regression tests covering floating-mode z-index layering/pre-rAF positioning and docked/below/embedded/mobile workspace-picker positioning. - Add a patch changeset describing the fix. Files changed: .changeset/terminal-workspace-picker-floating.md | 7 ++ .../dashboard/app/components/TerminalModal.tsx | 37 +++++-- .../components/__tests__/TerminalModal.test.tsx | 120 +++++++++++++++++++++ 3 files changed, 156 insertions(+), 8 deletions(-) Fusion-Task-Id: FN-7931 Fusion-Task-Lineage: 41736804-3f99-4cb7-a0fb-f755a86ffc62 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
9177 lines
341 KiB
TypeScript
9177 lines
341 KiB
TypeScript
/*
|
||
FNXC:DashboardTests 2026-06-14-08:31:
|
||
FN-6441 rescued this orphaned component test after standalone dashboard-app execution passed without assertion, timeout, or source-code changes. Keep the terminal modal coverage in app backfill because keyboard, session, and mobile terminal regressions are user-facing and should not remain skip-listed.
|
||
*/
|
||
import { readFileSync } from "node:fs";
|
||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||
import { TerminalModal, _resetInitialViewportHeight, ctrlChar, altChar, evaluateTabsOverflow } from "../TerminalModal";
|
||
import {
|
||
DEFAULT_TERMINAL_PREFERENCES,
|
||
LEGACY_TERMINAL_FONT_SIZE_KEY,
|
||
MAX_TERMINAL_CUSTOM_SHORTCUTS,
|
||
TERMINAL_PREFERENCES_KEY,
|
||
TERMINAL_SYMBOLS_FONT_FAMILY,
|
||
XTERM_FONT_FAMILY,
|
||
resolveTerminalFontFamily,
|
||
} from "../../utils/terminalPreferences";
|
||
import * as useTerminalModule from "../../hooks/useTerminal";
|
||
import * as useTerminalSessionsModule from "../../hooks/useTerminalSessions";
|
||
import * as useWorkspacesModule from "../../hooks/useWorkspaces";
|
||
import * as apiModule from "../../api";
|
||
import { loadAllAppCss } from "../../test/cssFixture";
|
||
|
||
const terminalModalCss = readFileSync("app/components/TerminalModal.css", "utf8");
|
||
|
||
function splitFontFamilies(stack: string): string[] {
|
||
return stack
|
||
.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/)
|
||
.map((family) => family.trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function expectMeasurementSafeFontStack(stack: string): void {
|
||
const families = splitFontFamilies(stack);
|
||
expect(families.length).toBeGreaterThan(0);
|
||
expect(families).not.toContain(TERMINAL_SYMBOLS_FONT_FAMILY);
|
||
}
|
||
|
||
function expectTextSizeAdjustmentDisabledForExactXtermMetrics(cssSource: string): void {
|
||
const match = cssSource.match(/\.terminal-xterm\s*,\s*\.terminal-xterm \*\s*\{([^}]*)\}/);
|
||
expect(match?.[1] ?? "").toMatch(/-webkit-text-size-adjust\s*:\s*none\s*;/);
|
||
expect(match?.[1] ?? "").toMatch(/text-size-adjust\s*:\s*none\s*;/);
|
||
}
|
||
|
||
function defineMetric(element: Element, property: "clientWidth" | "scrollWidth", value: number) {
|
||
Object.defineProperty(element, property, { configurable: true, value });
|
||
}
|
||
|
||
// Mock hooks and API
|
||
vi.mock("../../hooks/useTerminal", () => ({
|
||
useTerminal: vi.fn(),
|
||
}));
|
||
|
||
vi.mock("../../hooks/useTerminalSessions", () => ({
|
||
useTerminalSessions: vi.fn(),
|
||
}));
|
||
|
||
vi.mock("../../hooks/useWorkspaces", () => ({
|
||
useWorkspaces: vi.fn(),
|
||
}));
|
||
|
||
vi.mock("../../api", () => ({
|
||
createTerminalSession: vi.fn(),
|
||
killPtyTerminalSession: vi.fn(),
|
||
listTerminalSessions: vi.fn().mockResolvedValue([]),
|
||
}));
|
||
|
||
// Mock xterm modules to prevent DOM errors in jsdom
|
||
/*
|
||
FNXC:Terminal 2026-07-04-11:05:
|
||
FN-7567 recurrence #4: forcing a genuine fontFamily/fontSize transition
|
||
(FN-7561's `forceTerminalFontRemeasure`) is necessary but not sufficient. Real
|
||
xterm's `DomRenderer._setDefaultSpacing()` — the letter-spacing compensation
|
||
baked onto `.xterm-rows` (`spacing = dimensions.css.cell.width -
|
||
widthCache.get('W')`) — is recomputed on `CharSizeService.onCharSizeChange`
|
||
(any genuine option change) and on `handleDevicePixelRatioChange`, but NOT on
|
||
`handleResize()` (the path `fitAddon.fit()` -> `terminal.resize(cols, rows)`
|
||
takes; see `@xterm/xterm` `src/browser/renderer/dom/DomRenderer.ts`). Both
|
||
settle sites call `forceTerminalFontRemeasure()` (which bakes spacing against
|
||
the column count that predates `fit()`) and only THEN call `fitAddon.fit()`
|
||
(which changes cols/cell-width but never re-bakes spacing), leaving a stale,
|
||
oversized letter-spacing baked in until an unrelated later event happens to
|
||
force another genuine option/DPR change. `mockFitAddonFit`/`mockTerminalInstance.cols`
|
||
model this ordering-sensitive geometry (measured char width, cell width
|
||
derived from cols, and the baked letter-spacing) directly, mirroring xterm's
|
||
real internals, so the regression asserts actual geometry instead of a
|
||
CSS-property or call-count check.
|
||
*/
|
||
const MOCK_CONTAINER_WIDTH_PX = 728;
|
||
const MOCK_FALLBACK_CHAR_WIDTH_PX = 9;
|
||
const MOCK_SETTLED_CHAR_WIDTH_PX = 7;
|
||
/*
|
||
FNXC:Terminal 2026-07-05-12:45:
|
||
FN-7603 recurrence #5: real xterm's `CharSizeService` picks ONE of two
|
||
measurement strategies at `terminal.open()` time — Canvas/OffscreenCanvas
|
||
(`ctx.measureText("W")`, chosen whenever `OffscreenCanvas` + the required
|
||
`TextMetrics` fields are available, i.e. virtually every real mobile browser)
|
||
or a DOM fallback (`offsetWidth` of a hidden repeated-"W" span, chosen only if
|
||
the canvas strategy's constructor throws). Separately, `DomRenderer.
|
||
_setDefaultSpacing()`/`DomRendererRowFactory` ALWAYS measure via `WidthCache`,
|
||
which is ALWAYS DOM-based (`offsetWidth`), regardless of which strategy
|
||
CharSizeService picked. The FN-7567 mock above (and prior recurrences) modeled
|
||
both as the SAME shared width, hiding this divergence. Model them
|
||
independently: `mockCanvasCharWidthPx` (drives `FitAddon.fit()`'s column
|
||
count, mirroring `dimensions.css.cell.width`) can diverge from
|
||
`mockDomCharWidthPx` (mirrors `WidthCache.get('W')`) whenever
|
||
`window.OffscreenCanvas` is defined at the moment the mock's `open()` runs —
|
||
exactly mirroring the real `CharSizeService` constructor's
|
||
`try { new OffscreenCanvasStrategy } catch { new DomFallbackStrategy }`
|
||
selection. The production fix (`withDomBasedTerminalCharacterMeasurement`)
|
||
hides `window.OffscreenCanvas` for the synchronous duration of `open()`, which
|
||
this mock's `open()` observes to decide which strategy was "selected".
|
||
*/
|
||
const MOCK_CANVAS_DOM_DIVERGENCE_PX = 0.7;
|
||
let mockFontsSettledForCharSize = false;
|
||
let mockDomCharWidthPx = MOCK_FALLBACK_CHAR_WIDTH_PX;
|
||
let mockCanvasCharWidthPx = MOCK_FALLBACK_CHAR_WIDTH_PX;
|
||
let mockCharSizeServiceUsesCanvasStrategy = true;
|
||
let mockBakedLetterSpacingPx = 0;
|
||
|
||
function resetMockTerminalGeometry(): void {
|
||
mockFontsSettledForCharSize = false;
|
||
mockDomCharWidthPx = MOCK_FALLBACK_CHAR_WIDTH_PX;
|
||
mockCanvasCharWidthPx = MOCK_FALLBACK_CHAR_WIDTH_PX;
|
||
mockCharSizeServiceUsesCanvasStrategy = true;
|
||
mockBakedLetterSpacingPx = 0;
|
||
mockTerminalInstance.cols = 80;
|
||
}
|
||
|
||
/** Mirrors the real web font finishing its network load/paint settle. */
|
||
function settleMockTerminalFontForCharSize(): void {
|
||
mockFontsSettledForCharSize = true;
|
||
}
|
||
|
||
/** The rendered cell/advance-width geometry invariant under test: 0 == tight contiguous monospace. */
|
||
function getMockBakedLetterSpacingPx(): number {
|
||
return mockBakedLetterSpacingPx;
|
||
}
|
||
|
||
/**
|
||
* Mirrors real xterm's `CharSizeService` constructor picking its measurement
|
||
* strategy the moment `terminal.open()` runs: Canvas/OffscreenCanvas when
|
||
* `window.OffscreenCanvas` is present, DOM fallback otherwise.
|
||
*/
|
||
function mockSelectCharSizeServiceStrategyAtOpen(): void {
|
||
mockCharSizeServiceUsesCanvasStrategy =
|
||
typeof (window as unknown as { OffscreenCanvas?: unknown }).OffscreenCanvas !== "undefined";
|
||
}
|
||
|
||
// Mirrors xterm's CharSizeService.measure() -> onCharSizeChange ->
|
||
// DomRenderer.handleCharSizeChanged() -> _updateDimensions() +
|
||
// _setDefaultSpacing(): runs on every GENUINE fontFamily/fontSize option
|
||
// transition, using the CURRENT (possibly stale, pre-fit) column count.
|
||
//
|
||
// `mockDomCharWidthPx` mirrors `WidthCache.get('W')` (always DOM-based).
|
||
// `mockCanvasCharWidthPx` mirrors `CharSizeService.width`: identical to the
|
||
// DOM value when the DOM strategy was selected at open(), but offset by a
|
||
// fixed divergence when the Canvas strategy was selected — modeling the real
|
||
// cross-pipeline (Canvas 2D vs DOM layout) measurement discrepancy that
|
||
// `_setDefaultSpacing()`'s `dimensions.css.cell.width - widthCache.get('W')`
|
||
// formula depends on both operands agreeing to correctly converge to zero.
|
||
function mockHandleCharSizeChanged(): void {
|
||
mockDomCharWidthPx = mockFontsSettledForCharSize
|
||
? MOCK_SETTLED_CHAR_WIDTH_PX
|
||
: MOCK_FALLBACK_CHAR_WIDTH_PX;
|
||
mockCanvasCharWidthPx = mockCharSizeServiceUsesCanvasStrategy
|
||
? mockDomCharWidthPx + MOCK_CANVAS_DOM_DIVERGENCE_PX
|
||
: mockDomCharWidthPx;
|
||
const cols = (mockTerminalInstance.cols as number) || 1;
|
||
const cellWidthPx = MOCK_CONTAINER_WIDTH_PX / cols;
|
||
mockBakedLetterSpacingPx = cellWidthPx - mockDomCharWidthPx;
|
||
}
|
||
|
||
// Mirrors FitAddon.proposeDimensions(): cols = floor(availableWidth /
|
||
// renderService.dimensions.css.cell.width) — the CANVAS-strategy-derived
|
||
// value when that strategy is active, matching the installed
|
||
// @xterm/addon-fit@0.10.0 source (`t.css.cell.width`).
|
||
const mockFitAddonFit = vi.fn(() => {
|
||
mockTerminalInstance.cols = Math.max(
|
||
1,
|
||
Math.floor(MOCK_CONTAINER_WIDTH_PX / mockCanvasCharWidthPx),
|
||
);
|
||
});
|
||
|
||
let terminalKeyEventHandler: ((event: KeyboardEvent) => boolean) | null = null;
|
||
let terminalDataHandler: ((data: string) => void) | null = null;
|
||
|
||
/*
|
||
FNXC:Terminal 2026-07-04-09:15:
|
||
Real xterm.js's OptionsService setter is a strict no-op (no onOptionChange fires,
|
||
so CharSizeService/DomRenderer never remeasure) whenever a caller reassigns an
|
||
option to a value that already equals the option's current value — see
|
||
`@xterm/xterm` `common/services/OptionsService.ts` `setter()`. The plain object
|
||
literal previously used for `mockTerminalInstance.options` could not model this
|
||
no-op-on-unchanged-value behavior, which is why prior FN-7456/FN-7460 coverage
|
||
could pass while the real recurrence (reassigning the SAME resolved font after
|
||
an async web-font settle never forces a genuine remeasure) stayed uncaught.
|
||
Track a `fontRemeasureCount` that only increments on a genuine (distinct-value)
|
||
fontFamily/fontSize transition so tests can assert xterm's measurement pipeline
|
||
was actually forced to recompute, not merely reassigned to an identical value.
|
||
*/
|
||
let fontRemeasureCount = 0;
|
||
function resetFontRemeasureCount(): void {
|
||
fontRemeasureCount = 0;
|
||
}
|
||
function getFontRemeasureCount(): number {
|
||
return fontRemeasureCount;
|
||
}
|
||
function createMockTerminalOptions(): Record<string, unknown> {
|
||
const store: Record<string, unknown> = {
|
||
fontSize: 14,
|
||
fontFamily: undefined,
|
||
cursorStyle: undefined,
|
||
cursorBlink: undefined,
|
||
};
|
||
const options: Record<string, unknown> = {};
|
||
for (const key of Object.keys(store)) {
|
||
Object.defineProperty(options, key, {
|
||
enumerable: true,
|
||
configurable: true,
|
||
get(): unknown {
|
||
return store[key];
|
||
},
|
||
set(value: unknown): void {
|
||
if (store[key] !== value) {
|
||
store[key] = value;
|
||
if (key === "fontFamily" || key === "fontSize") {
|
||
fontRemeasureCount += 1;
|
||
mockHandleCharSizeChanged();
|
||
}
|
||
}
|
||
},
|
||
});
|
||
}
|
||
return options;
|
||
}
|
||
|
||
const mockTerminalInstance = {
|
||
loadAddon: vi.fn(),
|
||
// FN-7603: mirror the real CharSizeService constructor's strategy
|
||
// selection, which happens synchronously inside terminal.open().
|
||
open: vi.fn(() => {
|
||
mockSelectCharSizeServiceStrategyAtOpen();
|
||
}),
|
||
onData: vi.fn((cb: (data: string) => void) => {
|
||
terminalDataHandler = cb;
|
||
return { dispose: vi.fn() };
|
||
}),
|
||
attachCustomKeyEventHandler: vi.fn((handler: (event: KeyboardEvent) => boolean) => {
|
||
terminalKeyEventHandler = handler;
|
||
}),
|
||
hasSelection: vi.fn(() => false),
|
||
getSelection: vi.fn(() => ""),
|
||
paste: vi.fn(),
|
||
dispose: vi.fn(),
|
||
write: vi.fn(),
|
||
clear: vi.fn(),
|
||
focus: vi.fn(),
|
||
refresh: vi.fn(),
|
||
options: createMockTerminalOptions(),
|
||
cols: 80,
|
||
rows: 24,
|
||
};
|
||
|
||
vi.mock("@xterm/xterm", () => ({
|
||
Terminal: vi.fn(function TerminalMock() {
|
||
return mockTerminalInstance;
|
||
}),
|
||
}));
|
||
|
||
vi.mock("@xterm/addon-fit", () => ({
|
||
FitAddon: vi.fn(function FitAddonMock() {
|
||
return {
|
||
fit: mockFitAddonFit,
|
||
dispose: vi.fn(),
|
||
};
|
||
}),
|
||
}));
|
||
|
||
vi.mock("@xterm/addon-web-links", () => ({
|
||
WebLinksAddon: vi.fn(function WebLinksAddonMock() {
|
||
return {
|
||
dispose: vi.fn(),
|
||
};
|
||
}),
|
||
}));
|
||
|
||
vi.mock("@xterm/addon-webgl", () => {
|
||
throw new Error("WebGL not available");
|
||
});
|
||
|
||
// Suppress xterm CSS import
|
||
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
|
||
|
||
const mockUseTerminal = vi.mocked(useTerminalModule.useTerminal);
|
||
const mockUseTerminalSessions = vi.mocked(useTerminalSessionsModule.useTerminalSessions);
|
||
const mockUseWorkspaces = vi.mocked(useWorkspacesModule.useWorkspaces);
|
||
const mockCreateTerminalSession = vi.mocked(apiModule.createTerminalSession);
|
||
const mockKillPtyTerminalSession = vi.mocked(apiModule.killPtyTerminalSession);
|
||
const TERMINAL_FONT_SIZE_KEY = LEGACY_TERMINAL_FONT_SIZE_KEY;
|
||
|
||
describe("ctrlChar/altChar helpers", () => {
|
||
it("maps Ctrl+C/D/Z/L and Alt sequences correctly", () => {
|
||
expect(ctrlChar("c")).toBe("\x03");
|
||
expect(ctrlChar("d")).toBe("\x04");
|
||
expect(ctrlChar("z")).toBe("\x1a");
|
||
expect(ctrlChar("l")).toBe("\x0c");
|
||
|
||
expect(altChar("c")).toBe("\x1bc");
|
||
expect(altChar("[")).toBe("\x1b[");
|
||
});
|
||
});
|
||
|
||
describe("evaluateTabsOverflow", () => {
|
||
it("collapses only after the tab content exceeds available width beyond hysteresis", () => {
|
||
expect(evaluateTabsOverflow({ scrollWidth: 201, clientWidth: 200 })).toBe(false);
|
||
expect(evaluateTabsOverflow({ scrollWidth: 202, clientWidth: 200 })).toBe(true);
|
||
expect(evaluateTabsOverflow({ scrollWidth: 200, clientWidth: 200 })).toBe(false);
|
||
expect(evaluateTabsOverflow({ scrollWidth: 201, clientWidth: 200, currentlyOverflowing: true })).toBe(true);
|
||
expect(evaluateTabsOverflow({ scrollWidth: 200, clientWidth: 200, currentlyOverflowing: true })).toBe(false);
|
||
expect(evaluateTabsOverflow({ scrollWidth: 300, clientWidth: 0 })).toBe(false);
|
||
});
|
||
});
|
||
|
||
// Default tab state
|
||
const defaultTab = {
|
||
id: "tab-1",
|
||
sessionId: "test-session-123",
|
||
title: "bash",
|
||
isActive: true,
|
||
createdAt: Date.now(),
|
||
};
|
||
|
||
const defaultSessionState = {
|
||
tabs: [defaultTab],
|
||
activeTab: defaultTab,
|
||
isReady: true,
|
||
bootstrapError: null,
|
||
createTab: vi.fn(),
|
||
closeTab: vi.fn(),
|
||
setActiveTab: vi.fn(),
|
||
updateTabTitle: vi.fn(),
|
||
restartActiveTab: vi.fn(),
|
||
retryBootstrap: vi.fn(),
|
||
replaceActiveTabSession: vi.fn().mockResolvedValue(undefined),
|
||
};
|
||
|
||
describe("TerminalModal", () => {
|
||
const mockOnClose = vi.fn();
|
||
const mockSendInput = vi.fn();
|
||
const mockResize = vi.fn();
|
||
const mockReconnect = vi.fn();
|
||
|
||
const createMockTerminalState = (overrides = {}) => ({
|
||
connectionStatus: "disconnected" as const,
|
||
sendInput: mockSendInput,
|
||
resize: mockResize,
|
||
onData: vi.fn(() => vi.fn()),
|
||
onExit: vi.fn(() => vi.fn()),
|
||
onConnect: vi.fn(() => vi.fn()),
|
||
onScrollback: vi.fn(() => vi.fn()),
|
||
reconnect: mockReconnect,
|
||
onSessionInvalid: vi.fn(() => vi.fn()),
|
||
...overrides,
|
||
});
|
||
|
||
beforeEach(async () => {
|
||
const xtermModule = await import("@xterm/xterm");
|
||
const fitAddonModule = await import("@xterm/addon-fit");
|
||
const webLinksAddonModule = await import("@xterm/addon-web-links");
|
||
vi.mocked(xtermModule.Terminal).mockImplementation(function TerminalMock() {
|
||
return mockTerminalInstance;
|
||
} as never);
|
||
vi.mocked(fitAddonModule.FitAddon).mockImplementation(function FitAddonMock() {
|
||
return {
|
||
fit: mockFitAddonFit,
|
||
dispose: vi.fn(),
|
||
};
|
||
} as never);
|
||
vi.mocked(webLinksAddonModule.WebLinksAddon).mockImplementation(function WebLinksAddonMock() {
|
||
return {
|
||
dispose: vi.fn(),
|
||
};
|
||
} as never);
|
||
vi.clearAllMocks();
|
||
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
||
matches: false,
|
||
media: query,
|
||
onchange: null,
|
||
addListener: vi.fn(),
|
||
removeListener: vi.fn(),
|
||
addEventListener: vi.fn(),
|
||
removeEventListener: vi.fn(),
|
||
dispatchEvent: vi.fn(),
|
||
}));
|
||
terminalKeyEventHandler = null;
|
||
terminalDataHandler = null;
|
||
mockTerminalInstance.onData.mockImplementation((cb: (data: string) => void) => {
|
||
terminalDataHandler = cb;
|
||
return { dispose: vi.fn() };
|
||
});
|
||
mockFitAddonFit.mockClear();
|
||
mockTerminalInstance.hasSelection.mockReturnValue(false);
|
||
mockTerminalInstance.getSelection.mockReturnValue("");
|
||
mockTerminalInstance.refresh.mockClear();
|
||
Object.defineProperty(navigator, "platform", {
|
||
value: "Win32",
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(navigator, "clipboard", {
|
||
value: undefined,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(document, "fonts", {
|
||
value: undefined,
|
||
configurable: true,
|
||
});
|
||
window.localStorage.removeItem(TERMINAL_FONT_SIZE_KEY);
|
||
window.localStorage.removeItem(TERMINAL_PREFERENCES_KEY);
|
||
mockTerminalInstance.options.fontFamily = XTERM_FONT_FAMILY;
|
||
mockTerminalInstance.options.fontSize = 14;
|
||
mockTerminalInstance.options.cursorStyle = "block";
|
||
mockTerminalInstance.options.cursorBlink = true;
|
||
resetFontRemeasureCount();
|
||
resetMockTerminalGeometry();
|
||
mockCreateTerminalSession.mockResolvedValue({
|
||
sessionId: "test-session-123",
|
||
shell: "/bin/bash",
|
||
cwd: "/project",
|
||
});
|
||
mockKillPtyTerminalSession.mockResolvedValue({ killed: true });
|
||
mockUseTerminal.mockReturnValue(createMockTerminalState());
|
||
mockUseTerminalSessions.mockReturnValue(defaultSessionState);
|
||
mockUseWorkspaces.mockReturnValue({
|
||
projectName: "kb",
|
||
workspaces: [],
|
||
loading: false,
|
||
error: null,
|
||
});
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.useRealTimers();
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
it("renders without crashing when open", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
it("does not render when closed", () => {
|
||
const { container } = render(<TerminalModal isOpen={false} onClose={mockOnClose} />);
|
||
expect(container.firstChild).toBeNull();
|
||
});
|
||
|
||
it("renders embedded mode in-flow without overlay chrome while keeping shell tabs", async () => {
|
||
const { container } = render(
|
||
<TerminalModal
|
||
isOpen={true}
|
||
onClose={mockOnClose}
|
||
embedded
|
||
defaultCwd="/project/.worktrees/FN-7813"
|
||
scopeId="FN-7813"
|
||
projectId="proj-123"
|
||
/>,
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
|
||
});
|
||
|
||
expect(screen.getByTestId("terminal-embedded-host")).toBeTruthy();
|
||
expect(container.querySelector(".terminal-embedded-host")).toBeTruthy();
|
||
expect(screen.queryByTestId("terminal-modal-overlay")).toBeNull();
|
||
expect(screen.queryByTestId("terminal-close-btn")).toBeNull();
|
||
expect(screen.queryByTestId("terminal-popout-toggle")).toBeNull();
|
||
expect(screen.queryByTestId("terminal-pin-toggle")).toBeNull();
|
||
expect(screen.getByTestId("terminal-tabs")).toBeTruthy();
|
||
expect(mockUseTerminalSessions).toHaveBeenCalledWith("proj-123", {
|
||
storageScope: "task:FN-7813",
|
||
defaultCwd: "/project/.worktrees/FN-7813",
|
||
});
|
||
});
|
||
|
||
it("keeps the fast new-terminal button and hides the workspace picker when no task worktrees exist", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
|
||
});
|
||
|
||
expect(screen.queryByTestId("terminal-workspace-picker")).toBeNull();
|
||
fireEvent.click(screen.getByLabelText("New terminal"));
|
||
expect(defaultSessionState.createTab).toHaveBeenCalledWith();
|
||
});
|
||
|
||
it("defaults the embedded picker to the workspace matching defaultCwd", async () => {
|
||
mockUseWorkspaces.mockReturnValue({
|
||
projectName: "kb",
|
||
workspaces: [
|
||
{ id: "FN-7832", label: "FN-7832", title: "Task terminal picker", worktree: "/repo/.worktrees/FN-7832", kind: "task" },
|
||
],
|
||
loading: false,
|
||
error: null,
|
||
});
|
||
|
||
render(
|
||
<TerminalModal
|
||
isOpen={true}
|
||
onClose={mockOnClose}
|
||
embedded
|
||
defaultCwd="/repo/.worktrees/FN-7832"
|
||
scopeId="FN-7832"
|
||
/>,
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByLabelText("Select terminal workspace: FN-7832")).toBeInTheDocument();
|
||
});
|
||
expect(screen.queryByLabelText("Select terminal workspace: Project Root")).toBeNull();
|
||
});
|
||
|
||
it("falls back to Project Root when embedded defaultCwd has no workspace match", async () => {
|
||
mockUseWorkspaces.mockReturnValue({
|
||
projectName: "kb",
|
||
workspaces: [
|
||
{ id: "FN-0001", label: "FN-0001", title: "Different task", worktree: "/repo/.worktrees/FN-0001", kind: "task" },
|
||
],
|
||
loading: false,
|
||
error: null,
|
||
});
|
||
|
||
render(
|
||
<TerminalModal
|
||
isOpen={true}
|
||
onClose={mockOnClose}
|
||
embedded
|
||
defaultCwd="/repo/.worktrees/FN-7832"
|
||
scopeId="FN-7832"
|
||
/>,
|
||
);
|
||
|
||
expect(await screen.findByLabelText("Select terminal workspace: Project Root")).toBeInTheDocument();
|
||
});
|
||
|
||
it("keeps the footer picker defaulted to Project Root when defaultCwd is omitted", async () => {
|
||
mockUseWorkspaces.mockReturnValue({
|
||
projectName: "kb",
|
||
workspaces: [
|
||
{ id: "FN-7832", label: "FN-7832", title: "Task terminal picker", worktree: "/repo/.worktrees/FN-7832", kind: "task" },
|
||
],
|
||
loading: false,
|
||
error: null,
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
expect(await screen.findByLabelText("Select terminal workspace: Project Root")).toBeInTheDocument();
|
||
});
|
||
|
||
it("opens a new terminal in the selected task worktree", async () => {
|
||
const createTab = vi.fn().mockResolvedValue(defaultTab);
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
createTab,
|
||
});
|
||
mockUseWorkspaces.mockReturnValue({
|
||
projectName: "kb",
|
||
workspaces: [
|
||
{ id: "FN-7253", label: "FN-7253", title: "Add worktree picker", worktree: "/repo/.worktrees/fn-7253", kind: "task" },
|
||
{ id: "FN-0000", label: "FN-0000", title: "Missing worktree", kind: "task" },
|
||
],
|
||
loading: false,
|
||
error: null,
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
fireEvent.click(screen.getByTitle("Select terminal workspace"));
|
||
expect(screen.getByText("No worktree").closest("button")).toBeDisabled();
|
||
fireEvent.click(screen.getByText("FN-7253"));
|
||
fireEvent.click(screen.getByLabelText("Open terminal in selected workspace"));
|
||
|
||
expect(createTab).toHaveBeenCalledWith({
|
||
cwd: "/repo/.worktrees/fn-7253",
|
||
title: "FN-7253",
|
||
});
|
||
});
|
||
|
||
it("keeps the docked worktree picker accessible with duplicate, missing, and long workspace data", async () => {
|
||
const createTab = vi.fn().mockResolvedValue(defaultTab);
|
||
const longTitle = "FN-9999 — Implement a very long terminal worktree picker title that must truncate before actions";
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [
|
||
{ ...defaultTab, title: "very-long-active-terminal-tab-title-that-should-not-push-actions" },
|
||
{ ...defaultTab, id: "tab-2", title: "another-long-tab", isActive: false },
|
||
{ ...defaultTab, id: "tab-3", title: "third-long-tab", isActive: false },
|
||
],
|
||
createTab,
|
||
});
|
||
mockUseWorkspaces.mockReturnValue({
|
||
projectName: "kb",
|
||
workspaces: [
|
||
{ id: "FN-9999", label: "FN-9999", title: longTitle, worktree: "/repo/.worktrees/duplicate", kind: "task" },
|
||
{ id: "FN-9998", label: "FN-9998", title: "Duplicate path", worktree: "/repo/.worktrees/duplicate", kind: "task" },
|
||
{ id: "FN-0000", label: "FN-0000", title: "Missing worktree", kind: "task" },
|
||
{ id: "FN-0001", label: "FN-0001", title: "Undefined worktree", worktree: undefined, kind: "task" },
|
||
],
|
||
loading: false,
|
||
error: null,
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
const modal = await screen.findByTestId("terminal-modal");
|
||
expect(modal).toHaveClass("terminal-modal--docked");
|
||
const trigger = screen.getByLabelText("Select terminal workspace: Project Root");
|
||
expect(trigger).toHaveAttribute("aria-haspopup", "listbox");
|
||
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||
expect(screen.getByLabelText("Open terminal in selected workspace")).toBeEnabled();
|
||
|
||
fireEvent.click(trigger);
|
||
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
||
expect(trigger).toHaveAttribute("aria-controls", "terminal-workspace-picker-menu");
|
||
expect(screen.getByRole("listbox", { name: "Select terminal workspace" })).toBeInTheDocument();
|
||
expect(screen.getByText("FN-9999")).toBeInTheDocument();
|
||
expect(screen.getByText("FN-9998")).toBeInTheDocument();
|
||
expect(screen.getAllByText("No worktree")).toHaveLength(2);
|
||
for (const missingOption of screen.getAllByText("No worktree")) {
|
||
const option = missingOption.closest("button");
|
||
expect(option).toBeDisabled();
|
||
expect(option).toHaveAttribute("aria-disabled", "true");
|
||
}
|
||
|
||
fireEvent.click(screen.getByText("FN-9998"));
|
||
fireEvent.click(screen.getByLabelText("Open terminal in selected workspace"));
|
||
expect(createTab).toHaveBeenCalledWith({ cwd: "/repo/.worktrees/duplicate", title: "FN-9998" });
|
||
});
|
||
|
||
function mockPopulatedTerminalWorkspaces(): void {
|
||
mockUseWorkspaces.mockReturnValue({
|
||
projectName: "kb",
|
||
workspaces: [
|
||
{ id: "FN-7253", label: "FN-7253", title: "Add worktree picker", worktree: "/repo/.worktrees/fn-7253", kind: "task" },
|
||
{ id: "FN-0000", label: "FN-0000", title: "Missing worktree", kind: "task" },
|
||
],
|
||
loading: false,
|
||
error: null,
|
||
});
|
||
}
|
||
|
||
function mockWorkspaceTriggerRect(trigger: Element, rect: Partial<DOMRect> = {}): void {
|
||
vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({
|
||
x: 220,
|
||
y: 18,
|
||
top: 18,
|
||
left: 220,
|
||
right: 352,
|
||
bottom: 54,
|
||
width: 132,
|
||
height: 36,
|
||
toJSON: () => ({}),
|
||
...rect,
|
||
} as DOMRect);
|
||
}
|
||
|
||
it("layers the floating workspace picker above the terminal and positions it before the rAF fallback", async () => {
|
||
const requestAnimationFrameSpy = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 123);
|
||
vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined);
|
||
mockPopulatedTerminalWorkspaces();
|
||
window.localStorage.setItem("fusion:terminal-display-mode-floating-layering", "floating");
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} projectId="floating-layering" />);
|
||
|
||
const modal = await screen.findByTestId("terminal-modal");
|
||
expect(modal).toHaveClass("terminal-modal--floating");
|
||
const trigger = screen.getByLabelText("Select terminal workspace: Project Root");
|
||
mockWorkspaceTriggerRect(trigger);
|
||
|
||
fireEvent.click(trigger);
|
||
|
||
const listbox = screen.getByRole("listbox", { name: "Select terminal workspace" });
|
||
expect(listbox.parentElement).toBe(document.body);
|
||
expect(requestAnimationFrameSpy).toHaveBeenCalled();
|
||
expect(Number.parseFloat(listbox.style.zIndex)).toBeGreaterThan(Number.parseFloat(modal.style.zIndex));
|
||
expect(listbox.style.top).not.toBe("");
|
||
expect(listbox.style.left).not.toBe("");
|
||
expect(listbox.style.width).not.toBe("");
|
||
expect(listbox.style.maxHeight).not.toBe("");
|
||
expect(listbox).not.toHaveStyle({ visibility: "hidden" });
|
||
expect(listbox).not.toHaveStyle({ pointerEvents: "none" });
|
||
expect(listbox).toHaveTextContent("Project Root");
|
||
expect(listbox).toHaveTextContent("FN-7253");
|
||
expect(screen.getByText("No worktree").closest("button")).toBeDisabled();
|
||
});
|
||
|
||
it.each([
|
||
["docked", { projectId: "workspace-picker-docked", displayMode: "docked", embedded: false, mobile: false }],
|
||
["below", { projectId: "workspace-picker-below", displayMode: "below", embedded: false, mobile: false }],
|
||
["embedded", { projectId: "workspace-picker-embedded", displayMode: "docked", embedded: true, mobile: false }],
|
||
["mobile", { projectId: "workspace-picker-mobile", displayMode: "docked", embedded: false, mobile: true }],
|
||
])("keeps the workspace picker positioned in %s terminal mode", async (_label, config) => {
|
||
mockPopulatedTerminalWorkspaces();
|
||
const previousInnerWidth = window.innerWidth;
|
||
const previousInnerHeight = window.innerHeight;
|
||
const previousOntouchstart = window.ontouchstart;
|
||
window.localStorage.setItem(`fusion:terminal-display-mode-${config.projectId}`, config.displayMode);
|
||
if (config.mobile) {
|
||
Object.defineProperty(window, "innerWidth", { value: 390, configurable: true });
|
||
Object.defineProperty(window, "innerHeight", { value: 720, configurable: true });
|
||
Object.defineProperty(window, "ontouchstart", { value: null, configurable: true });
|
||
_resetInitialViewportHeight();
|
||
}
|
||
|
||
try {
|
||
render(
|
||
<TerminalModal
|
||
isOpen={true}
|
||
onClose={mockOnClose}
|
||
projectId={config.projectId}
|
||
embedded={config.embedded}
|
||
scopeId={config.embedded ? "FN-7253" : undefined}
|
||
/>,
|
||
);
|
||
|
||
const modal = await screen.findByTestId("terminal-modal");
|
||
if (config.displayMode === "below" && !config.mobile && !config.embedded) {
|
||
expect(modal).toHaveClass("terminal-modal--below");
|
||
} else if (config.embedded) {
|
||
expect(screen.getByTestId("terminal-embedded-host")).toBeInTheDocument();
|
||
} else if (config.mobile) {
|
||
expect(modal).not.toHaveClass("terminal-modal--floating");
|
||
expect(modal).not.toHaveClass("terminal-modal--docked");
|
||
} else {
|
||
expect(modal).toHaveClass("terminal-modal--docked");
|
||
}
|
||
|
||
const trigger = screen.getByLabelText("Select terminal workspace: Project Root");
|
||
mockWorkspaceTriggerRect(trigger, config.mobile ? { right: 360, width: 140 } : {});
|
||
fireEvent.click(trigger);
|
||
const listbox = screen.getByRole("listbox", { name: "Select terminal workspace" });
|
||
expect(listbox.parentElement).toBe(document.body);
|
||
expect(listbox.style.top).not.toBe("");
|
||
expect(listbox.style.left).not.toBe("");
|
||
expect(listbox).not.toHaveStyle({ visibility: "hidden" });
|
||
expect(listbox).toHaveTextContent("Project Root");
|
||
expect(listbox).toHaveTextContent("FN-7253");
|
||
} finally {
|
||
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
|
||
Object.defineProperty(window, "innerHeight", { value: previousInnerHeight, configurable: true });
|
||
if (previousOntouchstart === undefined) {
|
||
delete (window as any).ontouchstart;
|
||
} else {
|
||
Object.defineProperty(window, "ontouchstart", { value: previousOntouchstart, configurable: true });
|
||
}
|
||
_resetInitialViewportHeight();
|
||
}
|
||
});
|
||
|
||
it("keeps floating and mobile worktree menus reachable and dismissible without orphaned controls", async () => {
|
||
const createTab = vi.fn().mockResolvedValue(defaultTab);
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
createTab,
|
||
});
|
||
mockUseWorkspaces.mockReturnValue({
|
||
projectName: "kb",
|
||
workspaces: [
|
||
{ id: "FN-7253", label: "FN-7253", title: "Add worktree picker", worktree: "/repo/.worktrees/fn-7253", kind: "task" },
|
||
],
|
||
loading: false,
|
||
error: null,
|
||
});
|
||
window.localStorage.setItem("fusion:terminal-display-mode-floating-picker", "floating");
|
||
|
||
const { unmount } = render(<TerminalModal isOpen={true} onClose={mockOnClose} projectId="floating-picker" />);
|
||
expect(await screen.findByTestId("terminal-modal")).toHaveClass("terminal-modal--floating");
|
||
fireEvent.click(screen.getByLabelText("Select terminal workspace: Project Root"));
|
||
expect(screen.getByRole("listbox", { name: "Select terminal workspace" })).toBeInTheDocument();
|
||
fireEvent.pointerDown(document.body);
|
||
expect(screen.queryByRole("listbox", { name: "Select terminal workspace" })).toBeNull();
|
||
unmount();
|
||
|
||
const previousInnerWidth = window.innerWidth;
|
||
const previousInnerHeight = window.innerHeight;
|
||
const previousOntouchstart = window.ontouchstart;
|
||
Object.defineProperty(window, "innerWidth", { value: 390, configurable: true });
|
||
Object.defineProperty(window, "innerHeight", { value: 720, configurable: true });
|
||
Object.defineProperty(window, "ontouchstart", { value: null, configurable: true });
|
||
try {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} projectId="mobile-picker" />);
|
||
const mobileModal = await screen.findByTestId("terminal-modal");
|
||
expect(mobileModal).not.toHaveClass("terminal-modal--docked");
|
||
expect(mobileModal).not.toHaveClass("terminal-modal--floating");
|
||
const trigger = screen.getByLabelText("Select terminal workspace: Project Root");
|
||
vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({
|
||
x: 220,
|
||
y: 18,
|
||
top: 18,
|
||
left: 220,
|
||
right: 352,
|
||
bottom: 54,
|
||
width: 132,
|
||
height: 36,
|
||
toJSON: () => ({}),
|
||
} as DOMRect);
|
||
fireEvent.click(trigger);
|
||
const listbox = screen.getByRole("listbox", { name: "Select terminal workspace" });
|
||
expect(listbox).toBeInTheDocument();
|
||
expect(listbox.parentElement).toBe(document.body);
|
||
expect(listbox).toHaveTextContent("Project Root");
|
||
expect(listbox).toHaveTextContent("FN-7253");
|
||
await waitFor(() => {
|
||
expect(listbox).toHaveStyle({ position: "fixed" });
|
||
expect(Number.parseFloat(listbox.style.left)).toBeGreaterThanOrEqual(0);
|
||
expect(Number.parseFloat(listbox.style.top)).toBeGreaterThanOrEqual(0);
|
||
expect(Number.parseFloat(listbox.style.width)).toBeLessThanOrEqual(390);
|
||
expect(Number.parseFloat(listbox.style.maxHeight)).toBeLessThanOrEqual(720);
|
||
});
|
||
fireEvent.keyDown(document, { key: "Escape" });
|
||
expect(screen.queryByRole("listbox", { name: "Select terminal workspace" })).toBeNull();
|
||
expect(trigger).not.toHaveAttribute("aria-controls");
|
||
expect(mockOnClose).not.toHaveBeenCalled();
|
||
} finally {
|
||
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
|
||
Object.defineProperty(window, "innerHeight", { value: previousInnerHeight, configurable: true });
|
||
if (previousOntouchstart === undefined) {
|
||
delete (window as any).ontouchstart;
|
||
} else {
|
||
Object.defineProperty(window, "ontouchstart", { value: previousOntouchstart, configurable: true });
|
||
}
|
||
}
|
||
});
|
||
|
||
it("avoids inert workspace picker shells while loading or after workspace fetch errors", async () => {
|
||
mockUseWorkspaces.mockReturnValue({
|
||
projectName: "kb",
|
||
workspaces: [
|
||
{ id: "FN-7253", label: "FN-7253", title: "Loading stays usable", worktree: "/repo/.worktrees/fn-7253", kind: "task" },
|
||
],
|
||
loading: true,
|
||
error: null,
|
||
});
|
||
|
||
const { rerender } = render(<TerminalModal isOpen={true} onClose={mockOnClose} projectId="loading-picker" />);
|
||
fireEvent.click(await screen.findByLabelText("Select terminal workspace: Project Root"));
|
||
expect(screen.getByText("Task worktrees (refreshing…)")).toBeInTheDocument();
|
||
expect(screen.getByLabelText("Open terminal in selected workspace")).toBeEnabled();
|
||
|
||
mockUseWorkspaces.mockReturnValue({
|
||
projectName: "kb",
|
||
workspaces: [
|
||
{ id: "FN-7253", label: "FN-7253", title: "Stale entry hidden on error", worktree: "/repo/.worktrees/fn-7253", kind: "task" },
|
||
],
|
||
loading: false,
|
||
error: "failed",
|
||
});
|
||
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} projectId="loading-picker" />);
|
||
|
||
expect(screen.queryByTestId("terminal-workspace-picker")).toBeNull();
|
||
fireEvent.click(screen.getByLabelText("New terminal"));
|
||
expect(defaultSessionState.createTab).toHaveBeenCalledWith();
|
||
});
|
||
|
||
it("bounds terminal worktree picker and tab labels so header actions stay reachable", () => {
|
||
const tabRule = terminalModalCss.match(/\.terminal-tab\s*\{([^}]*)\}/)?.[1] ?? "";
|
||
const tabLabelRule = terminalModalCss.match(/\.terminal-tab-label\s*\{([^}]*)\}/)?.[1] ?? "";
|
||
const triggerRule = terminalModalCss.match(/\.terminal-workspace-picker-trigger\s*\{([^}]*)\}/)?.[1] ?? "";
|
||
const menuRule = terminalModalCss.match(/\.terminal-workspace-picker-menu\s*\{([^}]*)\}/)?.[1] ?? "";
|
||
const actionsRule = terminalModalCss.match(/\.terminal-actions\s*\{([^}]*)\}/)?.[1] ?? "";
|
||
const mobileTabsRule = terminalModalCss.match(/\.terminal-mobile-tabs\s*\{([^}]*)\}/)?.[1] ?? "";
|
||
const mobileSelectRule = terminalModalCss.match(/\.terminal-mobile-tab-select\s*\{([^}]*)\}/)?.[1] ?? "";
|
||
const mobileHeaderRule = terminalModalCss.match(/@media \(max-width: 768px\) \{[\s\S]*?\.terminal-header\s*\{([^}]*)\}/)?.[1] ?? "";
|
||
const mobileTerminalTabsRule = terminalModalCss.match(/@media \(max-width: 768px\) \{[\s\S]*?\.terminal-tabs\s*\{([^}]*)\}/)?.[1] ?? "";
|
||
const mobileSelectorRule = terminalModalCss.match(/@media \(max-width: 768px\) \{[\s\S]*?\.terminal-mobile-tabs\s*\{([^}]*)\}/)?.[1] ?? "";
|
||
const mobileRule = terminalModalCss.match(/@media \(max-width: 768px\) \{[\s\S]*?\.terminal-workspace-picker-menu\s*\{([^}]*)\}/)?.[1] ?? "";
|
||
|
||
expect(tabRule).toContain("max-width: min(260px, 42vw);");
|
||
expect(tabLabelRule).toContain("text-overflow: ellipsis;");
|
||
expect(mobileTabsRule).toContain("display: none;");
|
||
expect(mobileSelectRule).toContain("text-overflow: ellipsis;");
|
||
expect(triggerRule).toContain("width: clamp(112px, 16vw, 220px);");
|
||
expect(menuRule).toContain("position: fixed;");
|
||
expect(menuRule).toContain("width: min(var(--terminal-workspace-menu-width), calc(100vw - (var(--space-md) * 2)));");
|
||
expect(menuRule).toContain("max-height: min(var(--terminal-workspace-menu-height), calc(100dvh - (var(--space-md) * 2)));");
|
||
expect(menuRule).toContain("overflow-y: auto;");
|
||
expect(menuRule).toContain("overscroll-behavior: contain;");
|
||
expect(actionsRule).toContain("flex: 1 1 auto;");
|
||
expect(actionsRule).toContain("overflow-x: auto;");
|
||
expect(mobileHeaderRule).toContain("flex-wrap: wrap;");
|
||
expect(mobileHeaderRule).toContain("overflow: hidden;");
|
||
expect(mobileTerminalTabsRule).toContain("display: none;");
|
||
expect(mobileSelectorRule).toContain("display: flex;");
|
||
expect(mobileSelectorRule).toContain("min-width: 0;");
|
||
expect(mobileRule).not.toContain("right:");
|
||
expect(mobileRule).toContain("width: min(var(--terminal-workspace-menu-width), calc(100vw - (var(--space-sm) * 2)));");
|
||
expect(mobileRule).toContain("-webkit-overflow-scrolling: touch;");
|
||
});
|
||
|
||
it("renders desktop terminal as a docked bottom panel and refits after top-handle resize", async () => {
|
||
const projectId = "docked-resize-test";
|
||
window.localStorage.removeItem(`fusion:terminal-docked-height-${projectId}`);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} projectId={projectId} />);
|
||
|
||
const modal = await screen.findByTestId("terminal-modal");
|
||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
|
||
expect(modal).toHaveClass("terminal-modal--docked");
|
||
expect(modal).not.toHaveClass("terminal-modal--floating");
|
||
|
||
const fitCallBaseline = mockFitAddonFit.mock.calls.length;
|
||
// FNXC:Terminal 2026-06-22-19:50: The resize handlers now capture the pointer and listen on the CAPTURED handle element (not document), so move/up are fired on the handle with the matching pointerId; stub setPointerCapture/releasePointerCapture (jsdom no-ops).
|
||
const handle = screen.getByTestId("terminal-docked-resize-handle") as HTMLElement & { setPointerCapture: (pointerId: number) => void; releasePointerCapture: (pointerId: number) => void };
|
||
handle.setPointerCapture = vi.fn();
|
||
handle.releasePointerCapture = vi.fn();
|
||
|
||
fireEvent.pointerDown(handle, { pointerId: 1, clientY: 500 });
|
||
fireEvent.pointerMove(handle, { pointerId: 1, clientY: 420 });
|
||
fireEvent.pointerUp(handle, { pointerId: 1 });
|
||
|
||
await waitFor(() => {
|
||
expect(window.localStorage.getItem(`fusion:terminal-docked-height-${projectId}`)).toBe("440");
|
||
expect(mockFitAddonFit.mock.calls.length).toBeGreaterThan(fitCallBaseline);
|
||
});
|
||
});
|
||
|
||
it("toggles between docked and floating terminal modes with the pop-out control", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} projectId="popout-toggle-test" />);
|
||
|
||
const modal = await screen.findByTestId("terminal-modal");
|
||
expect(modal).toHaveClass("terminal-modal--docked");
|
||
expect(screen.getByTestId("terminal-docked-resize-handle")).toBeInTheDocument();
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-popout-toggle"));
|
||
|
||
await waitFor(() => {
|
||
expect(modal).toHaveClass("terminal-modal--floating");
|
||
expect(modal).not.toHaveClass("terminal-modal--docked");
|
||
expect(screen.getByTestId("terminal-floating-resize-se")).toBeInTheDocument();
|
||
});
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-popout-toggle"));
|
||
|
||
await waitFor(() => {
|
||
expect(modal).toHaveClass("terminal-modal--docked");
|
||
expect(screen.getByTestId("terminal-docked-resize-handle")).toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
it("defaults missing and invalid display-mode storage to overlay docked mode", async () => {
|
||
const missingProjectId = "missing-display-mode-test";
|
||
window.localStorage.removeItem(`fusion:terminal-display-mode-${missingProjectId}`);
|
||
|
||
const { unmount } = render(<TerminalModal isOpen={true} onClose={mockOnClose} projectId={missingProjectId} />);
|
||
expect(await screen.findByTestId("terminal-modal")).toHaveClass("terminal-modal--docked");
|
||
expect(screen.getByTestId("terminal-modal-overlay")).toBeInTheDocument();
|
||
expect(screen.queryByTestId("terminal-below-host")).toBeNull();
|
||
unmount();
|
||
|
||
const invalidProjectId = "invalid-display-mode-test";
|
||
window.localStorage.setItem(`fusion:terminal-display-mode-${invalidProjectId}`, "sideways");
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} projectId={invalidProjectId} />);
|
||
|
||
expect(await screen.findByTestId("terminal-modal")).toHaveClass("terminal-modal--docked");
|
||
expect(screen.getByTestId("terminal-modal-overlay")).toBeInTheDocument();
|
||
expect(screen.queryByTestId("terminal-below-host")).toBeNull();
|
||
});
|
||
|
||
it("pins and persists the terminal below the application with right-dock-style labels", async () => {
|
||
const projectId = "below-pin-test";
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} projectId={projectId} />);
|
||
|
||
const pin = await screen.findByTestId("terminal-pin-toggle");
|
||
expect(pin).toHaveAttribute("aria-label", "Pin terminal (push content)");
|
||
expect(pin).toHaveAttribute("title", "Pin terminal (push content)");
|
||
expect(pin).toHaveAttribute("aria-pressed", "false");
|
||
|
||
fireEvent.click(pin);
|
||
|
||
await waitFor(() => {
|
||
expect(window.localStorage.getItem(`fusion:terminal-display-mode-${projectId}`)).toBe("below");
|
||
expect(screen.getByTestId("terminal-below-host")).toBeInTheDocument();
|
||
expect(screen.queryByTestId("terminal-modal-overlay")).toBeNull();
|
||
expect(screen.getByTestId("terminal-modal")).toHaveClass("terminal-modal--below");
|
||
});
|
||
expect(screen.getByTestId("terminal-pin-toggle")).toHaveAttribute("aria-label", "Unpin terminal (overlay content)");
|
||
expect(screen.getByTestId("terminal-pin-toggle")).toHaveAttribute("aria-pressed", "true");
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-pin-toggle"));
|
||
await waitFor(() => {
|
||
expect(window.localStorage.getItem(`fusion:terminal-display-mode-${projectId}`)).toBe("docked");
|
||
expect(screen.getByTestId("terminal-modal-overlay")).toBeInTheDocument();
|
||
expect(screen.queryByTestId("terminal-below-host")).toBeNull();
|
||
});
|
||
});
|
||
|
||
// FN-7897: below-mode is reachable at both true desktop (>1024px) and tablet
|
||
// (769-1024px) widths — isBelowMode has no additional breakpoint gating beyond
|
||
// "not mobile". Exercise both explicitly (rather than relying on jsdom's default
|
||
// 1024px width, which sits exactly on the tablet/desktop boundary) so the fix is
|
||
// proven at a clearly-desktop viewport, not just the ambiguous default.
|
||
it.each([
|
||
["desktop", 1440],
|
||
["tablet", 900],
|
||
])(
|
||
"reserves executor footer height on the pinned terminal host when footerVisible is true (%s width, FN-7897)",
|
||
async (_label, width) => {
|
||
const previousInnerWidth = window.innerWidth;
|
||
Object.defineProperty(window, "innerWidth", { value: width, configurable: true });
|
||
try {
|
||
const projectId = `below-pin-footer-visible-${width}`;
|
||
render(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} projectId={projectId} footerVisible={true} />,
|
||
);
|
||
|
||
const pin = await screen.findByTestId("terminal-pin-toggle");
|
||
fireEvent.click(pin);
|
||
|
||
await waitFor(() => {
|
||
const host = screen.getByTestId("terminal-below-host");
|
||
expect(host).toHaveClass("terminal-below-host");
|
||
expect(host).toHaveClass("terminal-below-host--with-footer");
|
||
});
|
||
} finally {
|
||
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
|
||
}
|
||
},
|
||
);
|
||
|
||
it("does not reserve executor footer height on the pinned terminal host when footerVisible is false or omitted (FN-7897)", async () => {
|
||
// Default width (no explicit resize) — the negative-case default/omitted-prop path.
|
||
const projectId = "below-pin-footer-hidden";
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} projectId={projectId} />);
|
||
|
||
const pin = await screen.findByTestId("terminal-pin-toggle");
|
||
fireEvent.click(pin);
|
||
|
||
await waitFor(() => {
|
||
const host = screen.getByTestId("terminal-below-host");
|
||
expect(host).toHaveClass("terminal-below-host");
|
||
expect(host).not.toHaveClass("terminal-below-host--with-footer");
|
||
});
|
||
|
||
// Explicit footerVisible={false} behaves identically to the omitted-prop default.
|
||
const explicitFalseProjectId = "below-pin-footer-explicit-false";
|
||
render(
|
||
<TerminalModal
|
||
isOpen={true}
|
||
onClose={mockOnClose}
|
||
projectId={explicitFalseProjectId}
|
||
footerVisible={false}
|
||
/>,
|
||
);
|
||
const pins = await screen.findAllByTestId("terminal-pin-toggle");
|
||
fireEvent.click(pins[pins.length - 1]);
|
||
|
||
await waitFor(() => {
|
||
const hosts = screen.getAllByTestId("terminal-below-host");
|
||
const lastHost = hosts[hosts.length - 1];
|
||
expect(lastHost).not.toHaveClass("terminal-below-host--with-footer");
|
||
});
|
||
});
|
||
|
||
it("tracks footerVisible across re-renders with no stale --with-footer class (pin \u2192 unpin \u2192 re-pin, FN-7897)", async () => {
|
||
const projectId = "below-pin-footer-toggle-sequence";
|
||
const { rerender } = render(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} projectId={projectId} footerVisible={true} />,
|
||
);
|
||
|
||
const pin = await screen.findByTestId("terminal-pin-toggle");
|
||
fireEvent.click(pin);
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-below-host")).toHaveClass("terminal-below-host--with-footer");
|
||
});
|
||
|
||
// Simulate navigating away from "project" view mode while pinned: footerVisible flips to false.
|
||
rerender(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} projectId={projectId} footerVisible={false} />,
|
||
);
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-below-host")).not.toHaveClass("terminal-below-host--with-footer");
|
||
});
|
||
|
||
// Unpin (back to overlay docked mode), then re-pin with footerVisible restored to true.
|
||
fireEvent.click(screen.getByTestId("terminal-pin-toggle"));
|
||
await waitFor(() => {
|
||
expect(screen.queryByTestId("terminal-below-host")).toBeNull();
|
||
});
|
||
|
||
rerender(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} projectId={projectId} footerVisible={true} />,
|
||
);
|
||
fireEvent.click(screen.getByTestId("terminal-pin-toggle"));
|
||
await waitFor(() => {
|
||
const host = screen.getByTestId("terminal-below-host");
|
||
expect(host).toHaveClass("terminal-below-host--with-footer");
|
||
// No stale/duplicate class fragments from prior mount/unmount cycles.
|
||
expect(host.className.match(/terminal-below-host--with-footer/g)?.length ?? 0).toBe(1);
|
||
});
|
||
});
|
||
|
||
it("keeps floating and mobile modes out of the below-layout shell", async () => {
|
||
const floatingProjectId = "floating-no-below-shell";
|
||
window.localStorage.setItem(`fusion:terminal-display-mode-${floatingProjectId}`, "floating");
|
||
const { unmount } = render(<TerminalModal isOpen={true} onClose={mockOnClose} projectId={floatingProjectId} />);
|
||
expect(await screen.findByTestId("terminal-modal")).toHaveClass("terminal-modal--floating");
|
||
expect(screen.queryByTestId("terminal-below-host")).toBeNull();
|
||
unmount();
|
||
|
||
const previousInnerWidth = window.innerWidth;
|
||
const previousOntouchstart = window.ontouchstart;
|
||
Object.defineProperty(window, "innerWidth", { value: 500, configurable: true });
|
||
Object.defineProperty(window, "ontouchstart", { value: null, configurable: true });
|
||
try {
|
||
window.localStorage.setItem("fusion:terminal-display-mode-mobile-no-below-shell", "below");
|
||
// FN-7897: footerVisible={true} must not resurrect the below-layout shell (or its
|
||
// --with-footer reservation) on the mobile fullscreen-sheet fallback path.
|
||
render(
|
||
<TerminalModal
|
||
isOpen={true}
|
||
onClose={mockOnClose}
|
||
projectId="mobile-no-below-shell"
|
||
footerVisible={true}
|
||
/>,
|
||
);
|
||
expect(await screen.findByTestId("terminal-modal")).not.toHaveClass("terminal-modal--below");
|
||
expect(screen.queryByTestId("terminal-below-host")).toBeNull();
|
||
expect(screen.queryByTestId("terminal-pin-toggle")).toBeNull();
|
||
} finally {
|
||
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
|
||
if (previousOntouchstart === undefined) {
|
||
delete (window as any).ontouchstart;
|
||
} else {
|
||
Object.defineProperty(window, "ontouchstart", { value: previousOntouchstart, configurable: true });
|
||
}
|
||
}
|
||
});
|
||
|
||
it("encodes below-terminal in-flow layout without fixed overlay geometry", () => {
|
||
const hostRule = terminalModalCss.match(/\.terminal-below-host\s*\{([^}]*)\}/)?.[1] ?? "";
|
||
const belowRule = terminalModalCss.match(/\.modal\.terminal-modal\.terminal-modal--below\s*\{([^}]*)\}/)?.[1] ?? "";
|
||
|
||
expect(hostRule).toContain("display: flex;");
|
||
expect(belowRule).toContain("position: relative;");
|
||
expect(belowRule).not.toContain("position: fixed;");
|
||
expect(belowRule).toContain("height: var(--terminal-below-height);");
|
||
|
||
// FN-7829: the `.terminal-status-bar` footer is now the in-flow action-control location at every breakpoint, including below mode. It must remain an unconditional base rule with the horizontal-scroll safeguards that keep the single footer cluster reachable.
|
||
const footerRule = terminalModalCss.match(/\.terminal-status-bar\s*\{([^}]*)\}/)?.[1] ?? "";
|
||
expect(footerRule).toContain("display: flex;");
|
||
expect(footerRule).toContain("min-width: 0;");
|
||
expect(footerRule).toContain("overflow-x: auto;");
|
||
expect(footerRule).toContain("touch-action: pan-x pan-y;");
|
||
|
||
// FN-7897: the base .terminal-below-host rule must NOT reserve footer space unconditionally
|
||
// (that would leave a dead gap when the footer is not visible) — only the --with-footer
|
||
// modifier below reserves it.
|
||
expect(hostRule).not.toContain("padding-bottom");
|
||
});
|
||
|
||
it("reserves executor footer height on the pinned terminal host only when footerVisible (FN-7897)", () => {
|
||
// .terminal-below-host is a SIBLING of .dashboard-project-shell inside .dashboard-project-stack
|
||
// (not a descendant), so it cannot rely on --executor-footer-height being inherited from the
|
||
// shell the way .left-sidebar-nav--with-footer/.right-dock--with-footer do — it must redeclare
|
||
// the token at this consumer, matching the .project-content--with-footer precedent.
|
||
const withFooterRule =
|
||
terminalModalCss.match(/\.terminal-below-host--with-footer\s*\{([^}]*)\}/)?.[1] ?? "";
|
||
|
||
expect(withFooterRule).toContain("--executor-footer-height: 36px;");
|
||
expect(withFooterRule).toContain(
|
||
"padding-bottom: calc(var(--icb-bottom-offset, 0px) + var(--executor-footer-height));",
|
||
);
|
||
});
|
||
|
||
it("exposes floating drag and resize handles and refits after floating resize", async () => {
|
||
const projectId = "floating-resize-test";
|
||
window.localStorage.setItem(`fusion:terminal-display-mode-${projectId}`, "floating");
|
||
window.localStorage.removeItem(`fusion:terminal-modal-size-${projectId}`);
|
||
window.localStorage.removeItem(`fusion:terminal-float-pos-${projectId}`);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} projectId={projectId} />);
|
||
|
||
const modal = await screen.findByTestId("terminal-modal");
|
||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
|
||
expect(modal).toHaveClass("terminal-modal--floating");
|
||
expect(screen.getByTestId("terminal-floating-resize-n")).toBeInTheDocument();
|
||
expect(screen.getByTestId("terminal-floating-resize-se")).toBeInTheDocument();
|
||
|
||
const fitCallBaseline = mockFitAddonFit.mock.calls.length;
|
||
// FNXC:Terminal 2026-06-22-19:50: Floating resize/drag now capture the pointer and listen on the CAPTURED element (not document); fire move/up on that element with the matching pointerId and stub set/releasePointerCapture.
|
||
const resizeHandle = screen.getByTestId("terminal-floating-resize-se") as HTMLElement & { setPointerCapture: (pointerId: number) => void; releasePointerCapture: (pointerId: number) => void };
|
||
resizeHandle.setPointerCapture = vi.fn();
|
||
resizeHandle.releasePointerCapture = vi.fn();
|
||
|
||
fireEvent.pointerDown(resizeHandle, { pointerId: 1, clientX: 100, clientY: 100 });
|
||
fireEvent.pointerMove(resizeHandle, { pointerId: 1, clientX: 140, clientY: 130 });
|
||
fireEvent.pointerUp(resizeHandle, { pointerId: 1 });
|
||
|
||
await waitFor(() => {
|
||
expect(window.localStorage.getItem(`fusion:terminal-modal-size-${projectId}`)).toBe(JSON.stringify({ width: 992, height: 590 }));
|
||
expect(mockFitAddonFit.mock.calls.length).toBeGreaterThan(fitCallBaseline);
|
||
});
|
||
|
||
const header = modal.querySelector(".terminal-header") as HTMLElement & { setPointerCapture: (pointerId: number) => void; releasePointerCapture: (pointerId: number) => void };
|
||
header.setPointerCapture = vi.fn();
|
||
header.releasePointerCapture = vi.fn();
|
||
fireEvent.pointerDown(header, { pointerId: 2, pointerType: "touch", clientX: 100, clientY: 100 });
|
||
fireEvent.pointerMove(header, { pointerId: 2, pointerType: "touch", clientX: 125, clientY: 135 });
|
||
fireEvent.pointerUp(header, { pointerId: 2, pointerType: "touch" });
|
||
|
||
await waitFor(() => {
|
||
expect(window.localStorage.getItem(`fusion:terminal-float-pos-${projectId}`)).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
it("keeps the floating terminal touch-draggable with theme-controlled shadow", () => {
|
||
const panelRule = terminalModalCss.match(/\.modal\.terminal-modal\.terminal-modal--floating\s*\{([^}]*)\}/)?.[1] ?? "";
|
||
const headerRule = terminalModalCss.match(/\.terminal-header--draggable\s*\{([^}]*)\}/)?.[1] ?? "";
|
||
|
||
expect(panelRule).toContain("box-shadow: var(--floating-window-shadow, var(--shadow-lg));");
|
||
expect(headerRule).toContain("touch-action: none;");
|
||
expect(headerRule).toContain("min-height: 48px;");
|
||
expect(terminalModalCss).not.toContain("var(--shadow-xl)");
|
||
});
|
||
|
||
it("keeps mobile terminal on the full-screen modal path without docked or floating controls", async () => {
|
||
const previousInnerWidth = window.innerWidth;
|
||
const previousOntouchstart = window.ontouchstart;
|
||
Object.defineProperty(window, "innerWidth", { value: 500, configurable: true });
|
||
Object.defineProperty(window, "ontouchstart", { value: null, configurable: true });
|
||
|
||
try {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} projectId="mobile-fullscreen-test" />);
|
||
|
||
const modal = await screen.findByTestId("terminal-modal");
|
||
expect(modal).not.toHaveClass("terminal-modal--docked");
|
||
expect(modal).not.toHaveClass("terminal-modal--floating");
|
||
expect(screen.queryByTestId("terminal-docked-resize-handle")).toBeNull();
|
||
expect(screen.queryByTestId("terminal-popout-toggle")).toBeNull();
|
||
expect(screen.queryByTestId("terminal-floating-resize-se")).toBeNull();
|
||
} finally {
|
||
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
|
||
if (previousOntouchstart === undefined) {
|
||
delete (window as any).ontouchstart;
|
||
} else {
|
||
Object.defineProperty(window, "ontouchstart", { value: previousOntouchstart, configurable: true });
|
||
}
|
||
}
|
||
});
|
||
|
||
it("shows loading state while sessions are not ready", async () => {
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
isReady: false,
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-loading")).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
it("shows error with retry and refresh buttons when bootstrap fails instead of stuck loading", async () => {
|
||
const mockRetryBootstrap = vi.fn();
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [],
|
||
activeTab: null,
|
||
bootstrapError: "Server unreachable",
|
||
retryBootstrap: mockRetryBootstrap,
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
// Should NOT show the loading spinner
|
||
await waitFor(() => {
|
||
expect(screen.queryByTestId("terminal-loading")).toBeNull();
|
||
});
|
||
|
||
// Should show the bootstrap error state with retry + refresh buttons
|
||
expect(screen.getByTestId("terminal-bootstrap-error")).toBeTruthy();
|
||
expect(screen.getByText(/Failed to start terminal: Server unreachable/)).toBeTruthy();
|
||
|
||
const retryBtn = screen.getByTestId("terminal-retry-btn");
|
||
expect(retryBtn).toBeTruthy();
|
||
expect(retryBtn.textContent).toContain("Retry");
|
||
|
||
const refreshBtn = screen.getByTestId("terminal-bootstrap-refresh-btn");
|
||
expect(refreshBtn).toBeTruthy();
|
||
expect(refreshBtn.textContent).toContain("Refresh page");
|
||
});
|
||
|
||
it("shows Windows Terminal startup guidance inline on desktop and mobile without native dialog hooks", async () => {
|
||
const mockRetryBootstrap = vi.fn();
|
||
const windowsTerminalMessage =
|
||
"Fusion could not start an embedded terminal shell on Windows. Use Command Prompt or PowerShell for the embedded terminal, or install/repair Windows Terminal separately with `winget install Microsoft.WindowsTerminal` if you want Windows Terminal outside Fusion.";
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [],
|
||
activeTab: null,
|
||
bootstrapError: windowsTerminalMessage,
|
||
retryBootstrap: mockRetryBootstrap,
|
||
});
|
||
|
||
const { rerender } = render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
expect(screen.getByTestId("terminal-bootstrap-error")).toHaveTextContent("Command Prompt or PowerShell");
|
||
expect(screen.getByTestId("terminal-bootstrap-error")).toHaveTextContent("winget install Microsoft.WindowsTerminal");
|
||
expect(screen.getByTestId("terminal-bootstrap-error")).not.toHaveTextContent("1.24.11321.0");
|
||
expect(screen.getByTestId("terminal-retry-btn")).toBeTruthy();
|
||
|
||
const previousInnerWidth = window.innerWidth;
|
||
Object.defineProperty(window, "innerWidth", { value: 390, configurable: true });
|
||
try {
|
||
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
expect(screen.getByTestId("terminal-bootstrap-error")).toHaveTextContent("Command Prompt or PowerShell");
|
||
fireEvent.click(screen.getByTestId("terminal-retry-btn"));
|
||
expect(mockRetryBootstrap).toHaveBeenCalledTimes(1);
|
||
} finally {
|
||
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
|
||
}
|
||
});
|
||
|
||
it("retry button calls retryBootstrap from the hook", async () => {
|
||
const mockRetryBootstrap = vi.fn();
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [],
|
||
activeTab: null,
|
||
bootstrapError: "Connection refused",
|
||
retryBootstrap: mockRetryBootstrap,
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
const retryBtn = screen.getByTestId("terminal-retry-btn");
|
||
fireEvent.click(retryBtn);
|
||
|
||
expect(mockRetryBootstrap).toHaveBeenCalled();
|
||
});
|
||
|
||
it("bootstrap refresh button reloads the page", async () => {
|
||
const mockRetryBootstrap = vi.fn();
|
||
const reloadMock = vi.fn();
|
||
const originalWindow = globalThis.window;
|
||
const patchedWindow = Object.create(originalWindow) as Window & typeof globalThis;
|
||
|
||
Object.defineProperty(patchedWindow, "location", {
|
||
value: {
|
||
...originalWindow.location,
|
||
reload: reloadMock,
|
||
},
|
||
configurable: true,
|
||
});
|
||
|
||
(globalThis as { window: Window & typeof globalThis }).window = patchedWindow;
|
||
|
||
try {
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [],
|
||
activeTab: null,
|
||
bootstrapError: "Connection refused",
|
||
retryBootstrap: mockRetryBootstrap,
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
const refreshBtn = screen.getByTestId("terminal-bootstrap-refresh-btn");
|
||
fireEvent.click(refreshBtn);
|
||
|
||
expect(reloadMock).toHaveBeenCalledTimes(1);
|
||
} finally {
|
||
(globalThis as { window: Window & typeof globalThis }).window = originalWindow;
|
||
}
|
||
});
|
||
|
||
it("clears error state and shows terminal after successful retry", async () => {
|
||
const mockRetryBootstrap = vi.fn();
|
||
|
||
// Start with error state
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [],
|
||
activeTab: null,
|
||
bootstrapError: "Server unreachable",
|
||
retryBootstrap: mockRetryBootstrap,
|
||
});
|
||
|
||
const { rerender } = render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
// Error state should be shown
|
||
expect(screen.getByTestId("terminal-bootstrap-error")).toBeTruthy();
|
||
|
||
// Simulate successful retry — hook updates state
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [defaultTab],
|
||
activeTab: defaultTab,
|
||
bootstrapError: null,
|
||
retryBootstrap: mockRetryBootstrap,
|
||
});
|
||
|
||
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
// Error state should be gone
|
||
await waitFor(() => {
|
||
expect(screen.queryByTestId("terminal-bootstrap-error")).toBeNull();
|
||
});
|
||
|
||
// Loading spinner should also be gone (xterm will init)
|
||
// The loading overlay will disappear after xterm initializes
|
||
expect(screen.queryByTestId("terminal-loading")).toBeNull();
|
||
});
|
||
|
||
it("initializes xterm when activeTab transitions from null to valid after async session restoration", async () => {
|
||
// Start with no activeTab (simulating initial async load from useTerminalSessions)
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
activeTab: null,
|
||
isReady: false,
|
||
});
|
||
|
||
const { rerender } = render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
// xterm should not initialize yet because activeTab is null
|
||
expect(mockTerminalInstance.open).not.toHaveBeenCalled();
|
||
|
||
// Simulate async session restoration completing
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
activeTab: defaultTab,
|
||
isReady: true,
|
||
});
|
||
mockUseTerminal.mockReturnValue(createMockTerminalState({ connectionStatus: "connected" }));
|
||
|
||
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
// xterm should be initialized after activeTab becomes available
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
it("does not show bootstrap error when activeTab exists (recovered state)", async () => {
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
bootstrapError: "Previous error",
|
||
tabs: [defaultTab],
|
||
activeTab: defaultTab,
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
// Bootstrap error should NOT show because activeTab exists
|
||
await waitFor(() => {
|
||
expect(screen.queryByTestId("terminal-bootstrap-error")).toBeNull();
|
||
});
|
||
});
|
||
|
||
it("shows tabs when multiple sessions exist", async () => {
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [
|
||
defaultTab,
|
||
{ id: "tab-2", sessionId: "test-session-456", title: "zsh", isActive: false, createdAt: Date.now() },
|
||
],
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText("bash")).toBeTruthy();
|
||
expect(screen.getByText("zsh")).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
it("shows active tab styling", async () => {
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [
|
||
{ ...defaultTab, isActive: true },
|
||
{ id: "tab-2", sessionId: "test-session-456", title: "zsh", isActive: false, createdAt: Date.now() },
|
||
],
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const activeTab = screen.getByText("bash").closest(".terminal-tab");
|
||
expect(activeTab).toHaveClass("terminal-tab--active");
|
||
});
|
||
});
|
||
|
||
it("tab click switches active tab", async () => {
|
||
const mockSetActiveTab = vi.fn();
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [
|
||
{ ...defaultTab, isActive: true },
|
||
{ id: "tab-2", sessionId: "test-session-456", title: "zsh", isActive: false, createdAt: Date.now() },
|
||
],
|
||
setActiveTab: mockSetActiveTab,
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const zshTab = screen.getByText("zsh");
|
||
fireEvent.click(zshTab);
|
||
});
|
||
|
||
expect(mockSetActiveTab).toHaveBeenCalledWith("tab-2");
|
||
});
|
||
|
||
it("tab close button closes tab", async () => {
|
||
const mockCloseTab = vi.fn();
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [
|
||
{ ...defaultTab, isActive: true },
|
||
{ id: "tab-2", sessionId: "test-session-456", title: "zsh", isActive: false, createdAt: Date.now() },
|
||
],
|
||
closeTab: mockCloseTab,
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
// Find the close button for the zsh tab (second tab)
|
||
const closeButtons = screen.getAllByTitle("Close tab");
|
||
const zshCloseBtn = closeButtons[1]; // Second close button (for zsh tab)
|
||
if (zshCloseBtn) {
|
||
fireEvent.click(zshCloseBtn);
|
||
}
|
||
});
|
||
|
||
expect(mockCloseTab).toHaveBeenCalledWith("tab-2");
|
||
});
|
||
|
||
it("new tab button creates new tab", async () => {
|
||
const mockCreateTab = vi.fn().mockResolvedValue({
|
||
id: "tab-new",
|
||
sessionId: "new-session",
|
||
title: "Terminal 2",
|
||
isActive: true,
|
||
createdAt: Date.now(),
|
||
});
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
createTab: mockCreateTab,
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const newTabBtn = screen.getByTitle("New terminal");
|
||
fireEvent.click(newTabBtn);
|
||
});
|
||
|
||
expect(mockCreateTab).toHaveBeenCalled();
|
||
});
|
||
|
||
it("keeps desktop tab buttons and close buttons as the accessible tab surface", async () => {
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [
|
||
{ ...defaultTab, isActive: true },
|
||
{ id: "tab-2", sessionId: "test-session-456", title: "zsh", isActive: false, createdAt: Date.now() },
|
||
],
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
expect(await screen.findByTitle("bash")).toHaveAttribute("role", "tab");
|
||
expect(screen.getByTitle("zsh")).toHaveAttribute("role", "tab");
|
||
expect(screen.getAllByTitle("Close tab")).toHaveLength(2);
|
||
expect(screen.queryByTestId("terminal-mobile-tabs")).toBeNull();
|
||
});
|
||
|
||
it("collapses desktop tabs to the mobile dropdown on container overflow and expands when room returns", async () => {
|
||
const previousInnerWidth = window.innerWidth;
|
||
const mockSetActiveTab = vi.fn();
|
||
const mockCreateTab = vi.fn().mockResolvedValue(defaultTab);
|
||
const mockCloseTab = vi.fn();
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [
|
||
{ ...defaultTab, isActive: true },
|
||
{ id: "tab-2", sessionId: "test-session-456", title: "zsh", isActive: false, createdAt: Date.now() },
|
||
{ id: "tab-3", sessionId: "test-session-789", title: "make test", isActive: false, createdAt: Date.now() },
|
||
],
|
||
setActiveTab: mockSetActiveTab,
|
||
createTab: mockCreateTab,
|
||
closeTab: mockCloseTab,
|
||
});
|
||
Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true });
|
||
|
||
try {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
const expandedTabs = await screen.findByTestId("terminal-tabs");
|
||
defineMetric(expandedTabs, "scrollWidth", 360);
|
||
defineMetric(expandedTabs, "clientWidth", 200);
|
||
act(() => {
|
||
fireEvent(window, new Event("resize"));
|
||
});
|
||
|
||
const collapsedTabs = await screen.findByTestId("terminal-mobile-tabs");
|
||
expect(screen.queryByTestId("terminal-tabs")).toBeNull();
|
||
const measuringTabs = screen.getByTestId("terminal-tabs-measuring");
|
||
expect(measuringTabs).toHaveAttribute("aria-hidden", "true");
|
||
expect(measuringTabs.querySelector(".terminal-tab-close")).toBeDisabled();
|
||
|
||
const select = screen.getByTestId("terminal-mobile-tab-select") as HTMLSelectElement;
|
||
expect(collapsedTabs.contains(select)).toBe(true);
|
||
fireEvent.change(select, { target: { value: "tab-2" } });
|
||
expect(mockSetActiveTab).toHaveBeenCalledWith("tab-2");
|
||
fireEvent.click(screen.getByTestId("terminal-mobile-new-tab"));
|
||
expect(mockCreateTab).toHaveBeenCalledWith();
|
||
fireEvent.click(screen.getByTestId("terminal-mobile-close-tab"));
|
||
expect(mockCloseTab).toHaveBeenCalledWith("tab-1");
|
||
|
||
defineMetric(measuringTabs, "scrollWidth", 199);
|
||
defineMetric(measuringTabs, "clientWidth", 200);
|
||
act(() => {
|
||
fireEvent(window, new Event("resize"));
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-tabs")).toBeTruthy();
|
||
expect(screen.queryByTestId("terminal-mobile-tabs")).toBeNull();
|
||
});
|
||
} finally {
|
||
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
|
||
}
|
||
});
|
||
|
||
it("renders a mobile tab selector with every tab and switches by tab id", async () => {
|
||
const previousInnerWidth = window.innerWidth;
|
||
const previousInnerHeight = window.innerHeight;
|
||
const mockSetActiveTab = vi.fn();
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [
|
||
{ ...defaultTab, title: "duplicate", isActive: true },
|
||
{ id: "tab-2", sessionId: "test-session-456", title: "duplicate", isActive: false, createdAt: Date.now() },
|
||
{ id: "tab-3", sessionId: "test-session-789", title: "very-long-active-terminal-tab-title-that-should-not-push-actions", isActive: false, createdAt: Date.now() },
|
||
],
|
||
setActiveTab: mockSetActiveTab,
|
||
});
|
||
Object.defineProperty(window, "innerWidth", { value: 390, configurable: true });
|
||
Object.defineProperty(window, "innerHeight", { value: 720, configurable: true });
|
||
|
||
try {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
const select = await screen.findByLabelText("Terminal tab") as HTMLSelectElement;
|
||
const options = Array.from(select.options);
|
||
expect(options.map((option) => option.value)).toEqual(["tab-1", "tab-2", "tab-3"]);
|
||
expect(options.map((option) => option.textContent)).toEqual([
|
||
"duplicate",
|
||
"duplicate",
|
||
"very-long-active-terminal-tab-title-that-should-not-push-actions",
|
||
]);
|
||
expect(screen.queryByRole("tab", { name: "duplicate" })).toBeNull();
|
||
expect(screen.queryByTestId("terminal-tabs")).toBeNull();
|
||
|
||
fireEvent.change(select, { target: { value: "tab-2" } });
|
||
expect(mockSetActiveTab).toHaveBeenCalledWith("tab-2");
|
||
} finally {
|
||
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
|
||
Object.defineProperty(window, "innerHeight", { value: previousInnerHeight, configurable: true });
|
||
}
|
||
});
|
||
|
||
it("keeps mobile new-terminal and close-current-tab controls reachable", async () => {
|
||
const previousInnerWidth = window.innerWidth;
|
||
const mockCreateTab = vi.fn().mockResolvedValue(defaultTab);
|
||
const mockCloseTab = vi.fn();
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [
|
||
{ ...defaultTab, isActive: true },
|
||
{ id: "tab-2", sessionId: "test-session-456", title: "zsh", isActive: false, createdAt: Date.now() },
|
||
],
|
||
createTab: mockCreateTab,
|
||
closeTab: mockCloseTab,
|
||
});
|
||
Object.defineProperty(window, "innerWidth", { value: 375, configurable: true });
|
||
|
||
try {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
fireEvent.click(await screen.findByTestId("terminal-mobile-new-tab"));
|
||
expect(mockCreateTab).toHaveBeenCalledWith();
|
||
|
||
fireEvent.click(screen.getByLabelText("Close current tab"));
|
||
expect(mockCloseTab).toHaveBeenCalledWith("tab-1");
|
||
} finally {
|
||
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
|
||
}
|
||
});
|
||
|
||
it("omits the mobile close-current-tab control when only one tab exists", async () => {
|
||
const previousInnerWidth = window.innerWidth;
|
||
Object.defineProperty(window, "innerWidth", { value: 360, configurable: true });
|
||
|
||
try {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
expect(await screen.findByLabelText("Terminal tab")).toBeInTheDocument();
|
||
expect(screen.queryByLabelText("Close current tab")).toBeNull();
|
||
} finally {
|
||
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
|
||
}
|
||
});
|
||
|
||
it("sessions are NOT killed when modal closes (session persistence)", async () => {
|
||
const { rerender } = render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
|
||
});
|
||
|
||
await act(async () => {
|
||
rerender(<TerminalModal isOpen={false} onClose={mockOnClose} />);
|
||
});
|
||
|
||
// With multi-tab support, sessions should persist when modal closes
|
||
expect(mockKillPtyTerminalSession).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("closes modal on close button click", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const closeBtn = screen.getByTestId("terminal-close-btn");
|
||
fireEvent.click(closeBtn);
|
||
});
|
||
|
||
expect(mockOnClose).toHaveBeenCalled();
|
||
});
|
||
|
||
it("closes modal on escape key", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await act(async () => {
|
||
fireEvent.keyDown(document, { key: "Escape" });
|
||
});
|
||
|
||
expect(mockOnClose).toHaveBeenCalled();
|
||
});
|
||
|
||
it("closes modal on overlay click", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const overlay = screen.getByTestId("terminal-modal-overlay");
|
||
// Overlay dismiss is wired via mousedown→mouseup so a resize-drag that
|
||
// ends on the overlay (after starting inside the modal) doesn't close.
|
||
// A real click on the overlay fires both events on the overlay.
|
||
fireEvent.mouseDown(overlay);
|
||
fireEvent.mouseUp(overlay);
|
||
});
|
||
|
||
expect(mockOnClose).toHaveBeenCalled();
|
||
});
|
||
|
||
it("does NOT close when mousedown is on the modal but mouseup is on the overlay (resize drag)", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const overlay = screen.getByTestId("terminal-modal-overlay");
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
fireEvent.mouseDown(modal);
|
||
fireEvent.mouseUp(overlay);
|
||
});
|
||
|
||
expect(mockOnClose).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("shows reconnect button when disconnected", async () => {
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({
|
||
connectionStatus: "disconnected",
|
||
})
|
||
);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-reconnect-btn")).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
it("reconnects when reconnect button clicked", async () => {
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({
|
||
connectionStatus: "disconnected",
|
||
})
|
||
);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const reconnectBtn = screen.getByTestId("terminal-reconnect-btn");
|
||
fireEvent.click(reconnectBtn);
|
||
});
|
||
|
||
expect(mockReconnect).toHaveBeenCalled();
|
||
});
|
||
|
||
it("WebSocket connects on mount with sessionId from active tab", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(mockUseTerminal).toHaveBeenCalledWith("test-session-123", undefined);
|
||
});
|
||
});
|
||
|
||
it("initializes xterm after session is ready", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
// Wait for session to be ready and xterm to initialize
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||
});
|
||
|
||
// Verify xterm was opened with the terminal container div
|
||
const terminalDiv = screen.getByTestId("terminal-xterm");
|
||
expect(mockTerminalInstance.open).toHaveBeenCalledWith(terminalDiv);
|
||
});
|
||
|
||
it("initializes xterm with a Nerd Font-preferred monospace stack", async () => {
|
||
const { Terminal } = await import("@xterm/xterm");
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||
});
|
||
|
||
expect(Terminal).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
fontFamily: XTERM_FONT_FAMILY,
|
||
}),
|
||
);
|
||
expectMeasurementSafeFontStack(mockTerminalInstance.options.fontFamily as string);
|
||
expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("14px");
|
||
});
|
||
|
||
it("initializes xterm with a non-default symbols-free font preset", async () => {
|
||
const { Terminal } = await import("@xterm/xterm");
|
||
window.localStorage.setItem(
|
||
TERMINAL_PREFERENCES_KEY,
|
||
JSON.stringify({
|
||
...DEFAULT_TERMINAL_PREFERENCES,
|
||
fontFamily: "system-mono",
|
||
}),
|
||
);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||
});
|
||
|
||
expect(Terminal).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
fontFamily: resolveTerminalFontFamily("system-mono"),
|
||
}),
|
||
);
|
||
expectMeasurementSafeFontStack(mockTerminalInstance.options.fontFamily as string);
|
||
});
|
||
|
||
describe("shortcut panel", () => {
|
||
it("constrains the panel to the modal width so it scrolls horizontally instead of overflowing (FN-7550)", () => {
|
||
// FN-7550: the base rule must declare min-width: 0 to defeat the flex
|
||
// min-width:auto trap — without it the panel's automatic minimum equals
|
||
// the sum of all nowrap buttons, which overrides overflow-x: auto and
|
||
// lets the modal's overflow: hidden clip the rightmost shortcuts on mobile.
|
||
const panelRule = terminalModalCss.match(/\.terminal-shortcut-panel\s*\{([^}]*)\}/)?.[1] ?? "";
|
||
expect(panelRule).toContain("min-width: 0;");
|
||
expect(panelRule).toContain("overflow-x: auto;");
|
||
expect(panelRule).toContain("flex-wrap: nowrap;");
|
||
|
||
// The mobile override (max-height clamp) must still exist and must not
|
||
// reintroduce a conflicting min-width.
|
||
const mobilePanelRule =
|
||
terminalModalCss.match(/@media \(max-width: 768px\) \{[\s\S]*?\.terminal-shortcut-panel\s*\{([^}]*)\}/)?.[1] ?? "";
|
||
expect(mobilePanelRule).toContain("max-height");
|
||
expect(mobilePanelRule).not.toContain("min-width");
|
||
});
|
||
|
||
it("gives the unconditional footer bar the horizontal-scroll pattern (FN-7829)", () => {
|
||
// FN-7829: desktop, tablet, mobile, and embedded terminals share the same footer rule, so the scroll-safety declarations must live in the base stylesheet instead of media-query-only overrides.
|
||
const footerRule = terminalModalCss.match(/\.terminal-status-bar\s*\{([^}]*)\}/)?.[1] ?? "";
|
||
expect(footerRule).toContain("overflow-x: auto;");
|
||
expect(footerRule).toContain("min-width: 0;");
|
||
expect(footerRule).toContain("flex-wrap: nowrap;");
|
||
expect(footerRule).toContain("touch-action: pan-x pan-y;");
|
||
|
||
const mobileHideBlock = terminalModalCss.match(
|
||
/@media \(max-width: 768px\) \{[\s\S]*?\.terminal-connection-status \{[\s\S]*?\}\s*\n\}/,
|
||
);
|
||
expect(mobileHideBlock).not.toBeNull();
|
||
const tabletBlock = terminalModalCss.match(
|
||
/@media \(min-width: 769px\) and \(max-width: 1024px\) \{([\s\S]*?)\n\}/,
|
||
)?.[1] ?? "";
|
||
expect(tabletBlock).not.toMatch(/\.terminal-connection-status/);
|
||
expect(tabletBlock).not.toMatch(/\.terminal-status-bar/);
|
||
});
|
||
|
||
it("keeps the desktop terminal header controls on one scrollable row when narrow (FN-7823)", () => {
|
||
// FN-7823: large viewport breakpoints can still produce narrow floating or
|
||
// docked panels, so the desktop header must preserve horizontal scrolling
|
||
// instead of wrapping status text into multiple rows.
|
||
const actionsRule = terminalModalCss.match(/\.terminal-actions\s*\{([^}]*)\}/)?.[1] ?? "";
|
||
expect(actionsRule).toContain("min-width: 0;");
|
||
expect(actionsRule).toContain("overflow-x: auto;");
|
||
expect(actionsRule).not.toContain("flex-wrap: wrap;");
|
||
|
||
const connectionStatusRule = terminalModalCss.match(/\.terminal-connection-status\s*\{([^}]*)\}/)?.[1] ?? "";
|
||
expect(connectionStatusRule).toContain("white-space: nowrap;");
|
||
|
||
const mobileHideBlock = terminalModalCss.match(
|
||
/@media \(max-width: 768px\) \{[\s\S]*?\.terminal-connection-status \{([^}]*)\}/,
|
||
)?.[1] ?? "";
|
||
expect(mobileHideBlock).toContain("display: none;");
|
||
});
|
||
|
||
describe("real-CSS mobile cascade (FN-7621 recurrence #3)", () => {
|
||
// FN-7621: the FN-7550/FN-7560 tests above are leaf-rule string matches —
|
||
// they proved the declarations exist, but never proved the panel actually
|
||
// scrolls under the real mobile cascade (ancestor overrides, mobile
|
||
// classes, media queries). This is exactly why the bug recurred a third
|
||
// time while those tests stayed green: touch-action's used value for a
|
||
// touch gesture is the INTERSECTION of the touched element's value and
|
||
// every ancestor's value (confirmed by this codebase's own
|
||
// "opt known horizontal scrollers back into pan-x" convention in
|
||
// styles.css's mobile lockdown), so a correct leaf `touch-action: pan-x`
|
||
// on `.terminal-shortcut-panel` was silently defeated by its ancestors
|
||
// (`.terminal-modal`, `.modal-overlay`) staying locked to `pan-y` by that
|
||
// same global mobile lockdown. Resolve real cascade winners via
|
||
// `getComputedStyle` under `loadAllAppCss()` instead of matching rule text.
|
||
let styleEl: HTMLStyleElement;
|
||
|
||
beforeAll(() => {
|
||
styleEl = document.createElement("style");
|
||
styleEl.textContent = loadAllAppCss();
|
||
document.head.appendChild(styleEl);
|
||
});
|
||
|
||
afterAll(() => {
|
||
styleEl.remove();
|
||
});
|
||
|
||
async function renderMobileShortcutPanel(): Promise<{ panel: HTMLElement; modal: HTMLElement; overlay: HTMLElement | null }> {
|
||
Object.defineProperty(window, "innerWidth", { value: 390, configurable: true });
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
const toggle = await screen.findByTestId("terminal-shortcut-toggle");
|
||
fireEvent.click(toggle);
|
||
const panel = await screen.findByTestId("terminal-shortcut-panel");
|
||
const modal = await screen.findByTestId("terminal-modal");
|
||
const overlay = modal.closest(".modal-overlay") as HTMLElement | null;
|
||
return { panel, modal, overlay };
|
||
}
|
||
|
||
it("resolves the panel + full ancestor chain as width-bounded and pan-x-capable at mobile fullscreen", async () => {
|
||
const { panel, modal, overlay } = await renderMobileShortcutPanel();
|
||
expect(modal).toHaveClass("terminal-modal--mobile");
|
||
|
||
const panelStyle = getComputedStyle(panel);
|
||
expect(panelStyle.minWidth).toBe("0px");
|
||
expect(panelStyle.overflowX).toBe("auto");
|
||
expect(panelStyle.flexWrap).toBe("nowrap");
|
||
expect(panelStyle.touchAction).toContain("pan-x");
|
||
|
||
// The direct parent (.terminal-modal) must itself be width-bounded
|
||
// (overflow: hidden + a definite width/max-width) AND must not
|
||
// re-lock horizontal panning for its descendants — this is the
|
||
// ancestor that FN-7550/FN-7560 never touched.
|
||
const modalStyle = getComputedStyle(modal);
|
||
expect(modalStyle.overflow).toBe("hidden");
|
||
expect(modalStyle.maxWidth).not.toBe("");
|
||
expect(modalStyle.touchAction).toContain("pan-x");
|
||
|
||
// The portaled overlay ancestor is the OTHER surface the mobile
|
||
// lockdown explicitly re-locks to pan-y (`.modal-overlay:not(.confirm-dialog-overlay)`
|
||
// in styles.css) — it must be carved back out too.
|
||
expect(overlay).toBeTruthy();
|
||
const overlayStyle = getComputedStyle(overlay as HTMLElement);
|
||
expect(overlayStyle.touchAction).toContain("pan-x");
|
||
});
|
||
|
||
it("keeps the ancestor chain pan-x-capable in the --keyboard-overlap narrowed-visual-viewport variant", async () => {
|
||
const { panel, modal, overlay } = await renderMobileShortcutPanel();
|
||
|
||
// Simulate the keyboard-open narrowed-visual-viewport variant
|
||
// (--vv-width narrower than the layout viewport) directly on the DOM
|
||
// node — this exercises the `.terminal-modal--mobile[style*="--keyboard-overlap"]`
|
||
// selector's cascade without needing the full focus/resize keyboard
|
||
// simulation machinery used elsewhere in this file.
|
||
act(() => {
|
||
modal.style.setProperty("--keyboard-overlap", "300px");
|
||
modal.style.setProperty("--vv-width", "320px");
|
||
modal.style.setProperty("--vv-height", "380px");
|
||
});
|
||
|
||
const panelStyle = getComputedStyle(panel);
|
||
expect(panelStyle.minWidth).toBe("0px");
|
||
expect(panelStyle.overflowX).toBe("auto");
|
||
expect(panelStyle.touchAction).toContain("pan-x");
|
||
|
||
const modalStyle = getComputedStyle(modal);
|
||
expect(modalStyle.maxWidth).toBe("var(--vv-width, 100vw)");
|
||
expect(modalStyle.touchAction).toContain("pan-x");
|
||
|
||
expect(overlay).toBeTruthy();
|
||
expect(getComputedStyle(overlay as HTMLElement).touchAction).toContain("pan-x");
|
||
});
|
||
|
||
it("does not regress desktop docked/floating/pinned-below touch-action or width contracts", async () => {
|
||
Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true });
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
const modal = await screen.findByTestId("terminal-modal");
|
||
expect(modal).not.toHaveClass("terminal-modal--mobile");
|
||
// Desktop never had a touch-action restriction on the modal/overlay —
|
||
// this fix only carves out the mobile-locked ancestors, so desktop's
|
||
// computed touch-action must stay whatever it already was (unset/auto),
|
||
// never narrowed to something that would block desktop pointer input.
|
||
const modalStyle = getComputedStyle(modal);
|
||
expect(modalStyle.touchAction === "" || modalStyle.touchAction === "auto").toBe(true);
|
||
});
|
||
});
|
||
|
||
it("is hidden by default and toggles from header action", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
expect(screen.queryByTestId("terminal-shortcut-panel")).toBeNull();
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
|
||
expect(screen.getByTestId("terminal-shortcut-panel")).toBeTruthy();
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
|
||
expect(screen.queryByTestId("terminal-shortcut-panel")).toBeNull();
|
||
});
|
||
|
||
it("supports sticky modifier toggle semantics", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
|
||
|
||
const ctrlBtn = screen.getByTestId("terminal-modifier-ctrl");
|
||
const altBtn = screen.getByTestId("terminal-modifier-alt");
|
||
|
||
fireEvent.click(ctrlBtn);
|
||
expect(ctrlBtn.getAttribute("aria-pressed")).toBe("true");
|
||
|
||
fireEvent.click(ctrlBtn);
|
||
expect(ctrlBtn.getAttribute("aria-pressed")).toBe("false");
|
||
|
||
fireEvent.click(ctrlBtn);
|
||
fireEvent.click(altBtn);
|
||
expect(ctrlBtn.getAttribute("aria-pressed")).toBe("false");
|
||
expect(altBtn.getAttribute("aria-pressed")).toBe("true");
|
||
});
|
||
|
||
it("sends modified and literal keys, then clears sticky modifier", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
|
||
|
||
const ctrlBtn = screen.getByTestId("terminal-modifier-ctrl");
|
||
const altBtn = screen.getByTestId("terminal-modifier-alt");
|
||
|
||
fireEvent.click(ctrlBtn);
|
||
fireEvent.click(screen.getByRole("button", { name: "C" }));
|
||
expect(mockSendInput).toHaveBeenCalledWith("\x03");
|
||
expect(ctrlBtn.getAttribute("aria-pressed")).toBe("false");
|
||
|
||
fireEvent.click(ctrlBtn);
|
||
fireEvent.click(screen.getByRole("button", { name: "D" }));
|
||
expect(mockSendInput).toHaveBeenCalledWith("\x04");
|
||
expect(ctrlBtn.getAttribute("aria-pressed")).toBe("false");
|
||
|
||
fireEvent.click(ctrlBtn);
|
||
fireEvent.click(screen.getByRole("button", { name: "L" }));
|
||
expect(mockSendInput).toHaveBeenCalledWith("\x0c");
|
||
expect(ctrlBtn.getAttribute("aria-pressed")).toBe("false");
|
||
|
||
fireEvent.click(ctrlBtn);
|
||
fireEvent.click(screen.getByRole("button", { name: "." }));
|
||
expect(mockSendInput).toHaveBeenCalledWith(".");
|
||
expect(ctrlBtn.getAttribute("aria-pressed")).toBe("false");
|
||
|
||
fireEvent.click(altBtn);
|
||
fireEvent.click(screen.getByRole("button", { name: "D" }));
|
||
expect(mockSendInput).toHaveBeenCalledWith("\x1bd");
|
||
expect(altBtn.getAttribute("aria-pressed")).toBe("false");
|
||
|
||
fireEvent.click(screen.getByRole("button", { name: "Z" }));
|
||
expect(mockSendInput).toHaveBeenCalledWith("z");
|
||
|
||
fireEvent.click(screen.getByRole("button", { name: "ESC" }));
|
||
expect(mockSendInput).toHaveBeenCalledWith("\x1b");
|
||
|
||
fireEvent.click(screen.getByRole("button", { name: "Tab" }));
|
||
expect(mockSendInput).toHaveBeenCalledWith("\t");
|
||
});
|
||
|
||
it("keeps desktop hardware-keyboard focus while every shortcut category delivers bytes", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||
expect(terminalDataHandler).not.toBeNull();
|
||
});
|
||
|
||
const terminalDiv = screen.getByTestId("terminal-xterm");
|
||
const helperTextarea = document.createElement("textarea");
|
||
helperTextarea.className = "xterm-helper-textarea";
|
||
const focusSpy = vi.spyOn(helperTextarea, "focus");
|
||
terminalDiv.appendChild(helperTextarea);
|
||
helperTextarea.focus();
|
||
expect(document.activeElement).toBe(helperTextarea);
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
|
||
const assertMouseDownPreservesFocus = (button: HTMLElement) => {
|
||
const mouseDown = new MouseEvent("mousedown", { bubbles: true, cancelable: true });
|
||
button.dispatchEvent(mouseDown);
|
||
expect(mouseDown.defaultPrevented).toBe(true);
|
||
expect(document.activeElement).toBe(helperTextarea);
|
||
};
|
||
|
||
mockSendInput.mockClear();
|
||
mockTerminalInstance.focus.mockClear();
|
||
focusSpy.mockClear();
|
||
|
||
const ctrlButton = screen.getByTestId("terminal-modifier-ctrl");
|
||
assertMouseDownPreservesFocus(ctrlButton);
|
||
fireEvent.click(ctrlButton);
|
||
expect(mockTerminalInstance.focus).toHaveBeenCalled();
|
||
expect(focusSpy).toHaveBeenCalled();
|
||
expect(mockSendInput).not.toHaveBeenCalled();
|
||
|
||
fireEvent.click(screen.getByRole("button", { name: "C" }));
|
||
fireEvent.click(screen.getByRole("button", { name: "ESC" }));
|
||
fireEvent.click(screen.getByRole("button", { name: "Tab" }));
|
||
fireEvent.click(screen.getByTestId("terminal-arrow-up"));
|
||
|
||
expect(mockSendInput.mock.calls.map(([value]) => value)).toEqual([
|
||
"\x03",
|
||
"\x1b",
|
||
"\t",
|
||
"\x1b[A",
|
||
]);
|
||
expect(document.activeElement).toBe(helperTextarea);
|
||
|
||
act(() => {
|
||
terminalDataHandler?.("a");
|
||
});
|
||
expect(mockSendInput).toHaveBeenLastCalledWith("a");
|
||
});
|
||
|
||
it("keeps touch-primary shortcut buttons from stranding hardware-keyboard focus", async () => {
|
||
const previousInnerWidth = window.innerWidth;
|
||
const previousOntouchstart = window.ontouchstart;
|
||
const matchMediaSpy = vi
|
||
.spyOn(window, "matchMedia")
|
||
.mockImplementation((query: string) => ({
|
||
matches:
|
||
query === "(hover: none) and (pointer: coarse)" ||
|
||
query.includes("max-width: 768px"),
|
||
media: query,
|
||
onchange: null,
|
||
addListener: vi.fn(),
|
||
removeListener: vi.fn(),
|
||
addEventListener: vi.fn(),
|
||
removeEventListener: vi.fn(),
|
||
dispatchEvent: vi.fn(),
|
||
}));
|
||
|
||
Object.defineProperty(window, "innerWidth", {
|
||
value: 375,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(window, "ontouchstart", {
|
||
value: null,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
|
||
let unmount = () => {};
|
||
|
||
try {
|
||
({ unmount } = render(<TerminalModal isOpen={true} onClose={mockOnClose} />));
|
||
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||
});
|
||
|
||
const terminalDiv = screen.getByTestId("terminal-xterm");
|
||
const helperTextarea = document.createElement("textarea");
|
||
helperTextarea.className = "xterm-helper-textarea";
|
||
const focusSpy = vi.spyOn(helperTextarea, "focus");
|
||
terminalDiv.appendChild(helperTextarea);
|
||
helperTextarea.focus();
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
|
||
const ctrlButton = screen.getByTestId("terminal-modifier-ctrl");
|
||
const arrowUpButton = screen.getByTestId("terminal-arrow-up");
|
||
fireEvent.touchStart(ctrlButton);
|
||
expect(document.activeElement).toBe(helperTextarea);
|
||
|
||
const pointerDown = new PointerEvent("pointerdown", {
|
||
bubbles: true,
|
||
cancelable: true,
|
||
pointerType: "touch",
|
||
});
|
||
ctrlButton.dispatchEvent(pointerDown);
|
||
expect(pointerDown.defaultPrevented).toBe(true);
|
||
expect(document.activeElement).toBe(helperTextarea);
|
||
const mouseDown = new MouseEvent("mousedown", { bubbles: true, cancelable: true });
|
||
arrowUpButton.dispatchEvent(mouseDown);
|
||
expect(mouseDown.defaultPrevented).toBe(true);
|
||
expect(document.activeElement).toBe(helperTextarea);
|
||
|
||
mockSendInput.mockClear();
|
||
mockTerminalInstance.focus.mockClear();
|
||
focusSpy.mockClear();
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-modifier-ctrl"));
|
||
fireEvent.click(screen.getByRole("button", { name: "C" }));
|
||
fireEvent.click(screen.getByRole("button", { name: "ESC" }));
|
||
fireEvent.click(screen.getByRole("button", { name: "Tab" }));
|
||
fireEvent.click(arrowUpButton);
|
||
|
||
expect(mockSendInput.mock.calls.map(([value]) => value)).toEqual([
|
||
"\x03",
|
||
"\x1b",
|
||
"\t",
|
||
"\x1b[A",
|
||
]);
|
||
expect(mockTerminalInstance.focus).toHaveBeenCalled();
|
||
expect(focusSpy).not.toHaveBeenCalled();
|
||
expect(document.activeElement).toBe(helperTextarea);
|
||
} finally {
|
||
unmount();
|
||
matchMediaSpy.mockRestore();
|
||
Object.defineProperty(window, "innerWidth", {
|
||
value: previousInnerWidth,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(window, "ontouchstart", {
|
||
value: previousOntouchstart,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
}
|
||
});
|
||
|
||
it("sends sticky Ctrl shortcut bytes and clears the modifier after each delivery", async () => {
|
||
const terminalDiv = document.createElement("div");
|
||
terminalDiv.setAttribute("data-testid", "terminal");
|
||
const helperTextarea = document.createElement("textarea");
|
||
helperTextarea.className = "xterm-helper-textarea";
|
||
terminalDiv.appendChild(helperTextarea);
|
||
document.body.appendChild(terminalDiv);
|
||
|
||
try {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
helperTextarea.focus();
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
|
||
const ctrlButton = screen.getByTestId("terminal-modifier-ctrl");
|
||
|
||
for (const [label, expected] of [
|
||
["C", "\x03"],
|
||
["D", "\x04"],
|
||
["L", "\x0c"],
|
||
] as const) {
|
||
fireEvent.click(ctrlButton);
|
||
fireEvent.click(screen.getByRole("button", { name: label }));
|
||
expect(mockSendInput).toHaveBeenLastCalledWith(expected);
|
||
expect(ctrlButton.getAttribute("aria-pressed")).toBe("false");
|
||
}
|
||
|
||
expect(mockSendInput.mock.calls.map(([value]) => value)).toEqual(["\x03", "\x04", "\x0c"]);
|
||
expect(document.activeElement).toBe(helperTextarea);
|
||
} finally {
|
||
document.body.removeChild(terminalDiv);
|
||
}
|
||
});
|
||
|
||
it("sends literal ANSI arrow sequences independent of sticky modifiers", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
|
||
fireEvent.click(screen.getByTestId("terminal-modifier-ctrl"));
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-arrow-up"));
|
||
fireEvent.click(screen.getByTestId("terminal-arrow-down"));
|
||
fireEvent.click(screen.getByTestId("terminal-arrow-left"));
|
||
fireEvent.click(screen.getByTestId("terminal-arrow-right"));
|
||
|
||
expect(mockSendInput).toHaveBeenNthCalledWith(1, "\x1b[A");
|
||
expect(mockSendInput).toHaveBeenNthCalledWith(2, "\x1b[B");
|
||
expect(mockSendInput).toHaveBeenNthCalledWith(3, "\x1b[D");
|
||
expect(mockSendInput).toHaveBeenNthCalledWith(4, "\x1b[C");
|
||
expect(screen.getByTestId("terminal-modifier-ctrl").getAttribute("aria-pressed")).toBe("false");
|
||
});
|
||
|
||
it("renders persisted custom shortcuts and injects decoded values without stealing focus", async () => {
|
||
window.localStorage.setItem(
|
||
TERMINAL_PREFERENCES_KEY,
|
||
JSON.stringify({
|
||
...DEFAULT_TERMINAL_PREFERENCES,
|
||
customShortcuts: [{ id: "cs-status", label: "Status", value: "git status\\n" }],
|
||
}),
|
||
);
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
|
||
|
||
const terminalDiv = screen.getByTestId("terminal-xterm");
|
||
const helperTextarea = document.createElement("textarea");
|
||
helperTextarea.className = "xterm-helper-textarea";
|
||
const focusSpy = vi.spyOn(helperTextarea, "focus");
|
||
terminalDiv.appendChild(helperTextarea);
|
||
helperTextarea.focus();
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
|
||
const customButton = screen.getByTestId("terminal-custom-shortcut-cs-status");
|
||
expect(customButton).toHaveClass("terminal-shortcut-btn--custom");
|
||
|
||
const mouseDown = new MouseEvent("mousedown", { bubbles: true, cancelable: true });
|
||
customButton.dispatchEvent(mouseDown);
|
||
expect(mouseDown.defaultPrevented).toBe(true);
|
||
expect(document.activeElement).toBe(helperTextarea);
|
||
|
||
mockSendInput.mockClear();
|
||
mockTerminalInstance.focus.mockClear();
|
||
focusSpy.mockClear();
|
||
|
||
fireEvent.click(customButton);
|
||
|
||
expect(mockSendInput).toHaveBeenCalledWith("git status\n");
|
||
expect(mockTerminalInstance.focus).toHaveBeenCalled();
|
||
expect(focusSpy).toHaveBeenCalled();
|
||
});
|
||
|
||
it("renders custom shortcut controls on mobile without empty shells", async () => {
|
||
const previousInnerWidth = window.innerWidth;
|
||
window.localStorage.setItem(
|
||
TERMINAL_PREFERENCES_KEY,
|
||
JSON.stringify({
|
||
...DEFAULT_TERMINAL_PREFERENCES,
|
||
customShortcuts: [{ id: "cs-mobile", label: "Clear", value: "clear\\n" }],
|
||
}),
|
||
);
|
||
Object.defineProperty(window, "innerWidth", {
|
||
value: 375,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
|
||
try {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
|
||
fireEvent.click(screen.getByTestId("terminal-preferences-toggle"));
|
||
|
||
expect(screen.getByTestId("terminal-modal")).toHaveClass("terminal-modal--mobile");
|
||
expect(screen.getByTestId("terminal-custom-shortcut-cs-mobile")).toHaveTextContent("Clear");
|
||
expect(screen.getByTestId("terminal-custom-shortcuts")).toBeTruthy();
|
||
expect(screen.queryByTestId("terminal-custom-shortcuts-empty")).toBeNull();
|
||
} finally {
|
||
Object.defineProperty(window, "innerWidth", {
|
||
value: previousInnerWidth,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
}
|
||
});
|
||
|
||
it("renders shortcut controls on mobile viewport", async () => {
|
||
const previousInnerWidth = window.innerWidth;
|
||
const previousOntouchstart = window.ontouchstart;
|
||
|
||
Object.defineProperty(window, "innerWidth", {
|
||
value: 375,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(window, "ontouchstart", {
|
||
value: null,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
|
||
try {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
|
||
|
||
expect(screen.getByTestId("terminal-shortcut-panel")).toBeTruthy();
|
||
expect(screen.getByTestId("terminal-modifier-ctrl")).toBeTruthy();
|
||
expect(screen.getByTestId("terminal-modifier-alt")).toBeTruthy();
|
||
} finally {
|
||
Object.defineProperty(window, "innerWidth", {
|
||
value: previousInnerWidth,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(window, "ontouchstart", {
|
||
value: previousOntouchstart,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("font size controls", () => {
|
||
it("renders controls in the status bar with default font-size value", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-font-size-decrease")).toBeTruthy();
|
||
expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("14px");
|
||
expect(screen.getByTestId("terminal-font-size-increase")).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
it("increases font size via button, persists, and refits xterm", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("14px");
|
||
});
|
||
const fitCallBaseline = mockFitAddonFit.mock.calls.length;
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-font-size-increase"));
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("15px");
|
||
expect(window.localStorage.getItem(TERMINAL_FONT_SIZE_KEY)).toBe("15");
|
||
expect(mockFitAddonFit.mock.calls.length).toBeGreaterThan(fitCallBaseline);
|
||
});
|
||
});
|
||
|
||
it("decreases font size via button, persists, and refits xterm", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("14px");
|
||
});
|
||
const fitCallBaseline = mockFitAddonFit.mock.calls.length;
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-font-size-decrease"));
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("13px");
|
||
expect(window.localStorage.getItem(TERMINAL_FONT_SIZE_KEY)).toBe("13");
|
||
expect(mockFitAddonFit.mock.calls.length).toBeGreaterThan(fitCallBaseline);
|
||
});
|
||
});
|
||
|
||
it("reads persisted font size from localStorage on mount", async () => {
|
||
window.localStorage.setItem(TERMINAL_FONT_SIZE_KEY, "18");
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("18px");
|
||
});
|
||
});
|
||
|
||
it("clamps button changes to max 32", async () => {
|
||
window.localStorage.setItem(TERMINAL_FONT_SIZE_KEY, "32");
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-font-size-increase"));
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("32px");
|
||
expect(window.localStorage.getItem(TERMINAL_FONT_SIZE_KEY)).toBe("32");
|
||
});
|
||
});
|
||
|
||
it("clamps button changes to min 8", async () => {
|
||
window.localStorage.setItem(TERMINAL_FONT_SIZE_KEY, "8");
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-font-size-decrease"));
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("8px");
|
||
expect(window.localStorage.getItem(TERMINAL_FONT_SIZE_KEY)).toBe("8");
|
||
});
|
||
});
|
||
|
||
it("keeps keyboard zoom shortcuts wired to shared font-size state and refits xterm", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("14px");
|
||
});
|
||
|
||
const baseline = mockFitAddonFit.mock.calls.length;
|
||
|
||
fireEvent.keyDown(window, { ctrlKey: true, code: "Equal" });
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("15px");
|
||
expect(mockFitAddonFit.mock.calls.length).toBeGreaterThan(baseline);
|
||
});
|
||
|
||
const afterEqual = mockFitAddonFit.mock.calls.length;
|
||
fireEvent.keyDown(window, { ctrlKey: true, code: "Minus" });
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("14px");
|
||
expect(mockFitAddonFit.mock.calls.length).toBeGreaterThan(afterEqual);
|
||
});
|
||
|
||
fireEvent.keyDown(window, { ctrlKey: true, code: "Equal" });
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("15px");
|
||
});
|
||
|
||
const afterSecondEqual = mockFitAddonFit.mock.calls.length;
|
||
fireEvent.keyDown(window, { ctrlKey: true, code: "Digit0" });
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("14px");
|
||
expect(window.localStorage.getItem(TERMINAL_FONT_SIZE_KEY)).toBe("14");
|
||
expect(mockFitAddonFit.mock.calls.length).toBeGreaterThan(afterSecondEqual);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("terminal preferences", () => {
|
||
it("toggles the preferences panel", () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
expect(screen.queryByTestId("terminal-preferences-panel")).toBeNull();
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-preferences-toggle"));
|
||
expect(screen.getByTestId("terminal-preferences-panel")).toBeTruthy();
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-preferences-toggle"));
|
||
expect(screen.queryByTestId("terminal-preferences-panel")).toBeNull();
|
||
});
|
||
|
||
it("persists preference changes and applies live xterm options", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
|
||
fireEvent.click(screen.getByTestId("terminal-preferences-toggle"));
|
||
|
||
fireEvent.change(screen.getByTestId("terminal-preference-font-family"), {
|
||
target: { value: "system-mono" },
|
||
});
|
||
fireEvent.change(screen.getByTestId("terminal-preference-cursor-style"), {
|
||
target: { value: "underline" },
|
||
});
|
||
fireEvent.click(screen.getByTestId("terminal-preference-cursor-blink"));
|
||
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.options.fontFamily).toBe(resolveTerminalFontFamily("system-mono"));
|
||
expectMeasurementSafeFontStack(mockTerminalInstance.options.fontFamily as string);
|
||
expect(mockTerminalInstance.options.cursorStyle).toBe("underline");
|
||
expect(mockTerminalInstance.options.cursorBlink).toBe(false);
|
||
});
|
||
|
||
const persisted = JSON.parse(window.localStorage.getItem(TERMINAL_PREFERENCES_KEY) ?? "null");
|
||
expect(persisted).toEqual({
|
||
...DEFAULT_TERMINAL_PREFERENCES,
|
||
fontFamily: "system-mono",
|
||
cursorStyle: "underline",
|
||
cursorBlink: false,
|
||
});
|
||
});
|
||
|
||
it("resets preferences to defaults", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-preferences-toggle"));
|
||
fireEvent.change(screen.getByTestId("terminal-preference-font-size"), {
|
||
target: { value: "21" },
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("21px");
|
||
});
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-preferences-reset"));
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("14px");
|
||
expect(screen.getByTestId("terminal-preference-font-size")).toHaveProperty("value", "14");
|
||
});
|
||
expect(JSON.parse(window.localStorage.getItem(TERMINAL_PREFERENCES_KEY) ?? "null")).toEqual(
|
||
DEFAULT_TERMINAL_PREFERENCES,
|
||
);
|
||
});
|
||
|
||
it("keeps panel font-size control and status-bar controls in sync", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-preferences-toggle"));
|
||
fireEvent.change(screen.getByTestId("terminal-preference-font-size"), {
|
||
target: { value: "16" },
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("16px");
|
||
});
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-font-size-increase"));
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-font-size-value").textContent).toBe("17px");
|
||
expect(screen.getByTestId("terminal-preference-font-size")).toHaveProperty("value", "17");
|
||
});
|
||
});
|
||
|
||
it("shows renderer changes as next-open only", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
|
||
fireEvent.click(screen.getByTestId("terminal-preferences-toggle"));
|
||
expect(screen.queryByTestId("terminal-renderer-reopen-note")).toBeNull();
|
||
|
||
fireEvent.change(screen.getByTestId("terminal-preference-renderer"), {
|
||
target: { value: "canvas" },
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-renderer-reopen-note")).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
it("adds and edits custom shortcuts through the shared preferences record", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-preferences-toggle"));
|
||
expect(screen.getByTestId("terminal-custom-shortcuts-empty")).toBeTruthy();
|
||
const addButton = screen.getByTestId("terminal-custom-shortcut-add");
|
||
expect(addButton).toHaveProperty("disabled", true);
|
||
|
||
fireEvent.change(screen.getByTestId("terminal-custom-shortcut-label-input"), {
|
||
target: { value: "Status" },
|
||
});
|
||
fireEvent.change(screen.getByTestId("terminal-custom-shortcut-value-input"), {
|
||
target: { value: "git status\\n" },
|
||
});
|
||
expect(addButton).toHaveProperty("disabled", false);
|
||
fireEvent.click(addButton);
|
||
|
||
const persisted = JSON.parse(window.localStorage.getItem(TERMINAL_PREFERENCES_KEY) ?? "null");
|
||
expect(persisted.customShortcuts).toEqual([
|
||
expect.objectContaining({ label: "Status", value: "git status\\n" }),
|
||
]);
|
||
const shortcutId = persisted.customShortcuts[0].id;
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
|
||
expect(screen.getByTestId(`terminal-custom-shortcut-${shortcutId}`)).toHaveTextContent("Status");
|
||
|
||
fireEvent.click(screen.getByTestId(`terminal-custom-shortcut-edit-${shortcutId}`));
|
||
fireEvent.change(screen.getByTestId("terminal-custom-shortcut-label-input"), {
|
||
target: { value: "Clear" },
|
||
});
|
||
fireEvent.change(screen.getByTestId("terminal-custom-shortcut-value-input"), {
|
||
target: { value: "clear\\n" },
|
||
});
|
||
fireEvent.click(screen.getByTestId("terminal-custom-shortcut-add"));
|
||
|
||
const edited = JSON.parse(window.localStorage.getItem(TERMINAL_PREFERENCES_KEY) ?? "null");
|
||
expect(edited.customShortcuts).toEqual([
|
||
{ id: shortcutId, label: "Clear", value: "clear\\n" },
|
||
]);
|
||
expect(screen.getByTestId(`terminal-custom-shortcut-${shortcutId}`)).toHaveTextContent("Clear");
|
||
});
|
||
|
||
it("removes custom shortcuts and reset to defaults clears them without leftover shells", async () => {
|
||
window.localStorage.setItem(
|
||
TERMINAL_PREFERENCES_KEY,
|
||
JSON.stringify({
|
||
...DEFAULT_TERMINAL_PREFERENCES,
|
||
customShortcuts: [{ id: "cs-clear", label: "Clear", value: "clear\\n" }],
|
||
}),
|
||
);
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-preferences-toggle"));
|
||
fireEvent.click(screen.getByTestId("terminal-shortcut-toggle"));
|
||
expect(screen.getByTestId("terminal-custom-shortcut-cs-clear")).toBeTruthy();
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-custom-shortcut-remove-cs-clear"));
|
||
expect(screen.queryByTestId("terminal-custom-shortcut-cs-clear")).toBeNull();
|
||
expect(screen.getByTestId("terminal-custom-shortcuts-empty")).toBeTruthy();
|
||
expect(document.querySelectorAll(".terminal-custom-shortcuts__row")).toHaveLength(0);
|
||
expect(
|
||
JSON.parse(window.localStorage.getItem(TERMINAL_PREFERENCES_KEY) ?? "null").customShortcuts,
|
||
).toEqual([]);
|
||
|
||
fireEvent.change(screen.getByTestId("terminal-custom-shortcut-label-input"), {
|
||
target: { value: "Again" },
|
||
});
|
||
fireEvent.change(screen.getByTestId("terminal-custom-shortcut-value-input"), {
|
||
target: { value: "echo again\\n" },
|
||
});
|
||
fireEvent.click(screen.getByTestId("terminal-custom-shortcut-add"));
|
||
expect(document.querySelectorAll(".terminal-custom-shortcuts__row")).toHaveLength(1);
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-preferences-reset"));
|
||
expect(screen.getByTestId("terminal-custom-shortcuts-empty")).toBeTruthy();
|
||
expect(document.querySelectorAll(".terminal-custom-shortcuts__row")).toHaveLength(0);
|
||
expect(JSON.parse(window.localStorage.getItem(TERMINAL_PREFERENCES_KEY) ?? "null")).toEqual(
|
||
DEFAULT_TERMINAL_PREFERENCES,
|
||
);
|
||
});
|
||
|
||
it("disables custom shortcut add when inputs are empty or the cap is reached", async () => {
|
||
window.localStorage.setItem(
|
||
TERMINAL_PREFERENCES_KEY,
|
||
JSON.stringify({
|
||
...DEFAULT_TERMINAL_PREFERENCES,
|
||
customShortcuts: Array.from({ length: MAX_TERMINAL_CUSTOM_SHORTCUTS }, (_, index) => ({
|
||
id: `cs-${index}`,
|
||
label: `S${index}`,
|
||
value: `echo ${index}`,
|
||
})),
|
||
}),
|
||
);
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-preferences-toggle"));
|
||
const addButton = screen.getByTestId("terminal-custom-shortcut-add");
|
||
expect(addButton).toHaveProperty("disabled", true);
|
||
|
||
fireEvent.change(screen.getByTestId("terminal-custom-shortcut-label-input"), {
|
||
target: { value: "Overflow" },
|
||
});
|
||
fireEvent.change(screen.getByTestId("terminal-custom-shortcut-value-input"), {
|
||
target: { value: "echo overflow\\n" },
|
||
});
|
||
expect(addButton).toHaveProperty("disabled", true);
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-custom-shortcut-edit-cs-0"));
|
||
expect(addButton).toHaveProperty("disabled", false);
|
||
});
|
||
});
|
||
|
||
it("xterm container is rendered (visible under loading overlay) while loading", async () => {
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
isReady: false,
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
// The xterm container is always rendered (no display:none) so that
|
||
// terminal.open() can measure dimensions even during a tab switch.
|
||
// The loading overlay visually covers it.
|
||
const xtermDiv = screen.getByTestId("terminal-xterm");
|
||
expect(xtermDiv.style.display).toBe("");
|
||
});
|
||
});
|
||
|
||
it("xterm container remains rendered when ready", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const xtermDiv = screen.getByTestId("terminal-xterm");
|
||
expect(xtermDiv.style.display).not.toBe("none");
|
||
});
|
||
});
|
||
|
||
it("subscribes to terminal data after xterm is ready", async () => {
|
||
const mockOnData = vi.fn(() => vi.fn());
|
||
const mockOnConnect = vi.fn(() => vi.fn());
|
||
const mockOnExit = vi.fn(() => vi.fn());
|
||
const mockOnScrollback = vi.fn(() => vi.fn());
|
||
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({
|
||
onData: mockOnData,
|
||
onConnect: mockOnConnect,
|
||
onExit: mockOnExit,
|
||
onScrollback: mockOnScrollback,
|
||
})
|
||
);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
// Wait for xterm initialization to complete
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||
});
|
||
|
||
// After xterm is ready, data subscriptions should be established
|
||
await waitFor(() => {
|
||
expect(mockOnData).toHaveBeenCalled();
|
||
expect(mockOnConnect).toHaveBeenCalled();
|
||
expect(mockOnExit).toHaveBeenCalled();
|
||
expect(mockOnScrollback).toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
it("calls restartActiveTab when New Session button clicked", async () => {
|
||
const mockRestartActiveTab = vi.fn();
|
||
let exitCallback: ((code: number) => void) | null = null;
|
||
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
restartActiveTab: mockRestartActiveTab,
|
||
});
|
||
|
||
// Create a custom mock that captures the exit callback
|
||
const customOnExit = vi.fn((cb: (code: number) => void) => {
|
||
exitCallback = cb;
|
||
return vi.fn();
|
||
});
|
||
|
||
mockUseTerminal.mockReturnValue({
|
||
connectionStatus: "connected",
|
||
sendInput: mockSendInput,
|
||
resize: mockResize,
|
||
onData: vi.fn(() => vi.fn()),
|
||
onExit: customOnExit,
|
||
onConnect: vi.fn(() => vi.fn()),
|
||
onScrollback: vi.fn(() => vi.fn()),
|
||
reconnect: mockReconnect,
|
||
onSessionInvalid: vi.fn(() => vi.fn()),
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
|
||
});
|
||
|
||
// Wait for xterm to initialize
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||
});
|
||
|
||
// Trigger the exit callback to simulate terminal exit
|
||
act(() => {
|
||
if (exitCallback) {
|
||
exitCallback(0);
|
||
}
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-restart-btn")).toBeTruthy();
|
||
});
|
||
|
||
const restartBtn = screen.getByTestId("terminal-restart-btn");
|
||
fireEvent.click(restartBtn);
|
||
|
||
expect(mockRestartActiveTab).toHaveBeenCalled();
|
||
});
|
||
|
||
// --- initialCommand / script launch behavior ---
|
||
describe("initialCommand execution", () => {
|
||
async function flushInitialCommandDelay() {
|
||
await act(async () => {
|
||
await vi.advanceTimersByTimeAsync(500);
|
||
});
|
||
}
|
||
|
||
async function flushCreateTabPromise() {
|
||
await act(async () => {});
|
||
}
|
||
|
||
function scriptTab(id: string, sessionId: string, title = "Terminal 2") {
|
||
return {
|
||
id,
|
||
sessionId,
|
||
title,
|
||
isActive: true,
|
||
createdAt: Date.now(),
|
||
};
|
||
}
|
||
|
||
function useConnectedTerminal() {
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({ connectionStatus: "connected" })
|
||
);
|
||
}
|
||
|
||
function expectCommandSentAfterCreateTab(mockCreateTab: ReturnType<typeof vi.fn>) {
|
||
expect(mockCreateTab.mock.invocationCallOrder[0]).toBeLessThan(
|
||
mockSendInput.mock.invocationCallOrder.at(-1) ?? Number.MAX_SAFE_INTEGER,
|
||
);
|
||
}
|
||
|
||
it("creates a new tab before sending an initialCommand on a fresh modal open", async () => {
|
||
vi.useFakeTimers();
|
||
const newScriptTab = scriptTab("tab-script", "script-session-456");
|
||
const mockCreateTab = vi.fn().mockResolvedValue(newScriptTab);
|
||
useConnectedTerminal();
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
createTab: mockCreateTab,
|
||
});
|
||
|
||
try {
|
||
const { rerender } = render(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="npm run build" />
|
||
);
|
||
|
||
await flushCreateTabPromise();
|
||
expect(mockCreateTab).toHaveBeenCalledTimes(1);
|
||
expect(mockSendInput).not.toHaveBeenCalled();
|
||
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [{ ...defaultTab, isActive: false }, newScriptTab],
|
||
activeTab: newScriptTab,
|
||
createTab: mockCreateTab,
|
||
});
|
||
rerender(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="npm run build" />
|
||
);
|
||
|
||
await flushInitialCommandDelay();
|
||
expect(mockUseTerminal).toHaveBeenLastCalledWith("script-session-456", undefined);
|
||
expect(mockSendInput).toHaveBeenCalledWith("npm run build\n");
|
||
expectCommandSentAfterCreateTab(mockCreateTab);
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
it("keeps the pending quick-script command when a session transition interrupts the delay", async () => {
|
||
vi.useFakeTimers();
|
||
const newScriptTab = scriptTab("tab-script", "script-session-456");
|
||
const mockCreateTab = vi.fn().mockResolvedValue(newScriptTab);
|
||
useConnectedTerminal();
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
createTab: mockCreateTab,
|
||
});
|
||
|
||
try {
|
||
const { rerender } = render(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="pnpm build" initialCommandGeneration={1} />
|
||
);
|
||
|
||
await flushCreateTabPromise();
|
||
expect(mockCreateTab).toHaveBeenCalledTimes(1);
|
||
expect(mockSendInput).not.toHaveBeenCalled();
|
||
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [{ ...defaultTab, isActive: false }, newScriptTab],
|
||
activeTab: newScriptTab,
|
||
createTab: mockCreateTab,
|
||
});
|
||
rerender(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="pnpm build" initialCommandGeneration={1} />
|
||
);
|
||
|
||
await act(async () => {
|
||
await vi.advanceTimersByTimeAsync(250);
|
||
});
|
||
expect(mockSendInput).not.toHaveBeenCalled();
|
||
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({ connectionStatus: "connecting" })
|
||
);
|
||
rerender(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="pnpm build" initialCommandGeneration={1} />
|
||
);
|
||
|
||
await flushInitialCommandDelay();
|
||
expect(mockSendInput).not.toHaveBeenCalled();
|
||
|
||
useConnectedTerminal();
|
||
rerender(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="pnpm build" initialCommandGeneration={1} />
|
||
);
|
||
|
||
await flushInitialCommandDelay();
|
||
expect(mockUseTerminal).toHaveBeenLastCalledWith("script-session-456", undefined);
|
||
expect(mockSendInput).toHaveBeenCalledTimes(1);
|
||
expect(mockSendInput).toHaveBeenCalledWith("pnpm build\n");
|
||
expectCommandSentAfterCreateTab(mockCreateTab);
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
it("waits for the new script session to connect before sending the quick-script command", async () => {
|
||
vi.useFakeTimers();
|
||
const newScriptTab = scriptTab("tab-script", "script-session-456");
|
||
const mockCreateTab = vi.fn().mockResolvedValue(newScriptTab);
|
||
useConnectedTerminal();
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
createTab: mockCreateTab,
|
||
});
|
||
|
||
try {
|
||
const { rerender } = render(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="pnpm build" initialCommandGeneration={1} />
|
||
);
|
||
|
||
await flushCreateTabPromise();
|
||
expect(mockCreateTab).toHaveBeenCalledTimes(1);
|
||
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({ connectionStatus: "connecting" })
|
||
);
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [{ ...defaultTab, isActive: false }, newScriptTab],
|
||
activeTab: newScriptTab,
|
||
createTab: mockCreateTab,
|
||
});
|
||
rerender(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="pnpm build" initialCommandGeneration={1} />
|
||
);
|
||
|
||
await flushInitialCommandDelay();
|
||
expect(mockUseTerminal).toHaveBeenLastCalledWith("script-session-456", undefined);
|
||
expect(mockSendInput).not.toHaveBeenCalledWith("pnpm build\n");
|
||
|
||
useConnectedTerminal();
|
||
rerender(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="pnpm build" initialCommandGeneration={1} />
|
||
);
|
||
|
||
await flushInitialCommandDelay();
|
||
expect(mockUseTerminal).toHaveBeenLastCalledWith("script-session-456", undefined);
|
||
expect(mockSendInput).toHaveBeenCalledTimes(1);
|
||
expect(mockSendInput).toHaveBeenCalledWith("pnpm build\n");
|
||
expectCommandSentAfterCreateTab(mockCreateTab);
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
it("keeps the quick-script command pending if the user switches tabs during the delay", async () => {
|
||
vi.useFakeTimers();
|
||
const newScriptTab = scriptTab("tab-script", "script-session-456");
|
||
const existingTab = { ...defaultTab, title: "Terminal 1", isActive: true };
|
||
const mockCreateTab = vi.fn().mockResolvedValue(newScriptTab);
|
||
useConnectedTerminal();
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [existingTab],
|
||
activeTab: existingTab,
|
||
createTab: mockCreateTab,
|
||
});
|
||
|
||
try {
|
||
const { rerender } = render(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="pnpm build" initialCommandGeneration={1} />
|
||
);
|
||
|
||
await flushCreateTabPromise();
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [{ ...existingTab, isActive: false }, newScriptTab],
|
||
activeTab: newScriptTab,
|
||
createTab: mockCreateTab,
|
||
});
|
||
rerender(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="pnpm build" initialCommandGeneration={1} />
|
||
);
|
||
|
||
await act(async () => {
|
||
await vi.advanceTimersByTimeAsync(250);
|
||
});
|
||
expect(mockSendInput).not.toHaveBeenCalled();
|
||
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [{ ...existingTab, isActive: true }, { ...newScriptTab, isActive: false }],
|
||
activeTab: existingTab,
|
||
createTab: mockCreateTab,
|
||
});
|
||
rerender(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="pnpm build" initialCommandGeneration={1} />
|
||
);
|
||
|
||
await flushInitialCommandDelay();
|
||
expect(mockUseTerminal).toHaveBeenLastCalledWith("test-session-123", undefined);
|
||
expect(mockSendInput).not.toHaveBeenCalled();
|
||
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [{ ...existingTab, isActive: false }, newScriptTab],
|
||
activeTab: newScriptTab,
|
||
createTab: mockCreateTab,
|
||
});
|
||
rerender(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="pnpm build" initialCommandGeneration={1} />
|
||
);
|
||
|
||
await flushInitialCommandDelay();
|
||
expect(mockUseTerminal).toHaveBeenLastCalledWith("script-session-456", undefined);
|
||
expect(mockSendInput).toHaveBeenCalledTimes(1);
|
||
expect(mockSendInput).toHaveBeenCalledWith("pnpm build\n");
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
it("dedupes same initialCommand generation on ordinary re-renders", async () => {
|
||
vi.useFakeTimers();
|
||
const newScriptTab = scriptTab("tab-script", "script-session-456");
|
||
const mockCreateTab = vi.fn().mockResolvedValue(newScriptTab);
|
||
useConnectedTerminal();
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
createTab: mockCreateTab,
|
||
});
|
||
|
||
try {
|
||
const { rerender } = render(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="npm run build" />
|
||
);
|
||
|
||
await flushCreateTabPromise();
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [{ ...defaultTab, isActive: false }, newScriptTab],
|
||
activeTab: newScriptTab,
|
||
createTab: mockCreateTab,
|
||
});
|
||
rerender(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="npm run build" />
|
||
);
|
||
await flushInitialCommandDelay();
|
||
expect(mockSendInput).toHaveBeenCalledWith("npm run build\n");
|
||
|
||
const createCount = mockCreateTab.mock.calls.length;
|
||
const sendCount = mockSendInput.mock.calls.length;
|
||
|
||
rerender(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="npm run build" />
|
||
);
|
||
|
||
await flushInitialCommandDelay();
|
||
expect(mockCreateTab).toHaveBeenCalledTimes(createCount);
|
||
expect(mockSendInput).toHaveBeenCalledTimes(sendCount);
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
it("creates a new tab before sending an initialCommand that arrives while terminal is already open", async () => {
|
||
vi.useFakeTimers();
|
||
const newScriptTab = scriptTab("tab-script", "script-session-456");
|
||
const mockCreateTab = vi.fn().mockResolvedValue(newScriptTab);
|
||
useConnectedTerminal();
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
createTab: mockCreateTab,
|
||
});
|
||
|
||
try {
|
||
const { rerender } = render(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} />
|
||
);
|
||
|
||
rerender(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="pnpm test" />
|
||
);
|
||
|
||
await flushCreateTabPromise();
|
||
expect(mockCreateTab).toHaveBeenCalledTimes(1);
|
||
expect(mockSendInput).not.toHaveBeenCalledWith("pnpm test\n");
|
||
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [{ ...defaultTab, isActive: false }, newScriptTab],
|
||
activeTab: newScriptTab,
|
||
createTab: mockCreateTab,
|
||
});
|
||
rerender(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="pnpm test" />
|
||
);
|
||
|
||
await flushInitialCommandDelay();
|
||
expect(mockUseTerminal).toHaveBeenLastCalledWith("script-session-456", undefined);
|
||
expect(mockSendInput).toHaveBeenCalledWith("pnpm test\n");
|
||
expectCommandSentAfterCreateTab(mockCreateTab);
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
it("creates a new tab before sending a changed initialCommand while terminal remains open", async () => {
|
||
vi.useFakeTimers();
|
||
const firstScriptTab = scriptTab("tab-script-1", "script-session-456", "Terminal 2");
|
||
const secondScriptTab = scriptTab("tab-script-2", "script-session-789", "Terminal 3");
|
||
const mockCreateTab = vi.fn()
|
||
.mockResolvedValueOnce(firstScriptTab)
|
||
.mockResolvedValueOnce(secondScriptTab);
|
||
useConnectedTerminal();
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
createTab: mockCreateTab,
|
||
});
|
||
|
||
try {
|
||
const { rerender } = render(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="npm run build" />
|
||
);
|
||
|
||
await flushCreateTabPromise();
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [{ ...defaultTab, isActive: false }, firstScriptTab],
|
||
activeTab: firstScriptTab,
|
||
createTab: mockCreateTab,
|
||
});
|
||
rerender(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="npm run build" />
|
||
);
|
||
await flushInitialCommandDelay();
|
||
expect(mockSendInput).toHaveBeenCalledWith("npm run build\n");
|
||
|
||
rerender(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="pnpm test" />
|
||
);
|
||
|
||
await flushCreateTabPromise();
|
||
expect(mockCreateTab).toHaveBeenCalledTimes(2);
|
||
expect(mockSendInput).not.toHaveBeenCalledWith("pnpm test\n");
|
||
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [{ ...defaultTab, isActive: false }, { ...firstScriptTab, isActive: false }, secondScriptTab],
|
||
activeTab: secondScriptTab,
|
||
createTab: mockCreateTab,
|
||
});
|
||
rerender(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="pnpm test" />
|
||
);
|
||
|
||
await flushInitialCommandDelay();
|
||
expect(mockUseTerminal).toHaveBeenLastCalledWith("script-session-789", undefined);
|
||
expect(mockSendInput).toHaveBeenCalledWith("pnpm test\n");
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
it("creates a new tab for the same command when the runScript generation changes", async () => {
|
||
vi.useFakeTimers();
|
||
const firstScriptTab = scriptTab("tab-script-1", "script-session-456", "Terminal 2");
|
||
const secondScriptTab = scriptTab("tab-script-2", "script-session-789", "Terminal 3");
|
||
const mockCreateTab = vi.fn()
|
||
.mockResolvedValueOnce(firstScriptTab)
|
||
.mockResolvedValueOnce(secondScriptTab);
|
||
useConnectedTerminal();
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
createTab: mockCreateTab,
|
||
});
|
||
|
||
try {
|
||
const { rerender } = render(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="pnpm test" initialCommandGeneration={1} />
|
||
);
|
||
|
||
await flushCreateTabPromise();
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [{ ...defaultTab, isActive: false }, firstScriptTab],
|
||
activeTab: firstScriptTab,
|
||
createTab: mockCreateTab,
|
||
});
|
||
rerender(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="pnpm test" initialCommandGeneration={1} />
|
||
);
|
||
await flushInitialCommandDelay();
|
||
expect(mockSendInput).toHaveBeenCalledTimes(1);
|
||
|
||
rerender(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="pnpm test" initialCommandGeneration={2} />
|
||
);
|
||
await flushCreateTabPromise();
|
||
expect(mockCreateTab).toHaveBeenCalledTimes(2);
|
||
expect(mockSendInput).toHaveBeenCalledTimes(1);
|
||
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [{ ...defaultTab, isActive: false }, { ...firstScriptTab, isActive: false }, secondScriptTab],
|
||
activeTab: secondScriptTab,
|
||
createTab: mockCreateTab,
|
||
});
|
||
rerender(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="pnpm test" initialCommandGeneration={2} />
|
||
);
|
||
|
||
await flushInitialCommandDelay();
|
||
expect(mockUseTerminal).toHaveBeenLastCalledWith("script-session-789", undefined);
|
||
expect(mockSendInput).toHaveBeenCalledTimes(2);
|
||
expect(mockSendInput).toHaveBeenLastCalledWith("pnpm test\n");
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
it("creates the script tab after the auto-created blank tab on a fresh open", async () => {
|
||
vi.useFakeTimers();
|
||
const autoCreatedBlankTab = {
|
||
...defaultTab,
|
||
title: "Terminal 1",
|
||
isActive: true,
|
||
};
|
||
const newScriptTab = scriptTab("tab-script", "script-session-456", "Terminal 2");
|
||
const mockCreateTab = vi.fn().mockResolvedValue(newScriptTab);
|
||
useConnectedTerminal();
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [autoCreatedBlankTab],
|
||
activeTab: autoCreatedBlankTab,
|
||
createTab: mockCreateTab,
|
||
});
|
||
|
||
try {
|
||
const { rerender } = render(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="npm run build" />
|
||
);
|
||
|
||
await flushCreateTabPromise();
|
||
expect(mockCreateTab).toHaveBeenCalledTimes(1);
|
||
expect(mockSendInput).not.toHaveBeenCalled();
|
||
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [{ ...autoCreatedBlankTab, isActive: false }, newScriptTab],
|
||
activeTab: newScriptTab,
|
||
createTab: mockCreateTab,
|
||
});
|
||
rerender(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="npm run build" />
|
||
);
|
||
|
||
await flushInitialCommandDelay();
|
||
expect(mockSendInput).toHaveBeenCalledWith("npm run build\n");
|
||
expect(screen.getByText("Terminal 1")).toBeTruthy();
|
||
expect(screen.getByText("Terminal 2")).toBeTruthy();
|
||
expect(screen.getByText("Terminal 2").closest(".terminal-tab")?.className).toContain("active");
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
it("creates a script tab when multiple tabs already exist", async () => {
|
||
vi.useFakeTimers();
|
||
const existingTabOne = { ...defaultTab, isActive: false, title: "Terminal 1" };
|
||
const existingTabTwo = {
|
||
id: "tab-2",
|
||
sessionId: "session-2",
|
||
title: "Terminal 2",
|
||
isActive: true,
|
||
createdAt: Date.now(),
|
||
};
|
||
const newScriptTab = scriptTab("tab-script", "script-session-456", "Terminal 3");
|
||
const mockCreateTab = vi.fn().mockResolvedValue(newScriptTab);
|
||
useConnectedTerminal();
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [existingTabOne, existingTabTwo],
|
||
activeTab: existingTabTwo,
|
||
createTab: mockCreateTab,
|
||
});
|
||
|
||
try {
|
||
const { rerender } = render(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="pnpm lint" />
|
||
);
|
||
|
||
await flushCreateTabPromise();
|
||
expect(mockCreateTab).toHaveBeenCalledTimes(1);
|
||
expect(mockSendInput).not.toHaveBeenCalled();
|
||
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [existingTabOne, { ...existingTabTwo, isActive: false }, newScriptTab],
|
||
activeTab: newScriptTab,
|
||
createTab: mockCreateTab,
|
||
});
|
||
rerender(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="pnpm lint" />
|
||
);
|
||
|
||
await flushInitialCommandDelay();
|
||
expect(mockUseTerminal).toHaveBeenLastCalledWith("script-session-456", undefined);
|
||
expect(mockSendInput).toHaveBeenCalledWith("pnpm lint\n");
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
it("resends command after modal close and reopen by creating a new tab", async () => {
|
||
vi.useFakeTimers();
|
||
const firstScriptTab = scriptTab("tab-script-1", "script-session-456", "Terminal 2");
|
||
const secondScriptTab = scriptTab("tab-script-2", "script-session-789", "Terminal 2");
|
||
const mockCreateTab = vi.fn()
|
||
.mockResolvedValueOnce(firstScriptTab)
|
||
.mockResolvedValueOnce(secondScriptTab);
|
||
useConnectedTerminal();
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
createTab: mockCreateTab,
|
||
});
|
||
|
||
try {
|
||
const { rerender } = render(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="npm run build" />
|
||
);
|
||
|
||
await flushCreateTabPromise();
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [{ ...defaultTab, isActive: false }, firstScriptTab],
|
||
activeTab: firstScriptTab,
|
||
createTab: mockCreateTab,
|
||
});
|
||
rerender(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="npm run build" />
|
||
);
|
||
await flushInitialCommandDelay();
|
||
expect(mockSendInput).toHaveBeenCalledWith("npm run build\n");
|
||
|
||
rerender(
|
||
<TerminalModal isOpen={false} onClose={mockOnClose} initialCommand="npm run build" />
|
||
);
|
||
|
||
mockSendInput.mockClear();
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
createTab: mockCreateTab,
|
||
});
|
||
rerender(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="npm run build" />
|
||
);
|
||
|
||
await flushCreateTabPromise();
|
||
expect(mockCreateTab).toHaveBeenCalledTimes(2);
|
||
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [{ ...defaultTab, isActive: false }, secondScriptTab],
|
||
activeTab: secondScriptTab,
|
||
createTab: mockCreateTab,
|
||
});
|
||
rerender(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} initialCommand="npm run build" />
|
||
);
|
||
|
||
await flushInitialCommandDelay();
|
||
expect(mockUseTerminal).toHaveBeenLastCalledWith("script-session-789", undefined);
|
||
expect(mockSendInput).toHaveBeenCalledWith("npm run build\n");
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
});
|
||
|
||
// --- xterm initialization watchdog tests ---
|
||
describe("xterm initialization watchdog", () => {
|
||
it("shows xterm init error overlay with reinitialize and refresh actions when xterm constructor throws", async () => {
|
||
// Override the mock to throw on construction
|
||
const { Terminal } = await import("@xterm/xterm");
|
||
const OrigTerminal = Terminal;
|
||
|
||
// Replace Terminal constructor with one that throws
|
||
const throwingModule = await import("@xterm/xterm");
|
||
(throwingModule as any).Terminal = vi.fn(function ThrowingTerminalMock() {
|
||
throw new Error("xterm constructor failed");
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
// Should show xterm init error
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-xterm-init-error")).toBeTruthy();
|
||
expect(screen.getByText(/Terminal UI failed to initialize/)).toBeTruthy();
|
||
});
|
||
|
||
// Should have both reinitialize + refresh fallback actions
|
||
const reinitBtn = screen.getByTestId("terminal-reinit-btn");
|
||
expect(reinitBtn).toBeTruthy();
|
||
expect(reinitBtn.textContent).toContain("Reinitialize");
|
||
|
||
const refreshBtn = screen.getByTestId("terminal-xterm-refresh-btn");
|
||
expect(refreshBtn).toBeTruthy();
|
||
expect(refreshBtn.textContent).toContain("Refresh page");
|
||
|
||
// Restore original Terminal
|
||
(throwingModule as any).Terminal = OrigTerminal;
|
||
});
|
||
|
||
it("clicking Reinitialize button clears error and triggers fresh init attempt", async () => {
|
||
// Make Terminal throw first, then work after reinitialize
|
||
const throwingModule = await import("@xterm/xterm");
|
||
const OrigTerminal = throwingModule.Terminal;
|
||
|
||
let callCount = 0;
|
||
(throwingModule as any).Terminal = vi.fn(function ReinitializingTerminalMock() {
|
||
callCount++;
|
||
if (callCount === 1) {
|
||
throw new Error("first init fails");
|
||
}
|
||
// Second call succeeds — return a mock terminal
|
||
return mockTerminalInstance;
|
||
});
|
||
|
||
const { rerender } = render(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} />
|
||
);
|
||
|
||
// Wait for error
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-xterm-init-error")).toBeTruthy();
|
||
});
|
||
|
||
// Click Reinitialize
|
||
const reinitBtn = screen.getByTestId("terminal-reinit-btn");
|
||
fireEvent.click(reinitBtn);
|
||
|
||
// After reinitialize, the error should be cleared and xterm should init successfully
|
||
await waitFor(() => {
|
||
expect(screen.queryByTestId("terminal-xterm-init-error")).toBeNull();
|
||
});
|
||
|
||
// Restore
|
||
(throwingModule as any).Terminal = OrigTerminal;
|
||
});
|
||
|
||
it("xterm init refresh button reloads the page", async () => {
|
||
const reloadMock = vi.fn();
|
||
const originalWindow = globalThis.window;
|
||
const patchedWindow = Object.create(originalWindow) as Window & typeof globalThis;
|
||
|
||
Object.defineProperty(patchedWindow, "location", {
|
||
value: {
|
||
...originalWindow.location,
|
||
reload: reloadMock,
|
||
},
|
||
configurable: true,
|
||
});
|
||
|
||
(globalThis as { window: Window & typeof globalThis }).window = patchedWindow;
|
||
|
||
try {
|
||
const xtermModule = await import("@xterm/xterm");
|
||
const OrigTerminal = xtermModule.Terminal;
|
||
|
||
(xtermModule as any).Terminal = vi.fn(function ThrowingTerminalMock() {
|
||
throw new Error("xterm constructor failed");
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-xterm-init-error")).toBeTruthy();
|
||
});
|
||
|
||
fireEvent.click(screen.getByTestId("terminal-xterm-refresh-btn"));
|
||
expect(reloadMock).toHaveBeenCalledTimes(1);
|
||
|
||
(xtermModule as any).Terminal = OrigTerminal;
|
||
} finally {
|
||
(globalThis as { window: Window & typeof globalThis }).window = originalWindow;
|
||
}
|
||
});
|
||
|
||
it("shows timeout error when xterm initialization exceeds XTERM_INIT_TIMEOUT_MS", async () => {
|
||
// This test uses vi.isolateModules to override the @xterm/xterm mock
|
||
// for this test only, making the dynamic import hang so the watchdog fires.
|
||
|
||
// Since isolateModules runs the factory in isolation, we need to set up
|
||
// all mocks inside the callback. However, this conflicts with the hoisted
|
||
// vi.mock calls used by the rest of the test suite.
|
||
//
|
||
// Alternative: directly exercise the timeout path by overriding the module's
|
||
// Terminal export to delay. Since the component does:
|
||
// await Promise.race([Promise.all([import("@xterm/xterm"), ...]), timeout])
|
||
// and vi.mock resolves imports instantly, the race is always won by imports.
|
||
//
|
||
// We CAN test the timeout by making one of the dynamic imports throw after a
|
||
// delay, but since imports are vi.mock'd, they resolve immediately.
|
||
//
|
||
// Best practical test: verify the timeout error message is rendered correctly
|
||
// by directly triggering the catch block with a timeout-like error.
|
||
const xtermModule = await import("@xterm/xterm");
|
||
const OrigTerminal = xtermModule.Terminal;
|
||
|
||
// Simulate the timeout error by making Terminal constructor throw
|
||
// with the exact timeout message the watchdog would produce
|
||
(xtermModule as any).Terminal = vi.fn(function TimeoutTerminalMock() {
|
||
throw new Error("xterm initialization timed out");
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-xterm-init-error")).toBeTruthy();
|
||
});
|
||
|
||
// Verify the timeout-specific message is rendered
|
||
expect(screen.getByText(/timed out/)).toBeTruthy();
|
||
|
||
// Reinitialize button should be present
|
||
expect(screen.getByTestId("terminal-reinit-btn")).toBeTruthy();
|
||
|
||
// Restore
|
||
(xtermModule as any).Terminal = OrigTerminal;
|
||
});
|
||
|
||
it("does not show xterm init error when no activeTab (bootstrap error takes priority)", async () => {
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [],
|
||
activeTab: null,
|
||
bootstrapError: "Server unreachable",
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
// Should show bootstrap error, not xterm init error
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-bootstrap-error")).toBeTruthy();
|
||
});
|
||
expect(screen.queryByTestId("terminal-xterm-init-error")).toBeNull();
|
||
});
|
||
|
||
it("xterm init error is cleared when modal is closed and reopened", async () => {
|
||
// Force xterm init error
|
||
const throwingModule = await import("@xterm/xterm");
|
||
const OrigTerminal = throwingModule.Terminal;
|
||
|
||
(throwingModule as any).Terminal = vi.fn(function ThrowingTerminalMock() {
|
||
throw new Error("xterm constructor failed");
|
||
});
|
||
|
||
const { rerender } = render(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} />
|
||
);
|
||
|
||
// Wait for error to appear
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-xterm-init-error")).toBeTruthy();
|
||
});
|
||
|
||
// Close the modal
|
||
rerender(<TerminalModal isOpen={false} onClose={mockOnClose} />);
|
||
|
||
// Modal is gone
|
||
expect(screen.queryByTestId("terminal-xterm-init-error")).toBeNull();
|
||
|
||
// Restore working xterm
|
||
(throwingModule as any).Terminal = OrigTerminal;
|
||
|
||
// Reopen the modal
|
||
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
// Should NOT show the old xterm init error — fresh init attempt
|
||
await waitFor(() => {
|
||
expect(screen.queryByTestId("terminal-xterm-init-error")).toBeNull();
|
||
});
|
||
});
|
||
});
|
||
|
||
// --- FN-1739 mobile WebGL skip regression tests ---
|
||
describe("mobile WebGL skip (FN-1739)", () => {
|
||
let savedInnerWidth: typeof window.innerWidth;
|
||
let savedOntouchstart: typeof window.ontouchstart;
|
||
let savedNavigator: typeof navigator;
|
||
|
||
beforeEach(() => {
|
||
savedInnerWidth = window.innerWidth;
|
||
savedOntouchstart = window.ontouchstart;
|
||
savedNavigator = navigator;
|
||
|
||
// Mock WebGL addon to track if it's loaded
|
||
vi.mock("@xterm/addon-webgl", () => ({
|
||
WebglAddon: vi.fn(function WebglAddonMock() {
|
||
return {
|
||
onContextLoss: vi.fn(),
|
||
dispose: vi.fn(),
|
||
};
|
||
}),
|
||
}));
|
||
});
|
||
|
||
afterEach(() => {
|
||
Object.defineProperty(window, "innerWidth", {
|
||
value: savedInnerWidth,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(window, "ontouchstart", {
|
||
value: savedOntouchstart,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(navigator, "maxTouchPoints", {
|
||
value: (savedNavigator as any).maxTouchPoints,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
});
|
||
|
||
function simulateMobileDevice() {
|
||
Object.defineProperty(window, "innerWidth", {
|
||
value: 375,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(window, "ontouchstart", {
|
||
value: undefined,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(navigator, "maxTouchPoints", {
|
||
value: 2,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
}
|
||
|
||
it("does not load WebGL addon when device is mobile", async () => {
|
||
simulateMobileDevice();
|
||
|
||
// Import WebGL addon mock to get reference for assertions
|
||
const webglModule = await import("@xterm/addon-webgl");
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
// Wait for xterm initialization to complete
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||
});
|
||
|
||
// WebGL addon constructor should NOT have been called
|
||
expect(webglModule.WebglAddon).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe("xterm import MIME type retry", () => {
|
||
function isXtermImportBatch(values: Iterable<unknown>): values is Promise<unknown>[] {
|
||
return (
|
||
Array.isArray(values) &&
|
||
values.length === 3 &&
|
||
values.every((entry) => entry && typeof (entry as Promise<unknown>).then === "function")
|
||
);
|
||
}
|
||
|
||
afterEach(() => {
|
||
vi.useRealTimers();
|
||
});
|
||
|
||
it("retries MIME type import failures and initializes successfully on a later attempt", async () => {
|
||
vi.useFakeTimers();
|
||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||
const originalPromiseAll = Promise.all.bind(Promise);
|
||
let importAttempts = 0;
|
||
|
||
vi.spyOn(Promise, "all").mockImplementation(((values: Iterable<unknown>) => {
|
||
if (isXtermImportBatch(values)) {
|
||
importAttempts += 1;
|
||
if (importAttempts === 1) {
|
||
return Promise.reject(
|
||
new Error("'text/html' is not a valid JavaScript MIME type"),
|
||
);
|
||
}
|
||
}
|
||
|
||
return originalPromiseAll(values);
|
||
}) as typeof Promise.all);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await act(async () => {
|
||
await vi.advanceTimersByTimeAsync(500);
|
||
});
|
||
|
||
vi.useRealTimers();
|
||
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||
});
|
||
|
||
expect(importAttempts).toBe(2);
|
||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||
expect(screen.queryByTestId("terminal-xterm-init-error")).toBeNull();
|
||
});
|
||
|
||
it("shows xterm init error UI when MIME type import retries are exhausted", async () => {
|
||
vi.useFakeTimers();
|
||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||
const originalPromiseAll = Promise.all.bind(Promise);
|
||
let importAttempts = 0;
|
||
|
||
vi.spyOn(Promise, "all").mockImplementation(((values: Iterable<unknown>) => {
|
||
if (isXtermImportBatch(values)) {
|
||
importAttempts += 1;
|
||
return Promise.reject(
|
||
new Error("'text/html' is not a valid JavaScript MIME type"),
|
||
);
|
||
}
|
||
|
||
return originalPromiseAll(values);
|
||
}) as typeof Promise.all);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await act(async () => {
|
||
await vi.advanceTimersByTimeAsync(5000);
|
||
});
|
||
|
||
vi.useRealTimers();
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-xterm-init-error")).toBeTruthy();
|
||
});
|
||
|
||
expect(screen.getByText(/MIME type/)).toBeTruthy();
|
||
expect(importAttempts).toBe(4);
|
||
expect(warnSpy).toHaveBeenCalledTimes(3);
|
||
});
|
||
|
||
it("does not retry non-MIME import failures", async () => {
|
||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||
const originalPromiseAll = Promise.all.bind(Promise);
|
||
let importAttempts = 0;
|
||
|
||
vi.spyOn(Promise, "all").mockImplementation(((values: Iterable<unknown>) => {
|
||
if (isXtermImportBatch(values)) {
|
||
importAttempts += 1;
|
||
return Promise.reject(new Error("xterm constructor failed"));
|
||
}
|
||
|
||
return originalPromiseAll(values);
|
||
}) as typeof Promise.all);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-xterm-init-error")).toBeTruthy();
|
||
});
|
||
|
||
expect(screen.getByText(/xterm constructor failed/)).toBeTruthy();
|
||
expect(importAttempts).toBe(1);
|
||
expect(warnSpy).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
// --- Invalid session auto-recovery ---
|
||
describe("invalid session auto-recovery (FN-1021)", () => {
|
||
it("calls replaceActiveTabSession when WebSocket reports session invalid (code 4004)", async () => {
|
||
const mockReplaceActiveTabSession = vi.fn().mockResolvedValue(undefined);
|
||
let capturedSessionInvalidCb: (() => void) | null = null;
|
||
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
replaceActiveTabSession: mockReplaceActiveTabSession,
|
||
});
|
||
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({
|
||
connectionStatus: "disconnected",
|
||
onSessionInvalid: vi.fn((cb: () => void) => {
|
||
capturedSessionInvalidCb = cb;
|
||
return vi.fn();
|
||
}),
|
||
})
|
||
);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(capturedSessionInvalidCb).not.toBeNull();
|
||
});
|
||
|
||
// Simulate the WebSocket reporting session invalid
|
||
act(() => {
|
||
capturedSessionInvalidCb!();
|
||
});
|
||
|
||
expect(mockReplaceActiveTabSession).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("clears xterm state when session is invalid", async () => {
|
||
const mockReplaceActiveTabSession = vi.fn().mockResolvedValue(undefined);
|
||
let capturedSessionInvalidCb: (() => void) | null = null;
|
||
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
replaceActiveTabSession: mockReplaceActiveTabSession,
|
||
});
|
||
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({
|
||
connectionStatus: "connected",
|
||
onSessionInvalid: vi.fn((cb: () => void) => {
|
||
capturedSessionInvalidCb = cb;
|
||
return vi.fn();
|
||
}),
|
||
})
|
||
);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
// Wait for xterm to initialize
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||
});
|
||
|
||
// Simulate session invalidation
|
||
act(() => {
|
||
capturedSessionInvalidCb!();
|
||
});
|
||
|
||
// xterm should be disposed and cleared for fresh init
|
||
expect(mockTerminalInstance.dispose).toHaveBeenCalled();
|
||
expect(mockTerminalInstance.clear).toHaveBeenCalled();
|
||
});
|
||
|
||
it("terminal is usable after session recovery without page reload", async () => {
|
||
const mockReplaceActiveTabSession = vi.fn().mockResolvedValue(undefined);
|
||
let capturedSessionInvalidCb: (() => void) | null = null;
|
||
|
||
// Start with a stale session that will be invalidated
|
||
const staleTab = {
|
||
id: "tab-stale",
|
||
sessionId: "stale-session-999",
|
||
title: "bash",
|
||
isActive: true,
|
||
createdAt: Date.now(),
|
||
};
|
||
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [staleTab],
|
||
activeTab: staleTab,
|
||
replaceActiveTabSession: mockReplaceActiveTabSession,
|
||
});
|
||
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({
|
||
connectionStatus: "disconnected",
|
||
onSessionInvalid: vi.fn((cb: () => void) => {
|
||
capturedSessionInvalidCb = cb;
|
||
return vi.fn();
|
||
}),
|
||
})
|
||
);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
|
||
});
|
||
|
||
// Trigger session invalidation
|
||
act(() => {
|
||
capturedSessionInvalidCb!();
|
||
});
|
||
|
||
// replaceActiveTabSession should be called — this creates a new session
|
||
await waitFor(() => {
|
||
expect(mockReplaceActiveTabSession).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
// Simulate the session hook returning a new session after replacement
|
||
const freshTab = {
|
||
id: "tab-stale",
|
||
sessionId: "fresh-session-001",
|
||
title: "bash",
|
||
isActive: true,
|
||
createdAt: Date.now(),
|
||
};
|
||
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [freshTab],
|
||
activeTab: freshTab,
|
||
replaceActiveTabSession: mockReplaceActiveTabSession,
|
||
});
|
||
|
||
// After replacement, useTerminal should be called with the new session ID
|
||
// This happens automatically because activeTab.sessionId changed
|
||
// The modal should still be open and usable
|
||
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
|
||
|
||
// No bootstrap error should be shown (we recovered)
|
||
expect(screen.queryByTestId("terminal-bootstrap-error")).toBeNull();
|
||
});
|
||
});
|
||
});
|
||
|
||
// --- Mobile layout regression tests ---
|
||
describe("TerminalModal — mobile layout contract", () => {
|
||
const mockOnClose = vi.fn();
|
||
const mockSendInput = vi.fn();
|
||
const mockResize = vi.fn();
|
||
const mockReconnect = vi.fn();
|
||
|
||
// Helper: create 5+ tabs for the many-tabs scenario
|
||
const createManyTabs = () => [
|
||
{ id: "tab-1", sessionId: "s-1", title: "bash", isActive: true, createdAt: Date.now() },
|
||
{ id: "tab-2", sessionId: "s-2", title: "zsh", isActive: false, createdAt: Date.now() },
|
||
{ id: "tab-3", sessionId: "s-3", title: "node", isActive: false, createdAt: Date.now() },
|
||
{ id: "tab-4", sessionId: "s-4", title: "python3", isActive: false, createdAt: Date.now() },
|
||
{ id: "tab-5", sessionId: "s-5", title: "make test", isActive: false, createdAt: Date.now() },
|
||
{ id: "tab-6", sessionId: "s-6", title: "docker", isActive: false, createdAt: Date.now() },
|
||
];
|
||
|
||
const createMockTerminalState = (overrides = {}) => ({
|
||
connectionStatus: "disconnected" as const,
|
||
sendInput: mockSendInput,
|
||
resize: mockResize,
|
||
onData: vi.fn(() => vi.fn()),
|
||
onExit: vi.fn(() => vi.fn()),
|
||
onConnect: vi.fn(() => vi.fn()),
|
||
onScrollback: vi.fn(() => vi.fn()),
|
||
reconnect: mockReconnect,
|
||
onSessionInvalid: vi.fn(() => vi.fn()),
|
||
...overrides,
|
||
});
|
||
|
||
const manyTabsSessionState = {
|
||
tabs: createManyTabs(),
|
||
activeTab: createManyTabs()[0],
|
||
isReady: true,
|
||
bootstrapError: null,
|
||
createTab: vi.fn(),
|
||
closeTab: vi.fn(),
|
||
setActiveTab: vi.fn(),
|
||
updateTabTitle: vi.fn(),
|
||
restartActiveTab: vi.fn(),
|
||
retryBootstrap: vi.fn(),
|
||
replaceActiveTabSession: vi.fn().mockResolvedValue(undefined),
|
||
};
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
mockUseTerminal.mockReturnValue(createMockTerminalState());
|
||
mockUseTerminalSessions.mockReturnValue(manyTabsSessionState);
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
it("renders all 6 tabs inside terminal-tabs container with many tabs", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const tabsContainer = screen.getByTestId("terminal-tabs");
|
||
expect(tabsContainer).toBeTruthy();
|
||
|
||
// All 6 tab titles should be rendered
|
||
expect(screen.getByText("bash")).toBeTruthy();
|
||
expect(screen.getByText("zsh")).toBeTruthy();
|
||
expect(screen.getByText("node")).toBeTruthy();
|
||
expect(screen.getByText("python3")).toBeTruthy();
|
||
expect(screen.getByText("make test")).toBeTruthy();
|
||
expect(screen.getByText("docker")).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
it("preserves header/footer structure: tabs and title in header, actions in footer", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-tabs")).toBeTruthy();
|
||
expect(screen.getByTestId("terminal-title")).toBeTruthy();
|
||
expect(screen.getByTestId("terminal-footer-actions")).toBeTruthy();
|
||
expect(screen.queryByTestId("terminal-actions")).toBeNull();
|
||
});
|
||
});
|
||
|
||
it("close button is clickable with many tabs", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const closeBtn = screen.getByTestId("terminal-close-btn");
|
||
expect(closeBtn).toBeTruthy();
|
||
fireEvent.click(closeBtn);
|
||
});
|
||
|
||
expect(mockOnClose).toHaveBeenCalled();
|
||
});
|
||
|
||
it("clear button is clickable with many tabs", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const clearBtn = screen.getByTestId("terminal-clear-btn");
|
||
expect(clearBtn).toBeTruthy();
|
||
fireEvent.click(clearBtn);
|
||
});
|
||
|
||
// Clear calls xtermRef.current?.clear() — just verify button is functional
|
||
expect(screen.getByTestId("terminal-clear-btn")).toBeTruthy();
|
||
});
|
||
|
||
it("reconnect button is clickable with many tabs when disconnected", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const reconnectBtn = screen.getByTestId("terminal-reconnect-btn");
|
||
expect(reconnectBtn).toBeTruthy();
|
||
fireEvent.click(reconnectBtn);
|
||
});
|
||
|
||
expect(mockReconnect).toHaveBeenCalled();
|
||
});
|
||
|
||
it("action buttons have .terminal-action-label spans for mobile CSS targeting", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
// The reconnect and clear buttons should have .terminal-action-label spans
|
||
const reconnectBtn = screen.getByTestId("terminal-reconnect-btn");
|
||
const labelSpan = reconnectBtn.querySelector(".terminal-action-label");
|
||
expect(labelSpan).toBeTruthy();
|
||
expect(labelSpan?.textContent).toBe("Reconnect");
|
||
|
||
const clearBtn = screen.getByTestId("terminal-clear-btn");
|
||
const clearLabel = clearBtn.querySelector(".terminal-action-label");
|
||
expect(clearLabel).toBeTruthy();
|
||
expect(clearLabel?.textContent).toBe("Clear");
|
||
});
|
||
});
|
||
|
||
it("adds the shortcut spacing hook to the shortcuts toggle", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-shortcut-toggle").className).toContain(
|
||
"terminal-clear-btn--shortcut",
|
||
);
|
||
});
|
||
});
|
||
|
||
it("terminal-title section contains the status indicator for connection state", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const titleSection = screen.getByTestId("terminal-title");
|
||
// Should contain the TerminalIcon (svg) and the status indicator span
|
||
expect(titleSection.querySelector("svg")).toBeTruthy();
|
||
const statusIndicator = titleSection.querySelector(".terminal-status");
|
||
expect(statusIndicator).toBeTruthy();
|
||
// Disconnected state should show disconnected class
|
||
expect(statusIndicator?.classList.contains("disconnected")).toBe(true);
|
||
});
|
||
});
|
||
|
||
it("footer actions show connection state on true desktop with no header actions shell", async () => {
|
||
const previousInnerWidth = window.innerWidth;
|
||
Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true });
|
||
|
||
try {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const footer = screen.getByTestId("terminal-footer-actions");
|
||
expect(footer.className).toContain("terminal-status-bar");
|
||
const header = document.querySelector(".terminal-header");
|
||
const clearBtn = screen.getByTestId("terminal-clear-btn");
|
||
const shortcutToggle = screen.getByTestId("terminal-shortcut-toggle");
|
||
const preferencesToggle = screen.getByTestId("terminal-preferences-toggle");
|
||
const fontSizeValue = screen.getByTestId("terminal-font-size-value");
|
||
const pinToggle = screen.getByTestId("terminal-pin-toggle");
|
||
const popoutToggle = screen.getByTestId("terminal-popout-toggle");
|
||
const connectionStatus = footer.querySelector(".terminal-connection-status");
|
||
|
||
expect(connectionStatus?.textContent).toBe("Disconnected");
|
||
expect(footer.querySelector(".terminal-shortcuts--header")).toBeNull();
|
||
for (const control of [clearBtn, shortcutToggle, preferencesToggle, fontSizeValue, pinToggle, popoutToggle]) {
|
||
expect(footer.contains(control)).toBe(true);
|
||
expect(header?.contains(control)).toBe(false);
|
||
}
|
||
expect(screen.queryByTestId("terminal-actions")).toBeNull();
|
||
expect(header?.contains(screen.getByTestId("terminal-close-btn"))).toBe(true);
|
||
expect(header?.contains(screen.getByTestId("terminal-tabs"))).toBe(true);
|
||
});
|
||
} finally {
|
||
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
|
||
}
|
||
});
|
||
|
||
it("omits steady-state connected text and shortcut help from the shared footer controls", async () => {
|
||
const previousInnerWidth = window.innerWidth;
|
||
Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true });
|
||
|
||
try {
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({ connectionStatus: "connected" }),
|
||
);
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const footer = screen.getByTestId("terminal-footer-actions");
|
||
const connectionStatus = footer.querySelector(".terminal-connection-status");
|
||
|
||
expect(connectionStatus).toBeTruthy();
|
||
expect(connectionStatus?.textContent).toBe("");
|
||
expect(footer.querySelector(".terminal-shortcuts--header")).toBeNull();
|
||
expect(screen.queryByText("Ctrl++/- zoom • ⌨ Shortcuts panel • Esc close")).toBeNull();
|
||
});
|
||
} finally {
|
||
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
|
||
}
|
||
});
|
||
|
||
it("renders terminal action controls in a mobile footer, not the header (FN-7560)", async () => {
|
||
const previousInnerWidth = window.innerWidth;
|
||
Object.defineProperty(window, "innerWidth", { value: 390, configurable: true });
|
||
|
||
try {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const footer = screen.getByTestId("terminal-footer-actions");
|
||
expect(footer.className).toContain("terminal-status-bar");
|
||
|
||
const clearBtn = screen.getByTestId("terminal-clear-btn");
|
||
const shortcutToggle = screen.getByTestId("terminal-shortcut-toggle");
|
||
const preferencesToggle = screen.getByTestId("terminal-preferences-toggle");
|
||
const fontSizeValue = screen.getByTestId("terminal-font-size-value");
|
||
|
||
// Controls live inside the footer region...
|
||
expect(footer.contains(clearBtn)).toBe(true);
|
||
expect(footer.contains(shortcutToggle)).toBe(true);
|
||
expect(footer.contains(preferencesToggle)).toBe(true);
|
||
expect(footer.contains(fontSizeValue)).toBe(true);
|
||
|
||
// ...and NOT inside the header.
|
||
const header = document.querySelector(".terminal-header");
|
||
expect(header).toBeTruthy();
|
||
expect(header?.contains(clearBtn)).toBe(false);
|
||
expect(header?.contains(shortcutToggle)).toBe(false);
|
||
expect(header?.contains(preferencesToggle)).toBe(false);
|
||
expect(header?.contains(fontSizeValue)).toBe(false);
|
||
|
||
// No empty .terminal-actions shell renders in the mobile header, and non-mobile display toggles stay omitted.
|
||
expect(header?.querySelector(".terminal-actions")).toBeNull();
|
||
expect(screen.queryByTestId("terminal-pin-toggle")).toBeNull();
|
||
expect(screen.queryByTestId("terminal-popout-toggle")).toBeNull();
|
||
|
||
// The close button and mobile tab dropdown remain in the header.
|
||
const closeBtn = screen.getByTestId("terminal-close-btn");
|
||
expect(header?.contains(closeBtn)).toBe(true);
|
||
expect(footer.contains(closeBtn)).toBe(false);
|
||
const mobileTabs = screen.getByTestId("terminal-mobile-tabs");
|
||
expect(header?.contains(mobileTabs)).toBe(true);
|
||
});
|
||
} finally {
|
||
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
|
||
}
|
||
});
|
||
|
||
it("renders terminal action controls in a tablet footer, not the header, and keeps pin/pop-out toggles (FN-7684)", async () => {
|
||
// FN-7684: tablet width (769-1024px) must relocate the same shared
|
||
// terminalActionControls fragment into the footer, exactly as FN-7560
|
||
// did for mobile — but unlike mobile, tablet keeps the desktop pin/
|
||
// pop-out toggles and the desktop tab strip / workspace picker.
|
||
const previousInnerWidth = window.innerWidth;
|
||
Object.defineProperty(window, "innerWidth", { value: 900, configurable: true });
|
||
fireEvent(window, new Event("resize"));
|
||
|
||
try {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const footer = screen.getByTestId("terminal-footer-actions");
|
||
expect(footer.className).toContain("terminal-status-bar");
|
||
|
||
const clearBtn = screen.getByTestId("terminal-clear-btn");
|
||
const shortcutToggle = screen.getByTestId("terminal-shortcut-toggle");
|
||
const preferencesToggle = screen.getByTestId("terminal-preferences-toggle");
|
||
const fontSizeValue = screen.getByTestId("terminal-font-size-value");
|
||
const pinToggle = screen.getByTestId("terminal-pin-toggle");
|
||
const popoutToggle = screen.getByTestId("terminal-popout-toggle");
|
||
|
||
// Controls live inside the footer region, including the desktop-only
|
||
// pin/pop-out toggles (tablet keeps docked/floating/pinned-below modes)...
|
||
expect(footer.contains(clearBtn)).toBe(true);
|
||
expect(footer.contains(shortcutToggle)).toBe(true);
|
||
expect(footer.contains(preferencesToggle)).toBe(true);
|
||
expect(footer.contains(fontSizeValue)).toBe(true);
|
||
expect(footer.contains(pinToggle)).toBe(true);
|
||
expect(footer.contains(popoutToggle)).toBe(true);
|
||
|
||
// ...and NOT inside the header.
|
||
const header = document.querySelector(".terminal-header");
|
||
expect(header).toBeTruthy();
|
||
expect(header?.contains(clearBtn)).toBe(false);
|
||
expect(header?.contains(shortcutToggle)).toBe(false);
|
||
expect(header?.contains(preferencesToggle)).toBe(false);
|
||
expect(header?.contains(fontSizeValue)).toBe(false);
|
||
expect(header?.contains(pinToggle)).toBe(false);
|
||
expect(header?.contains(popoutToggle)).toBe(false);
|
||
|
||
// No empty .terminal-actions shell renders in the tablet header.
|
||
expect(header?.querySelector(".terminal-actions")).toBeNull();
|
||
|
||
// The close button, the desktop tab strip (not the mobile dropdown),
|
||
// and the workspace picker remain in the header.
|
||
const closeBtn = screen.getByTestId("terminal-close-btn");
|
||
expect(header?.contains(closeBtn)).toBe(true);
|
||
expect(footer.contains(closeBtn)).toBe(false);
|
||
expect(screen.queryByTestId("terminal-mobile-tabs")).toBeNull();
|
||
const tabs = screen.queryByTestId("terminal-tabs");
|
||
expect(tabs).toBeTruthy();
|
||
expect(header?.contains(tabs)).toBe(true);
|
||
// The workspace picker only renders when workspaces exist (default mock
|
||
// has none) — assert it stays in the header when present.
|
||
const workspacePicker = screen.queryByTestId("terminal-workspace-picker");
|
||
if (workspacePicker) {
|
||
expect(header?.contains(workspacePicker)).toBe(true);
|
||
}
|
||
});
|
||
} finally {
|
||
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
|
||
fireEvent(window, new Event("resize"));
|
||
}
|
||
});
|
||
|
||
it("pins the mobile close button to the top-right corner of the header, not buried in .terminal-actions (FN-7565)", async () => {
|
||
const previousInnerWidth = window.innerWidth;
|
||
Object.defineProperty(window, "innerWidth", { value: 390, configurable: true });
|
||
|
||
try {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const header = document.querySelector(".terminal-header");
|
||
expect(header).toBeTruthy();
|
||
|
||
// Exactly one close button renders on mobile — no duplicate close target.
|
||
const closeButtons = screen.getAllByTestId("terminal-close-btn");
|
||
expect(closeButtons).toHaveLength(1);
|
||
const closeBtn = closeButtons[0];
|
||
|
||
// It is a direct child of .terminal-header, not nested inside a wrapping
|
||
// .terminal-actions cluster (which no longer renders on mobile at all).
|
||
expect(closeBtn.parentElement).toBe(header);
|
||
expect(header?.querySelector(".terminal-actions")).toBeNull();
|
||
|
||
// It carries the mobile corner-pin class so CSS order/margin can place
|
||
// it last in flex order, flush against the right edge next to the tab
|
||
// dropdown, instead of falling back to order:0 (far left).
|
||
expect(closeBtn.className).toContain("terminal-close--corner");
|
||
});
|
||
} finally {
|
||
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
|
||
}
|
||
});
|
||
|
||
it("keeps the mobile corner-pin invariant across connection/exit states (FN-7565)", async () => {
|
||
const previousInnerWidth = window.innerWidth;
|
||
Object.defineProperty(window, "innerWidth", { value: 390, configurable: true });
|
||
|
||
try {
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({ connectionStatus: "disconnected" }),
|
||
);
|
||
const { rerender } = render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const header = document.querySelector(".terminal-header");
|
||
const closeBtn = screen.getByTestId("terminal-close-btn");
|
||
expect(closeBtn.parentElement).toBe(header);
|
||
expect(closeBtn.className).toContain("terminal-close--corner");
|
||
// Reconnect control lives in the footer, not the header, so it cannot
|
||
// crowd the corner-pinned close button.
|
||
expect(screen.getByTestId("terminal-reconnect-btn").closest(".terminal-header")).toBeNull();
|
||
});
|
||
|
||
let exitCallback: ((code: number) => void) | null = null;
|
||
const customOnExit = vi.fn((cb: (code: number) => void) => {
|
||
exitCallback = cb;
|
||
return vi.fn();
|
||
});
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({ connectionStatus: "connected", onExit: customOnExit }),
|
||
);
|
||
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||
});
|
||
act(() => {
|
||
exitCallback?.(1);
|
||
});
|
||
|
||
await waitFor(() => {
|
||
const header = document.querySelector(".terminal-header");
|
||
const closeBtn = screen.getByTestId("terminal-close-btn");
|
||
expect(closeBtn.parentElement).toBe(header);
|
||
expect(closeBtn.className).toContain("terminal-close--corner");
|
||
// Restart control + exit code live in the footer, not the header.
|
||
expect(screen.getByTestId("terminal-restart-btn").closest(".terminal-header")).toBeNull();
|
||
});
|
||
} finally {
|
||
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
|
||
}
|
||
});
|
||
|
||
it("keeps the desktop close button as a plain header control with no actions shell (FN-7565)", async () => {
|
||
const previousInnerWidth = window.innerWidth;
|
||
Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true });
|
||
|
||
try {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const closeButtons = screen.getAllByTestId("terminal-close-btn");
|
||
expect(closeButtons).toHaveLength(1);
|
||
const closeBtn = closeButtons[0];
|
||
const header = document.querySelector(".terminal-header");
|
||
|
||
expect(header?.contains(closeBtn)).toBe(true);
|
||
expect(screen.queryByTestId("terminal-actions")).toBeNull();
|
||
expect(closeBtn.className).not.toContain("terminal-close--corner");
|
||
});
|
||
} finally {
|
||
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
|
||
}
|
||
});
|
||
|
||
it("corner-pins the mobile close button after .terminal-mobile-tabs and .terminal-workspace-picker in flex order (FN-7565)", () => {
|
||
const nonMediaMobileRule =
|
||
terminalModalCss.match(/\.modal\.terminal-modal\.terminal-modal--mobile \.terminal-close--corner\s*\{([^}]*)\}/)?.[1] ?? "";
|
||
const mediaMobileRule =
|
||
terminalModalCss.match(/@media \(max-width: 768px\) \{[\s\S]*?\.terminal-close--corner\s*\{([^}]*)\}/)?.[1] ?? "";
|
||
const mobileTabsOrderMedia = terminalModalCss.match(/@media \(max-width: 768px\) \{[\s\S]*?\.terminal-mobile-tabs\s*\{([^}]*)\}/)?.[1] ?? "";
|
||
const workspacePickerOrderMedia = terminalModalCss.match(/@media \(max-width: 768px\) \{[\s\S]*?\.terminal-workspace-picker\s*\{([^}]*)\}/)?.[1] ?? "";
|
||
const baseHeaderRule = terminalModalCss.match(/\.terminal-header\s*\{([^}]*)\}/)?.[1] ?? "";
|
||
|
||
expect(nonMediaMobileRule).toContain("order: 3;");
|
||
expect(nonMediaMobileRule).toContain("margin-inline-start: auto;");
|
||
expect(mediaMobileRule).toContain("order: 3;");
|
||
expect(mediaMobileRule).toContain("margin-inline-start: auto;");
|
||
expect(mobileTabsOrderMedia).toContain("order: 1;");
|
||
expect(workspacePickerOrderMedia).toContain("order: 2;");
|
||
// Base header stays a flex row so order-based corner-pinning applies.
|
||
expect(baseHeaderRule).toContain("display: flex;");
|
||
});
|
||
|
||
it("shows the reconnect control in the mobile footer when disconnected (FN-7560)", async () => {
|
||
const previousInnerWidth = window.innerWidth;
|
||
Object.defineProperty(window, "innerWidth", { value: 390, configurable: true });
|
||
|
||
try {
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({ connectionStatus: "disconnected" }),
|
||
);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const footer = screen.getByTestId("terminal-footer-actions");
|
||
const reconnectBtn = screen.getByTestId("terminal-reconnect-btn");
|
||
expect(footer.contains(reconnectBtn)).toBe(true);
|
||
});
|
||
} finally {
|
||
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
|
||
}
|
||
});
|
||
|
||
it("shows the restart control and exit code in the mobile footer after the terminal exits (FN-7560)", async () => {
|
||
const previousInnerWidth = window.innerWidth;
|
||
Object.defineProperty(window, "innerWidth", { value: 390, configurable: true });
|
||
|
||
let exitCallback: ((code: number) => void) | null = null;
|
||
const customOnExit = vi.fn((cb: (code: number) => void) => {
|
||
exitCallback = cb;
|
||
return vi.fn();
|
||
});
|
||
|
||
try {
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({ connectionStatus: "connected", onExit: customOnExit }),
|
||
);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||
});
|
||
|
||
act(() => {
|
||
exitCallback?.(0);
|
||
});
|
||
|
||
await waitFor(() => {
|
||
const footer = screen.getByTestId("terminal-footer-actions");
|
||
const restartBtn = screen.getByTestId("terminal-restart-btn");
|
||
const exitCodeEl = screen.getByTestId("terminal-exit-code");
|
||
expect(footer.contains(restartBtn)).toBe(true);
|
||
expect(footer.contains(exitCodeEl)).toBe(true);
|
||
});
|
||
} finally {
|
||
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
|
||
}
|
||
});
|
||
|
||
it("delivers buffered terminal output to xterm when subscriptions are established after websocket messages", async () => {
|
||
// This test verifies that the useTerminal hook's early message buffering
|
||
// works correctly with TerminalModal's late-subscription pattern (xterm
|
||
// must initialize before onData/onScrollback/onConnect are wired up).
|
||
// The hook's buffer ensures scrollback and early shell output are not lost.
|
||
|
||
let capturedDataCallback: ((data: string) => void) | null = null;
|
||
let capturedScrollbackCallback: ((data: string) => void) | null = null;
|
||
|
||
const mockOnData = vi.fn((cb: (data: string) => void) => {
|
||
capturedDataCallback = cb;
|
||
return vi.fn();
|
||
});
|
||
const mockOnScrollback = vi.fn((cb: (data: string) => void) => {
|
||
capturedScrollbackCallback = cb;
|
||
return vi.fn();
|
||
});
|
||
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({
|
||
connectionStatus: "connected",
|
||
onData: mockOnData,
|
||
onScrollback: mockOnScrollback,
|
||
})
|
||
);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
// Wait for xterm to initialize and subscriptions to be established
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(mockOnData).toHaveBeenCalled();
|
||
expect(mockOnScrollback).toHaveBeenCalled();
|
||
});
|
||
|
||
// Now simulate late-arriving data (after subscriptions are wired)
|
||
// This verifies the write path from callback to xterm
|
||
act(() => {
|
||
if (capturedDataCallback) {
|
||
capturedDataCallback("prompt$ ");
|
||
}
|
||
if (capturedScrollbackCallback) {
|
||
capturedScrollbackCallback("previous output");
|
||
}
|
||
});
|
||
|
||
// xterm should receive the data via write()
|
||
expect(mockTerminalInstance.write).toHaveBeenCalledWith("prompt$ ");
|
||
expect(mockTerminalInstance.write).toHaveBeenCalledWith("previous output");
|
||
});
|
||
|
||
/**
|
||
* Regression: terminal shows "Connected" and cursor but no visible prompt.
|
||
*
|
||
* The original bug occurred when PTY output containing the initial shell
|
||
* prompt was emitted during the resize-suppression window (150ms after the
|
||
* initial fitAddon.fit()). That output was silently discarded, so xterm
|
||
* rendered a connected cursor over an empty terminal — the prompt was
|
||
* permanently lost for that session.
|
||
*
|
||
* This test verifies the buffering layer ensures the prompt arrives at
|
||
* xterm even when subscribers register after the WebSocket has already
|
||
* received the scrollback and data messages.
|
||
*/
|
||
it("displays the shell prompt even when scrollback and data arrive before xterm subscription", async () => {
|
||
let capturedDataCallback: ((data: string) => void) | null = null;
|
||
let capturedScrollbackCallback: ((data: string) => void) | null = null;
|
||
|
||
const mockOnData = vi.fn((cb: (data: string) => void) => {
|
||
capturedDataCallback = cb;
|
||
return vi.fn();
|
||
});
|
||
const mockOnScrollback = vi.fn((cb: (data: string) => void) => {
|
||
capturedScrollbackCallback = cb;
|
||
return vi.fn();
|
||
});
|
||
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({
|
||
connectionStatus: "connected",
|
||
onData: mockOnData,
|
||
onScrollback: mockOnScrollback,
|
||
})
|
||
);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
// Wait for xterm to initialize
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(mockOnData).toHaveBeenCalled();
|
||
expect(mockOnScrollback).toHaveBeenCalled();
|
||
});
|
||
|
||
// Simulate the prompt arriving: scrollback contains the initial prompt,
|
||
// and data contains subsequent output (echo of first keystroke)
|
||
act(() => {
|
||
if (capturedScrollbackCallback) {
|
||
capturedScrollbackCallback("user@host:~$ ");
|
||
}
|
||
if (capturedDataCallback) {
|
||
capturedDataCallback("ls\r\n");
|
||
}
|
||
});
|
||
|
||
// xterm must receive BOTH the prompt and the data — neither should be lost
|
||
expect(mockTerminalInstance.write).toHaveBeenCalledWith("user@host:~$ ");
|
||
expect(mockTerminalInstance.write).toHaveBeenCalledWith("ls\r\n");
|
||
});
|
||
});
|
||
|
||
// --- New-tab regression tests ---
|
||
describe("TerminalModal — new tab while modal open", () => {
|
||
const mockOnClose = vi.fn();
|
||
const mockSendInput = vi.fn();
|
||
const mockResize = vi.fn();
|
||
const mockReconnect = vi.fn();
|
||
|
||
const createMockTerminalState = (overrides = {}) => ({
|
||
connectionStatus: "disconnected" as const,
|
||
sendInput: mockSendInput,
|
||
resize: mockResize,
|
||
onData: vi.fn(() => vi.fn()),
|
||
onExit: vi.fn(() => vi.fn()),
|
||
onConnect: vi.fn(() => vi.fn()),
|
||
onScrollback: vi.fn(() => vi.fn()),
|
||
reconnect: mockReconnect,
|
||
onSessionInvalid: vi.fn(() => vi.fn()),
|
||
...overrides,
|
||
});
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
mockCreateTerminalSession.mockResolvedValue({
|
||
sessionId: "test-session-123",
|
||
shell: "/bin/bash",
|
||
cwd: "/project",
|
||
});
|
||
mockKillPtyTerminalSession.mockResolvedValue({ killed: true });
|
||
mockUseTerminal.mockReturnValue(createMockTerminalState());
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
});
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
/**
|
||
* Regression: creating a new tab while the modal is open must initialize
|
||
* xterm for the new session immediately — no close/reopen required.
|
||
*/
|
||
it("initializes xterm for the new tab without closing and reopening the modal", async () => {
|
||
// Start with one tab
|
||
const firstTab = {
|
||
id: "tab-1",
|
||
sessionId: "session-1",
|
||
title: "Terminal 1",
|
||
isActive: true,
|
||
createdAt: Date.now(),
|
||
};
|
||
|
||
const { rerender } = renderWithTabs([firstTab], firstTab);
|
||
|
||
// Wait for initial xterm to be created
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
// Now simulate creating a new tab — the sessions hook updates state
|
||
const newTab = {
|
||
id: "tab-2",
|
||
sessionId: "session-2",
|
||
title: "Terminal 2",
|
||
isActive: true,
|
||
createdAt: Date.now(),
|
||
};
|
||
const deactivatedFirstTab = { ...firstTab, isActive: false };
|
||
|
||
// Update the mock to return the new tab state
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [deactivatedFirstTab, newTab],
|
||
activeTab: newTab,
|
||
});
|
||
|
||
// The useTerminal hook is called with the new sessionId
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({ connectionStatus: "connected" })
|
||
);
|
||
|
||
// Re-render to pick up the new tab
|
||
rerender(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} />
|
||
);
|
||
|
||
// xterm should be reinitialized for the new session
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
// Loading state should clear — no loading overlay present
|
||
await waitFor(() => {
|
||
expect(screen.queryByTestId("terminal-loading")).toBeNull();
|
||
});
|
||
});
|
||
|
||
/**
|
||
* Regression: output from the new tab's session must be delivered to xterm
|
||
* via write(), not silently dropped.
|
||
*/
|
||
it("delivers output from new tab session to xterm write()", async () => {
|
||
let capturedDataCallback: ((data: string) => void) | null = null;
|
||
let capturedScrollbackCallback: ((data: string) => void) | null = null;
|
||
|
||
const mockOnData = vi.fn((cb: (data: string) => void) => {
|
||
capturedDataCallback = cb;
|
||
return vi.fn();
|
||
});
|
||
const mockOnScrollback = vi.fn((cb: (data: string) => void) => {
|
||
capturedScrollbackCallback = cb;
|
||
return vi.fn();
|
||
});
|
||
|
||
const newTab = {
|
||
id: "tab-2",
|
||
sessionId: "session-2",
|
||
title: "Terminal 2",
|
||
isActive: true,
|
||
createdAt: Date.now(),
|
||
};
|
||
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [
|
||
{ id: "tab-1", sessionId: "session-1", title: "Terminal 1", isActive: false, createdAt: Date.now() },
|
||
newTab,
|
||
],
|
||
activeTab: newTab,
|
||
});
|
||
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({
|
||
connectionStatus: "connected",
|
||
onData: mockOnData,
|
||
onScrollback: mockOnScrollback,
|
||
})
|
||
);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
// Wait for xterm to initialize and subscriptions to be established
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(mockOnData).toHaveBeenCalled();
|
||
expect(mockOnScrollback).toHaveBeenCalled();
|
||
});
|
||
|
||
// Clear any previous write calls from buffered replay
|
||
mockTerminalInstance.write.mockClear();
|
||
|
||
// Simulate output arriving for the new tab's session
|
||
act(() => {
|
||
if (capturedScrollbackCallback) {
|
||
capturedScrollbackCallback("user@host:~$ ");
|
||
}
|
||
if (capturedDataCallback) {
|
||
capturedDataCallback("ls\r\n");
|
||
}
|
||
});
|
||
|
||
// xterm must receive the output via write()
|
||
expect(mockTerminalInstance.write).toHaveBeenCalledWith("user@host:~$ ");
|
||
expect(mockTerminalInstance.write).toHaveBeenCalledWith("ls\r\n");
|
||
});
|
||
|
||
/**
|
||
* Regression: subscriptions must be established for the new session,
|
||
* not stuck on the prior tab's session. When the active session changes,
|
||
* the subscription effect must re-run with the new sessionId.
|
||
*/
|
||
it("establishes subscriptions for the new session after tab creation", async () => {
|
||
const mockOnData1 = vi.fn(() => vi.fn());
|
||
const mockOnScrollback1 = vi.fn(() => vi.fn());
|
||
const mockOnConnect1 = vi.fn(() => vi.fn());
|
||
const mockOnExit1 = vi.fn(() => vi.fn());
|
||
|
||
// First tab's terminal state
|
||
const firstTabState = createMockTerminalState({
|
||
connectionStatus: "connected",
|
||
onData: mockOnData1,
|
||
onScrollback: mockOnScrollback1,
|
||
onConnect: mockOnConnect1,
|
||
onExit: mockOnExit1,
|
||
});
|
||
|
||
const firstTab = {
|
||
id: "tab-1",
|
||
sessionId: "session-1",
|
||
title: "Terminal 1",
|
||
isActive: true,
|
||
createdAt: Date.now(),
|
||
};
|
||
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [firstTab],
|
||
activeTab: firstTab,
|
||
});
|
||
mockUseTerminal.mockReturnValue(firstTabState);
|
||
|
||
const { rerender } = render(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} />
|
||
);
|
||
|
||
// Wait for initial subscriptions to be established for session-1
|
||
await waitFor(() => {
|
||
expect(mockOnData1).toHaveBeenCalled();
|
||
expect(mockOnScrollback1).toHaveBeenCalled();
|
||
});
|
||
|
||
// Now create a new tab — new session
|
||
const mockOnData2 = vi.fn(() => vi.fn());
|
||
const mockOnScrollback2 = vi.fn(() => vi.fn());
|
||
const mockOnConnect2 = vi.fn(() => vi.fn());
|
||
const mockOnExit2 = vi.fn(() => vi.fn());
|
||
|
||
const secondTabState = createMockTerminalState({
|
||
connectionStatus: "connected",
|
||
onData: mockOnData2,
|
||
onScrollback: mockOnScrollback2,
|
||
onConnect: mockOnConnect2,
|
||
onExit: mockOnExit2,
|
||
});
|
||
|
||
const newTab = {
|
||
id: "tab-2",
|
||
sessionId: "session-2",
|
||
title: "Terminal 2",
|
||
isActive: true,
|
||
createdAt: Date.now(),
|
||
};
|
||
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [{ ...firstTab, isActive: false }, newTab],
|
||
activeTab: newTab,
|
||
});
|
||
mockUseTerminal.mockReturnValue(secondTabState);
|
||
|
||
rerender(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} />
|
||
);
|
||
|
||
// Subscriptions should be re-established for session-2
|
||
await waitFor(() => {
|
||
expect(mockOnData2).toHaveBeenCalled();
|
||
expect(mockOnScrollback2).toHaveBeenCalled();
|
||
expect(mockOnConnect2).toHaveBeenCalled();
|
||
expect(mockOnExit2).toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
/**
|
||
* Regression: the xterm container must not have display:none when switching
|
||
* tabs, so that terminal.open() can always measure container dimensions.
|
||
*/
|
||
it("xterm container has no display:none during tab switch re-initialization", async () => {
|
||
const firstTab = {
|
||
id: "tab-1",
|
||
sessionId: "session-1",
|
||
title: "Terminal 1",
|
||
isActive: true,
|
||
createdAt: Date.now(),
|
||
};
|
||
|
||
const { rerender } = renderWithTabs([firstTab], firstTab);
|
||
|
||
// Wait for initial xterm
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||
});
|
||
|
||
// Switch to new tab
|
||
const newTab = {
|
||
id: "tab-2",
|
||
sessionId: "session-2",
|
||
title: "Terminal 2",
|
||
isActive: true,
|
||
createdAt: Date.now(),
|
||
};
|
||
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [{ ...firstTab, isActive: false }, newTab],
|
||
activeTab: newTab,
|
||
});
|
||
|
||
rerender(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} />
|
||
);
|
||
|
||
// The xterm container should never have display:none
|
||
await waitFor(() => {
|
||
const xtermDiv = screen.getByTestId("terminal-xterm");
|
||
expect(xtermDiv.style.display).not.toBe("none");
|
||
});
|
||
});
|
||
|
||
// Helper to render with specific tabs
|
||
function renderWithTabs(tabs: typeof defaultTab[], activeTab: typeof defaultTab) {
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs,
|
||
activeTab,
|
||
});
|
||
mockUseTerminal.mockReturnValue(createMockTerminalState());
|
||
|
||
return render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
}
|
||
});
|
||
|
||
// --- FN-1234 mobile tab + keyboard regression tests ---
|
||
describe("TerminalModal — FN-1234 mobile tab switch with keyboard", () => {
|
||
const mockOnClose = vi.fn();
|
||
|
||
const createMockTerminalState = (overrides = {}) => ({
|
||
connectionStatus: "connected" as const,
|
||
sendInput: vi.fn(),
|
||
resize: vi.fn(),
|
||
onData: vi.fn(() => vi.fn()),
|
||
onExit: vi.fn(() => vi.fn()),
|
||
onConnect: vi.fn(() => vi.fn()),
|
||
onScrollback: vi.fn(() => vi.fn()),
|
||
reconnect: vi.fn(),
|
||
onSessionInvalid: vi.fn(() => vi.fn()),
|
||
...overrides,
|
||
});
|
||
|
||
const makeTab = (id: string, sessionId: string, isActive: boolean, title = id) => ({
|
||
id,
|
||
sessionId,
|
||
title,
|
||
isActive,
|
||
createdAt: Date.now(),
|
||
});
|
||
|
||
const makeSessionState = (tabs: Array<ReturnType<typeof makeTab>>) => ({
|
||
tabs,
|
||
activeTab: tabs.find((tab) => tab.isActive) ?? null,
|
||
isReady: true,
|
||
bootstrapError: null,
|
||
createTab: vi.fn(),
|
||
closeTab: vi.fn(),
|
||
setActiveTab: vi.fn(),
|
||
updateTabTitle: vi.fn(),
|
||
restartActiveTab: vi.fn(),
|
||
retryBootstrap: vi.fn(),
|
||
replaceActiveTabSession: vi.fn().mockResolvedValue(undefined),
|
||
});
|
||
|
||
const createTerminalInstance = (cols: number, rows: number) => ({
|
||
loadAddon: vi.fn(),
|
||
open: vi.fn(),
|
||
onData: vi.fn((_cb: (data: string) => void) => ({ dispose: vi.fn() })),
|
||
attachCustomKeyEventHandler: vi.fn(),
|
||
hasSelection: vi.fn(() => false),
|
||
getSelection: vi.fn(() => ""),
|
||
paste: vi.fn(),
|
||
dispose: vi.fn(),
|
||
write: vi.fn(),
|
||
clear: vi.fn(),
|
||
focus: vi.fn(),
|
||
options: { fontSize: 14 },
|
||
cols,
|
||
rows,
|
||
});
|
||
|
||
let savedVisualViewport: typeof window.visualViewport;
|
||
let savedInnerWidth: typeof window.innerWidth;
|
||
let savedInnerHeight: typeof window.innerHeight;
|
||
let savedOntouchstart: typeof window.ontouchstart;
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
_resetInitialViewportHeight();
|
||
|
||
savedVisualViewport = window.visualViewport;
|
||
savedInnerWidth = window.innerWidth;
|
||
savedInnerHeight = window.innerHeight;
|
||
savedOntouchstart = window.ontouchstart;
|
||
});
|
||
|
||
afterEach(() => {
|
||
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,
|
||
});
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
function simulateMobileDevice(initialVvHeight = 667) {
|
||
(window as any).ontouchstart = null;
|
||
Object.defineProperty(window, "innerWidth", {
|
||
value: 375,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(window, "innerHeight", {
|
||
value: 667,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
|
||
const listeners: Record<string, Array<() => void>> = {
|
||
resize: [],
|
||
scroll: [],
|
||
};
|
||
|
||
const mockVV = {
|
||
width: 375,
|
||
height: initialVvHeight,
|
||
offsetTop: 0,
|
||
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("tab switch + keyboard open keeps data on the active session terminal", async () => {
|
||
const { listeners, mockVV } = simulateMobileDevice();
|
||
const tab1 = makeTab("tab-1", "session-1", true, "one");
|
||
const tab2 = makeTab("tab-2", "session-2", false, "two");
|
||
|
||
const terminalOne = createTerminalInstance(80, 24);
|
||
const terminalTwo = createTerminalInstance(120, 40);
|
||
|
||
const xtermModule = await import("@xterm/xterm");
|
||
vi.mocked(xtermModule.Terminal)
|
||
.mockImplementationOnce(function TerminalOneMock() {
|
||
return terminalOne as any;
|
||
} as never)
|
||
.mockImplementationOnce(function TerminalTwoMock() {
|
||
return terminalTwo as any;
|
||
} as never);
|
||
|
||
let sessionOneDataCallback: ((data: string) => void) | null = null;
|
||
let sessionTwoDataCallback: ((data: string) => void) | null = null;
|
||
|
||
mockUseTerminalSessions.mockReturnValue(makeSessionState([tab1, tab2]));
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({
|
||
onData: vi.fn((cb: (data: string) => void) => {
|
||
sessionOneDataCallback = cb;
|
||
return vi.fn();
|
||
}),
|
||
})
|
||
);
|
||
|
||
const { rerender } = render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(terminalOne.open).toHaveBeenCalled();
|
||
});
|
||
|
||
mockUseTerminalSessions.mockReturnValue(
|
||
makeSessionState([{ ...tab1, isActive: false }, { ...tab2, isActive: true }])
|
||
);
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({
|
||
onData: vi.fn((cb: (data: string) => void) => {
|
||
sessionTwoDataCallback = cb;
|
||
return vi.fn();
|
||
}),
|
||
})
|
||
);
|
||
|
||
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(terminalTwo.open).toHaveBeenCalled();
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(sessionTwoDataCallback).not.toBeNull();
|
||
});
|
||
|
||
Object.defineProperty(mockVV, "height", {
|
||
value: 417,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
act(() => {
|
||
listeners.resize.forEach((cb) => cb());
|
||
});
|
||
|
||
terminalOne.write.mockClear();
|
||
terminalTwo.write.mockClear();
|
||
|
||
act(() => {
|
||
sessionOneDataCallback?.("session-1 stale output\\r\\n");
|
||
sessionTwoDataCallback?.("session-2 fresh output\\r\\n");
|
||
});
|
||
|
||
expect(terminalTwo.write).toHaveBeenCalledWith("session-2 fresh output\\r\\n");
|
||
expect(terminalTwo.write).not.toHaveBeenCalledWith("session-1 stale output\\r\\n");
|
||
expect(terminalOne.write).not.toHaveBeenCalledWith("session-1 stale output\\r\\n");
|
||
});
|
||
|
||
it("re-fits and resizes the switched tab terminal when keyboard opens", async () => {
|
||
const { listeners, mockVV } = simulateMobileDevice();
|
||
const tab1 = makeTab("tab-1", "session-1", true, "one");
|
||
const tab2 = makeTab("tab-2", "session-2", false, "two");
|
||
|
||
const terminalOne = createTerminalInstance(90, 30);
|
||
const terminalTwo = createTerminalInstance(132, 44);
|
||
const fitOne = { fit: vi.fn(), dispose: vi.fn() };
|
||
const fitTwo = { fit: vi.fn(), dispose: vi.fn() };
|
||
const resizeOne = vi.fn();
|
||
const resizeTwo = vi.fn();
|
||
|
||
const xtermModule = await import("@xterm/xterm");
|
||
vi.mocked(xtermModule.Terminal)
|
||
.mockImplementationOnce(function TerminalOneMock() {
|
||
return terminalOne as any;
|
||
} as never)
|
||
.mockImplementationOnce(function TerminalTwoMock() {
|
||
return terminalTwo as any;
|
||
} as never);
|
||
|
||
const fitModule = await import("@xterm/addon-fit");
|
||
vi.mocked(fitModule.FitAddon)
|
||
.mockImplementationOnce(function FitOneMock() {
|
||
return fitOne as any;
|
||
} as never)
|
||
.mockImplementationOnce(function FitTwoMock() {
|
||
return fitTwo as any;
|
||
} as never);
|
||
|
||
mockUseTerminalSessions.mockReturnValue(makeSessionState([tab1, tab2]));
|
||
mockUseTerminal.mockReturnValue(createMockTerminalState({ resize: resizeOne }));
|
||
|
||
const { rerender } = render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(terminalOne.open).toHaveBeenCalled();
|
||
});
|
||
|
||
mockUseTerminalSessions.mockReturnValue(
|
||
makeSessionState([{ ...tab1, isActive: false }, { ...tab2, isActive: true }])
|
||
);
|
||
mockUseTerminal.mockReturnValue(createMockTerminalState({ resize: resizeTwo }));
|
||
|
||
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(terminalTwo.open).toHaveBeenCalled();
|
||
});
|
||
|
||
fitTwo.fit.mockClear();
|
||
resizeOne.mockClear();
|
||
resizeTwo.mockClear();
|
||
|
||
Object.defineProperty(mockVV, "height", {
|
||
value: 350,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
act(() => {
|
||
listeners.resize.forEach((cb) => cb());
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(fitTwo.fit).toHaveBeenCalled();
|
||
});
|
||
expect(resizeTwo).toHaveBeenCalledWith(132, 44);
|
||
expect(resizeOne).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("applies keyboard overlap CSS vars on the switched tab after keyboard opens", async () => {
|
||
const { listeners, mockVV } = simulateMobileDevice();
|
||
const tab1 = makeTab("tab-1", "session-1", true, "one");
|
||
const tab2 = makeTab("tab-2", "session-2", false, "two");
|
||
|
||
mockUseTerminalSessions.mockReturnValue(makeSessionState([tab1, tab2]));
|
||
mockUseTerminal.mockReturnValue(createMockTerminalState());
|
||
|
||
const { rerender } = render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
mockUseTerminalSessions.mockReturnValue(
|
||
makeSessionState([{ ...tab1, isActive: false }, { ...tab2, isActive: true }])
|
||
);
|
||
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
Object.defineProperty(mockVV, "height", {
|
||
value: 430,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
act(() => {
|
||
listeners.resize.forEach((cb) => cb());
|
||
});
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("237px");
|
||
expect(modal.style.getPropertyValue("--vv-height")).toBe("430px");
|
||
});
|
||
});
|
||
|
||
it("replays scrollback to the active session after tab switch + keyboard open", async () => {
|
||
const { listeners, mockVV } = simulateMobileDevice();
|
||
const tab1 = makeTab("tab-1", "session-1", true, "one");
|
||
const tab2 = makeTab("tab-2", "session-2", false, "two");
|
||
|
||
const terminalOne = createTerminalInstance(80, 24);
|
||
const terminalTwo = createTerminalInstance(100, 32);
|
||
|
||
const xtermModule = await import("@xterm/xterm");
|
||
vi.mocked(xtermModule.Terminal)
|
||
.mockImplementationOnce(function TerminalOneMock() {
|
||
return terminalOne as any;
|
||
} as never)
|
||
.mockImplementationOnce(function TerminalTwoMock() {
|
||
return terminalTwo as any;
|
||
} as never);
|
||
|
||
let sessionOneScrollbackCallback: ((data: string) => void) | null = null;
|
||
let sessionTwoScrollbackCallback: ((data: string) => void) | null = null;
|
||
|
||
mockUseTerminalSessions.mockReturnValue(makeSessionState([tab1, tab2]));
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({
|
||
onScrollback: vi.fn((cb: (data: string) => void) => {
|
||
sessionOneScrollbackCallback = cb;
|
||
return vi.fn();
|
||
}),
|
||
})
|
||
);
|
||
|
||
const { rerender } = render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(terminalOne.open).toHaveBeenCalled();
|
||
});
|
||
|
||
mockUseTerminalSessions.mockReturnValue(
|
||
makeSessionState([{ ...tab1, isActive: false }, { ...tab2, isActive: true }])
|
||
);
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({
|
||
onScrollback: vi.fn((cb: (data: string) => void) => {
|
||
sessionTwoScrollbackCallback = cb;
|
||
return vi.fn();
|
||
}),
|
||
})
|
||
);
|
||
|
||
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(terminalTwo.open).toHaveBeenCalled();
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(sessionTwoScrollbackCallback).not.toBeNull();
|
||
});
|
||
|
||
Object.defineProperty(mockVV, "height", {
|
||
value: 390,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
act(() => {
|
||
listeners.resize.forEach((cb) => cb());
|
||
});
|
||
|
||
terminalOne.write.mockClear();
|
||
terminalTwo.write.mockClear();
|
||
|
||
act(() => {
|
||
sessionOneScrollbackCallback?.("session-1 scrollback\\r\\n");
|
||
sessionTwoScrollbackCallback?.("session-2 scrollback\\r\\n");
|
||
});
|
||
|
||
expect(terminalTwo.write).toHaveBeenCalledWith("session-2 scrollback\\r\\n");
|
||
expect(terminalTwo.write).not.toHaveBeenCalledWith("session-1 scrollback\\r\\n");
|
||
expect(terminalOne.write).not.toHaveBeenCalledWith("session-1 scrollback\\r\\n");
|
||
});
|
||
});
|
||
|
||
// --- Virtual keyboard overlap handling ---
|
||
describe("TerminalModal — virtual keyboard overlap handling", () => {
|
||
const mockOnClose = vi.fn();
|
||
const mockSendInput = vi.fn();
|
||
const mockResize = vi.fn();
|
||
const mockReconnect = vi.fn();
|
||
|
||
const createMockTerminalState = (overrides = {}) => ({
|
||
connectionStatus: "disconnected" as const,
|
||
sendInput: mockSendInput,
|
||
resize: mockResize,
|
||
onData: vi.fn(() => vi.fn()),
|
||
onExit: vi.fn(() => vi.fn()),
|
||
onConnect: vi.fn(() => vi.fn()),
|
||
onScrollback: vi.fn(() => vi.fn()),
|
||
reconnect: mockReconnect,
|
||
onSessionInvalid: vi.fn(() => vi.fn()),
|
||
...overrides,
|
||
});
|
||
|
||
const defaultTab = {
|
||
id: "tab-1",
|
||
sessionId: "test-session-123",
|
||
title: "bash",
|
||
isActive: true,
|
||
createdAt: Date.now(),
|
||
};
|
||
|
||
const defaultSessionState = {
|
||
tabs: [defaultTab],
|
||
activeTab: defaultTab,
|
||
isReady: true,
|
||
bootstrapError: null,
|
||
createTab: vi.fn(),
|
||
closeTab: vi.fn(),
|
||
setActiveTab: vi.fn(),
|
||
updateTabTitle: vi.fn(),
|
||
restartActiveTab: vi.fn(),
|
||
retryBootstrap: vi.fn(),
|
||
replaceActiveTabSession: vi.fn().mockResolvedValue(undefined),
|
||
};
|
||
|
||
let savedVisualViewport: typeof window.visualViewport;
|
||
let savedInnerWidth: typeof window.innerWidth;
|
||
let savedOntouchstart: typeof window.ontouchstart;
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
_resetInitialViewportHeight();
|
||
mockUseTerminal.mockReturnValue(createMockTerminalState());
|
||
mockUseTerminalSessions.mockReturnValue(defaultSessionState);
|
||
|
||
// Stash originals
|
||
savedVisualViewport = window.visualViewport;
|
||
savedInnerWidth = window.innerWidth;
|
||
savedOntouchstart = window.ontouchstart;
|
||
});
|
||
|
||
afterEach(() => {
|
||
// Restore originals
|
||
Object.defineProperty(window, "visualViewport", {
|
||
value: savedVisualViewport,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(window, "innerWidth", {
|
||
value: savedInnerWidth,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(window, "ontouchstart", {
|
||
value: savedOntouchstart,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
/**
|
||
* Helper: simulate a mobile device with a visualViewport.
|
||
* The resize/scroll callbacks are captured so tests can fire them.
|
||
*/
|
||
function simulateMobileDevice(overlapPx: number) {
|
||
// Touch device
|
||
(window as any).ontouchstart = null; // truthy — "ontouchstart" in window → true
|
||
|
||
// Narrow viewport
|
||
Object.defineProperty(window, "innerWidth", {
|
||
value: 375,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
|
||
// visualViewport mock
|
||
const listeners: Record<string, Array<() => void>> = {
|
||
resize: [],
|
||
scroll: [],
|
||
};
|
||
|
||
const vvHeight = 300; // viewport shrunk by keyboard
|
||
const vvOffsetTop = overlapPx > 0 ? 0 : 0; // typically 0 on modern mobile
|
||
|
||
const mockVV = {
|
||
width: 375,
|
||
height: vvHeight,
|
||
offsetTop: vvOffsetTop,
|
||
offsetLeft: 0,
|
||
addEventListener: vi.fn((event: string, cb: () => void) => {
|
||
if (listeners[event]) listeners[event].push(cb);
|
||
}),
|
||
removeEventListener: vi.fn(),
|
||
};
|
||
|
||
Object.defineProperty(window, "visualViewport", {
|
||
value: mockVV,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
|
||
// Override innerHeight to simulate keyboard overlap
|
||
// keyboardOverlap = window.innerHeight - vv.offsetTop - vv.height
|
||
// For overlapPx > 0: window.innerHeight = vv.offsetTop + vv.height + overlapPx
|
||
Object.defineProperty(window, "innerHeight", {
|
||
value: vvOffsetTop + vvHeight + overlapPx,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
|
||
return { listeners, mockVV };
|
||
}
|
||
|
||
it("does not apply --keyboard-overlap when not on a mobile device", async () => {
|
||
// Desktop: no touch, wide viewport
|
||
delete (window as any).ontouchstart;
|
||
Object.defineProperty(window, "innerWidth", {
|
||
value: 1440,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
// No --keyboard-overlap should be set (style should be undefined/empty)
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||
});
|
||
});
|
||
|
||
it("applies --keyboard-overlap CSS variable when virtual keyboard is open on mobile", async () => {
|
||
const { listeners } = simulateMobileDevice(250); // 250px keyboard overlap
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
const overlap = modal.style.getPropertyValue("--keyboard-overlap");
|
||
expect(overlap).toBe("250px");
|
||
});
|
||
});
|
||
|
||
it("updates --keyboard-overlap when keyboard height changes", async () => {
|
||
const { listeners } = simulateMobileDevice(250);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("250px");
|
||
});
|
||
|
||
// Simulate keyboard shrinking (user swiped down partially)
|
||
Object.defineProperty(window, "innerHeight", {
|
||
value: 300 + 0 + 100, // keyboardOverlap becomes 100
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
|
||
act(() => {
|
||
for (const cb of listeners.resize) cb();
|
||
});
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("100px");
|
||
});
|
||
});
|
||
|
||
it("removes --keyboard-overlap when keyboard closes", async () => {
|
||
const { listeners, mockVV } = simulateMobileDevice(250);
|
||
|
||
const { rerender } = render(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} />,
|
||
);
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("250px");
|
||
});
|
||
|
||
// Keyboard closes → visualViewport.height returns to full height (550 = innerHeight)
|
||
Object.defineProperty(mockVV, "height", {
|
||
value: 550,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
|
||
act(() => {
|
||
for (const cb of listeners.resize) cb();
|
||
});
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
// When overlap is 0, the style prop should be undefined (no CSS variable set)
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||
});
|
||
});
|
||
|
||
it("clears overlap when modal closes", async () => {
|
||
const { listeners } = simulateMobileDevice(250);
|
||
|
||
const { rerender } = render(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} />,
|
||
);
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("250px");
|
||
});
|
||
|
||
// Close the modal
|
||
act(() => {
|
||
rerender(<TerminalModal isOpen={false} onClose={mockOnClose} />);
|
||
});
|
||
|
||
// Modal is no longer rendered
|
||
expect(screen.queryByTestId("terminal-modal")).toBeNull();
|
||
});
|
||
|
||
it("falls back gracefully when visualViewport is unavailable", async () => {
|
||
// Mobile device but no visualViewport API (older browser)
|
||
(window as any).ontouchstart = null;
|
||
Object.defineProperty(window, "innerWidth", {
|
||
value: 375,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(window, "visualViewport", {
|
||
value: undefined,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
// No keyboard overlap applied since visualViewport is unavailable
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||
});
|
||
});
|
||
|
||
it("registers and cleans up visualViewport listeners on mobile", async () => {
|
||
const { mockVV } = simulateMobileDevice(250);
|
||
|
||
const { unmount } = render(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} />,
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(mockVV.addEventListener).toHaveBeenCalledWith("resize", expect.any(Function));
|
||
expect(mockVV.addEventListener).toHaveBeenCalledWith("scroll", expect.any(Function));
|
||
});
|
||
|
||
const resizeCalls = mockVV.addEventListener.mock.calls.filter(
|
||
(c: any[]) => c[0] === "resize",
|
||
);
|
||
const scrollCalls = mockVV.addEventListener.mock.calls.filter(
|
||
(c: any[]) => c[0] === "scroll",
|
||
);
|
||
|
||
unmount();
|
||
|
||
// Cleanup should remove both listeners
|
||
expect(mockVV.removeEventListener).toHaveBeenCalledWith("resize", resizeCalls[0][1]);
|
||
expect(mockVV.removeEventListener).toHaveBeenCalledWith("scroll", scrollCalls[0][1]);
|
||
});
|
||
|
||
it("zero overlap on mobile with no keyboard does not set CSS variable", async () => {
|
||
simulateMobileDevice(0); // no keyboard
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||
});
|
||
});
|
||
|
||
it("scrolls modal into view when keyboard opens on mobile", async () => {
|
||
const scrollIntoViewSpy = vi.fn();
|
||
const { listeners } = simulateMobileDevice(250);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("250px");
|
||
});
|
||
|
||
// Attach the spy to the rendered modal element
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
modal.scrollIntoView = scrollIntoViewSpy;
|
||
|
||
// Trigger a resize event to re-run the update callback
|
||
act(() => {
|
||
for (const cb of listeners.resize) cb();
|
||
});
|
||
|
||
expect(scrollIntoViewSpy).toHaveBeenCalledWith({ block: "end", behavior: "smooth" });
|
||
});
|
||
|
||
it("does not scroll modal when keyboard overlap is zero", async () => {
|
||
const scrollIntoViewSpy = vi.fn();
|
||
const { listeners } = simulateMobileDevice(0); // no overlap
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||
});
|
||
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
modal.scrollIntoView = scrollIntoViewSpy;
|
||
|
||
// Trigger a resize event
|
||
act(() => {
|
||
for (const cb of listeners.resize) cb();
|
||
});
|
||
|
||
expect(scrollIntoViewSpy).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("sets --overlay-padding-top on overlay when keyboard overlap is detected", async () => {
|
||
simulateMobileDevice(250);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const overlay = screen.getByTestId("terminal-modal-overlay");
|
||
expect(overlay.style.getPropertyValue("--overlay-padding-top")).toBe("0px");
|
||
});
|
||
});
|
||
|
||
it("clears --overlay-padding-top from overlay when keyboard closes", async () => {
|
||
const { listeners, mockVV } = simulateMobileDevice(250);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const overlay = screen.getByTestId("terminal-modal-overlay");
|
||
expect(overlay.style.getPropertyValue("--overlay-padding-top")).toBe("0px");
|
||
});
|
||
|
||
// Keyboard closes → visualViewport.height returns to full height (550 = innerHeight)
|
||
Object.defineProperty(mockVV, "height", {
|
||
value: 550,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
|
||
act(() => {
|
||
for (const cb of listeners.resize) cb();
|
||
});
|
||
|
||
await waitFor(() => {
|
||
const overlay = screen.getByTestId("terminal-modal-overlay");
|
||
expect(overlay.style.getPropertyValue("--overlay-padding-top")).toBe("");
|
||
});
|
||
});
|
||
|
||
describe("xterm re-fit on keyboard open (FN-1043 regression)", () => {
|
||
/** Pending rAF callbacks keyed by fake id. */
|
||
let rafMap: Map<number, () => void>;
|
||
let nextRafId: number;
|
||
let originalRAF: typeof window.requestAnimationFrame;
|
||
let originalCAF: typeof window.cancelAnimationFrame;
|
||
|
||
beforeEach(() => {
|
||
rafMap = new Map();
|
||
nextRafId = 1;
|
||
originalRAF = window.requestAnimationFrame;
|
||
originalCAF = window.cancelAnimationFrame;
|
||
// Capture rAF callbacks with proper cancellation support so the
|
||
// coalescing logic (cancel → schedule) works correctly in tests.
|
||
window.requestAnimationFrame = ((cb: () => void) => {
|
||
const id = nextRafId++;
|
||
rafMap.set(id, cb);
|
||
return id;
|
||
}) as any;
|
||
window.cancelAnimationFrame = ((id: number) => {
|
||
rafMap.delete(id);
|
||
}) as any;
|
||
});
|
||
|
||
afterEach(() => {
|
||
window.requestAnimationFrame = originalRAF;
|
||
window.cancelAnimationFrame = originalCAF;
|
||
});
|
||
|
||
/** Flush all pending rAF callbacks and clear the map. */
|
||
function flushRaf() {
|
||
const callbacks = Array.from(rafMap.values());
|
||
rafMap.clear();
|
||
for (const cb of callbacks) cb();
|
||
}
|
||
|
||
it("defers fitAddon.fit() via requestAnimationFrame after keyboard open", async () => {
|
||
const { listeners } = simulateMobileDevice(250);
|
||
const mockResizeFn = vi.fn();
|
||
|
||
mockUseTerminal.mockReturnValue(createMockTerminalState({
|
||
connectionStatus: "connected",
|
||
resize: mockResizeFn,
|
||
}));
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
// Wait for --keyboard-overlap to be set (initial measurement + rAF)
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("250px");
|
||
});
|
||
|
||
// Flush any pending rAF from initial mount
|
||
act(() => { flushRaf(); });
|
||
|
||
// Clear the map before triggering the resize
|
||
rafMap.clear();
|
||
|
||
// Trigger a viewport resize (keyboard opened)
|
||
act(() => {
|
||
for (const cb of listeners.resize) cb();
|
||
});
|
||
|
||
// The rAF callback should have been scheduled
|
||
expect(rafMap.size).toBeGreaterThanOrEqual(1);
|
||
|
||
// Flush the rAF — this exercises the deferred fit logic.
|
||
// In the test env, fitAddonRef.current is null (xterm is mocked
|
||
// as a plain object, not wired into refs), so fit() won't actually
|
||
// run. We verify the mechanism by confirming rAF was used.
|
||
expect(() => {
|
||
act(() => { flushRaf(); });
|
||
}).not.toThrow();
|
||
});
|
||
|
||
it("coalesces rapid visualViewport resize events into a single rAF callback", async () => {
|
||
const { listeners } = simulateMobileDevice(250);
|
||
|
||
mockUseTerminal.mockReturnValue(createMockTerminalState({
|
||
connectionStatus: "connected",
|
||
}));
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("250px");
|
||
});
|
||
|
||
// Flush any pending rAF from initial mount
|
||
act(() => { flushRaf(); });
|
||
rafMap.clear();
|
||
|
||
// Fire multiple rapid resize events (keyboard animating open).
|
||
// Each event calls cancelAnimationFrame(previous) then requestAnimationFrame(new),
|
||
// so only 1 callback should remain in the map after 3 events.
|
||
act(() => {
|
||
for (const cb of listeners.resize) cb(); // event 1 → schedule rAF #1
|
||
for (const cb of listeners.resize) cb(); // event 2 → cancel #1, schedule rAF #2
|
||
for (const cb of listeners.resize) cb(); // event 3 → cancel #2, schedule rAF #3
|
||
});
|
||
|
||
// Only 1 rAF callback should survive (the last one)
|
||
expect(rafMap.size).toBe(1);
|
||
});
|
||
|
||
it("reads xterm refs inside the rAF callback (not stale closures)", async () => {
|
||
const { listeners } = simulateMobileDevice(250);
|
||
|
||
mockUseTerminal.mockReturnValue(createMockTerminalState({
|
||
connectionStatus: "connected",
|
||
resize: vi.fn(),
|
||
}));
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("250px");
|
||
});
|
||
|
||
// Flush any pending rAF from initial mount
|
||
act(() => { flushRaf(); });
|
||
rafMap.clear();
|
||
|
||
// Trigger resize
|
||
act(() => {
|
||
for (const cb of listeners.resize) cb();
|
||
});
|
||
|
||
// Flush rAF — this should not throw even though xterm refs may be null
|
||
// in the test environment. The callback reads refs at call time, not capture time.
|
||
expect(() => {
|
||
act(() => { flushRaf(); });
|
||
}).not.toThrow();
|
||
});
|
||
});
|
||
});
|
||
|
||
// --- Close/reopen regression tests ---
|
||
describe("TerminalModal — close and reopen scrollback replay", () => {
|
||
const mockOnClose = vi.fn();
|
||
const mockSendInput = vi.fn();
|
||
const mockResize = vi.fn();
|
||
const mockReconnect = vi.fn();
|
||
|
||
const createMockTerminalState = (overrides = {}) => ({
|
||
connectionStatus: "disconnected" as const,
|
||
sendInput: mockSendInput,
|
||
resize: mockResize,
|
||
onData: vi.fn(() => vi.fn()),
|
||
onExit: vi.fn(() => vi.fn()),
|
||
onConnect: vi.fn(() => vi.fn()),
|
||
onScrollback: vi.fn(() => vi.fn()),
|
||
reconnect: mockReconnect,
|
||
onSessionInvalid: vi.fn(() => vi.fn()),
|
||
...overrides,
|
||
});
|
||
|
||
const defaultTab = {
|
||
id: "tab-1",
|
||
sessionId: "test-session-123",
|
||
title: "bash",
|
||
isActive: true,
|
||
createdAt: Date.now(),
|
||
};
|
||
|
||
const defaultSessionState = {
|
||
tabs: [defaultTab],
|
||
activeTab: defaultTab,
|
||
isReady: true,
|
||
bootstrapError: null,
|
||
createTab: vi.fn(),
|
||
closeTab: vi.fn(),
|
||
setActiveTab: vi.fn(),
|
||
updateTabTitle: vi.fn(),
|
||
restartActiveTab: vi.fn(),
|
||
retryBootstrap: vi.fn(),
|
||
replaceActiveTabSession: vi.fn().mockResolvedValue(undefined),
|
||
};
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
mockUseTerminal.mockReturnValue(createMockTerminalState());
|
||
mockUseTerminalSessions.mockReturnValue(defaultSessionState);
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
/**
|
||
* Regression: terminal is empty after closing and reopening the modal
|
||
* without a page refresh.
|
||
*
|
||
* Root cause: the xterm init effect's early-return guard checked
|
||
* !terminalRef.current, which was null after cleanup. The fix restructures
|
||
* the guard to check session continuity (xtermInitializedRef) before the
|
||
* DOM ref, allowing the effect to proceed and reinitialize xterm.
|
||
*
|
||
* This test verifies:
|
||
* 1. xterm initializes on first open
|
||
* 2. xterm is disposed on close
|
||
* 3. xterm reinitializes on reopen
|
||
* 4. scrollback data is delivered to xterm after reopen
|
||
*/
|
||
it("replays scrollback to xterm after modal close and reopen", async () => {
|
||
let capturedScrollbackCallback: ((data: string) => void) | null = null;
|
||
let capturedDataCallback: ((data: string) => void) | null = null;
|
||
|
||
const mockOnScrollback = vi.fn((cb: (data: string) => void) => {
|
||
capturedScrollbackCallback = cb;
|
||
return vi.fn();
|
||
});
|
||
const mockOnData = vi.fn((cb: (data: string) => void) => {
|
||
capturedDataCallback = cb;
|
||
return vi.fn();
|
||
});
|
||
|
||
// Phase 1: Open modal — xterm initializes and subscriptions are established
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({
|
||
connectionStatus: "connected",
|
||
onScrollback: mockOnScrollback,
|
||
onData: mockOnData,
|
||
})
|
||
);
|
||
|
||
const { rerender } = render(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} />
|
||
);
|
||
|
||
// Wait for xterm to initialize on first open
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
// Wait for subscriptions to be established
|
||
await waitFor(() => {
|
||
expect(mockOnScrollback).toHaveBeenCalled();
|
||
expect(mockOnData).toHaveBeenCalled();
|
||
});
|
||
|
||
// Verify scrollback data is delivered on first open
|
||
act(() => {
|
||
if (capturedScrollbackCallback) {
|
||
capturedScrollbackCallback("first-open-output$ ");
|
||
}
|
||
});
|
||
expect(mockTerminalInstance.write).toHaveBeenCalledWith("first-open-output$ ");
|
||
|
||
// Phase 2: Close modal — xterm is disposed
|
||
rerender(<TerminalModal isOpen={false} onClose={mockOnClose} />);
|
||
|
||
// Modal is no longer rendered
|
||
expect(screen.queryByTestId("terminal-modal")).toBeNull();
|
||
|
||
// Verify xterm was disposed
|
||
expect(mockTerminalInstance.dispose).toHaveBeenCalled();
|
||
|
||
// Phase 3: Reopen modal — xterm should reinitialize
|
||
// Reset scrollback/data callbacks for the new subscription cycle
|
||
capturedScrollbackCallback = null;
|
||
capturedDataCallback = null;
|
||
|
||
const mockOnScrollback2 = vi.fn((cb: (data: string) => void) => {
|
||
capturedScrollbackCallback = cb;
|
||
return vi.fn();
|
||
});
|
||
const mockOnData2 = vi.fn((cb: (data: string) => void) => {
|
||
capturedDataCallback = cb;
|
||
return vi.fn();
|
||
});
|
||
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({
|
||
connectionStatus: "connected",
|
||
onScrollback: mockOnScrollback2,
|
||
onData: mockOnData2,
|
||
})
|
||
);
|
||
|
||
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
// xterm should reinitialize (open called again)
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
// Subscriptions should be re-established
|
||
await waitFor(() => {
|
||
expect(mockOnScrollback2).toHaveBeenCalled();
|
||
expect(mockOnData2).toHaveBeenCalled();
|
||
});
|
||
|
||
// Clear previous write calls
|
||
mockTerminalInstance.write.mockClear();
|
||
|
||
// Phase 4: Verify scrollback is replayed after reopen
|
||
act(() => {
|
||
if (capturedScrollbackCallback) {
|
||
capturedScrollbackCallback("reopened-output$ ");
|
||
}
|
||
if (capturedDataCallback) {
|
||
capturedDataCallback("ls -la\r\n");
|
||
}
|
||
});
|
||
|
||
// xterm must receive scrollback data after reopen
|
||
expect(mockTerminalInstance.write).toHaveBeenCalledWith("reopened-output$ ");
|
||
expect(mockTerminalInstance.write).toHaveBeenCalledWith("ls -la\r\n");
|
||
});
|
||
|
||
/**
|
||
* Verify that xterm open() is called again after close/reopen with the same session.
|
||
* This confirms the init effect runs again and doesn't skip due to session continuity check.
|
||
*/
|
||
it("calls xterm.open() again after close/reopen with same session", async () => {
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({
|
||
connectionStatus: "connected",
|
||
})
|
||
);
|
||
|
||
const { rerender } = render(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} />
|
||
);
|
||
|
||
// Wait for first xterm initialization
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
// Close modal
|
||
rerender(<TerminalModal isOpen={false} onClose={mockOnClose} />);
|
||
|
||
// Reopen with same session
|
||
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
// xterm should be reinitialized
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalledTimes(2);
|
||
});
|
||
});
|
||
});
|
||
|
||
// --- FN-872: Real mobile keyboard regression tests ---
|
||
describe("TerminalModal — FN-872 real-device keyboard overlap refinement", () => {
|
||
const mockOnClose = vi.fn();
|
||
const mockSendInput = vi.fn();
|
||
const mockResize = vi.fn();
|
||
const mockReconnect = vi.fn();
|
||
|
||
const createMockTerminalState = (overrides = {}) => ({
|
||
connectionStatus: "disconnected" as const,
|
||
sendInput: mockSendInput,
|
||
resize: mockResize,
|
||
onData: vi.fn(() => vi.fn()),
|
||
onExit: vi.fn(() => vi.fn()),
|
||
onConnect: vi.fn(() => vi.fn()),
|
||
onScrollback: vi.fn(() => vi.fn()),
|
||
reconnect: mockReconnect,
|
||
onSessionInvalid: vi.fn(() => vi.fn()),
|
||
...overrides,
|
||
});
|
||
|
||
const defaultTab = {
|
||
id: "tab-1",
|
||
sessionId: "test-session-123",
|
||
title: "bash",
|
||
isActive: true,
|
||
createdAt: Date.now(),
|
||
};
|
||
|
||
const defaultSessionState = {
|
||
tabs: [defaultTab],
|
||
activeTab: defaultTab,
|
||
isReady: true,
|
||
bootstrapError: null,
|
||
createTab: vi.fn(),
|
||
closeTab: vi.fn(),
|
||
setActiveTab: vi.fn(),
|
||
updateTabTitle: vi.fn(),
|
||
restartActiveTab: vi.fn(),
|
||
retryBootstrap: vi.fn(),
|
||
replaceActiveTabSession: vi.fn().mockResolvedValue(undefined),
|
||
};
|
||
|
||
let savedVisualViewport: typeof window.visualViewport;
|
||
let savedInnerWidth: typeof window.innerWidth;
|
||
let savedInnerHeight: typeof window.innerHeight;
|
||
let savedOntouchstart: typeof window.ontouchstart;
|
||
let savedDocumentElementClientHeight: number;
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
_resetInitialViewportHeight();
|
||
mockUseTerminal.mockReturnValue(createMockTerminalState());
|
||
mockUseTerminalSessions.mockReturnValue(defaultSessionState);
|
||
mockUseWorkspaces.mockReturnValue({
|
||
projectName: "kb",
|
||
workspaces: [],
|
||
loading: false,
|
||
error: null,
|
||
});
|
||
|
||
// Stash originals
|
||
savedVisualViewport = window.visualViewport;
|
||
savedInnerWidth = window.innerWidth;
|
||
savedInnerHeight = window.innerHeight;
|
||
savedOntouchstart = window.ontouchstart;
|
||
savedDocumentElementClientHeight = document.documentElement.clientHeight;
|
||
});
|
||
|
||
afterEach(() => {
|
||
// Restore originals
|
||
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(document.documentElement, "clientHeight", {
|
||
value: savedDocumentElementClientHeight,
|
||
configurable: true,
|
||
});
|
||
window.localStorage.removeItem(TERMINAL_FONT_SIZE_KEY);
|
||
window.localStorage.removeItem(TERMINAL_PREFERENCES_KEY);
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
/**
|
||
* Helper: simulate a mobile device (Chrome Android style) where
|
||
* window.innerHeight stays constant but visualViewport shrinks.
|
||
*/
|
||
function simulateChromeAndroid(overlapPx: number) {
|
||
(window as any).ontouchstart = null;
|
||
Object.defineProperty(window, "innerWidth", {
|
||
value: 375,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
|
||
const vvHeight = 667 - overlapPx; // initial height minus keyboard
|
||
|
||
const listeners: Record<string, Array<() => void>> = {
|
||
resize: [],
|
||
scroll: [],
|
||
};
|
||
|
||
const mockVV = {
|
||
width: 375,
|
||
height: vvHeight,
|
||
offsetTop: 0,
|
||
offsetLeft: 0,
|
||
addEventListener: vi.fn((event: string, cb: () => void) => {
|
||
if (listeners[event]) listeners[event].push(cb);
|
||
}),
|
||
removeEventListener: vi.fn(),
|
||
};
|
||
|
||
Object.defineProperty(window, "visualViewport", {
|
||
value: mockVV,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
|
||
// Chrome Android: innerHeight stays at full height
|
||
Object.defineProperty(window, "innerHeight", {
|
||
value: 667,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
|
||
return { listeners, mockVV };
|
||
}
|
||
|
||
/**
|
||
* Helper: simulate iOS Safari where window.innerHeight shrinks when
|
||
* the keyboard opens (both window.innerHeight and visualViewport.height
|
||
* shrink together).
|
||
*/
|
||
function simulateIOSSafari(keyboardOpen: boolean, vvHeight?: number) {
|
||
(window as any).ontouchstart = null;
|
||
Object.defineProperty(window, "innerWidth", {
|
||
value: 375,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
|
||
const initialHeight = 667;
|
||
const effectiveVvHeight = vvHeight ?? (keyboardOpen ? 300 : initialHeight);
|
||
|
||
const listeners: Record<string, Array<() => void>> = {
|
||
resize: [],
|
||
scroll: [],
|
||
};
|
||
|
||
const mockVV = {
|
||
width: 375,
|
||
height: effectiveVvHeight,
|
||
offsetTop: 0,
|
||
offsetLeft: 0,
|
||
addEventListener: vi.fn((event: string, cb: () => void) => {
|
||
if (listeners[event]) listeners[event].push(cb);
|
||
}),
|
||
removeEventListener: vi.fn(),
|
||
};
|
||
|
||
Object.defineProperty(window, "visualViewport", {
|
||
value: mockVV,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
|
||
// iOS Safari: innerHeight matches visual viewport height
|
||
Object.defineProperty(window, "innerHeight", {
|
||
value: effectiveVvHeight,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
|
||
return { listeners, mockVV, initialHeight };
|
||
}
|
||
|
||
it("remeasures the mobile keyboard-open terminal when reducing the persisted font size to 10px", async () => {
|
||
const { listeners } = simulateIOSSafari(true, 300);
|
||
const fontLoad = vi.fn().mockResolvedValue(undefined);
|
||
Object.defineProperty(document, "fonts", {
|
||
value: {
|
||
load: fontLoad,
|
||
ready: Promise.resolve(),
|
||
},
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(document.documentElement, "clientHeight", {
|
||
value: 667,
|
||
configurable: true,
|
||
});
|
||
const onDataListeners: Array<(data: string) => void> = [];
|
||
const resizeForSmallFont = vi.fn();
|
||
mockUseTerminal.mockReturnValue(createMockTerminalState({
|
||
connectionStatus: "connected",
|
||
resize: resizeForSmallFont,
|
||
onData: vi.fn((cb: (data: string) => void) => {
|
||
onDataListeners.push(cb);
|
||
return vi.fn();
|
||
}),
|
||
}));
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
|
||
await waitFor(() => expect(screen.getByTestId("terminal-font-size-value")).toHaveTextContent("14px"));
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("367px");
|
||
expect(modal.style.getPropertyValue("--vv-height")).toBe("300px");
|
||
});
|
||
expectMeasurementSafeFontStack(mockTerminalInstance.options.fontFamily as string);
|
||
|
||
fontLoad.mockClear();
|
||
resizeForSmallFont.mockClear();
|
||
const decrease = screen.getByTestId("terminal-font-size-decrease");
|
||
for (let i = 0; i < 4; i += 1) {
|
||
fireEvent.click(decrease);
|
||
}
|
||
|
||
await waitFor(() => expect(screen.getByTestId("terminal-font-size-value")).toHaveTextContent("10px"));
|
||
await waitFor(() => {
|
||
expect(fontLoad).toHaveBeenCalledWith(expect.stringContaining("10px"));
|
||
});
|
||
await waitFor(() => expect(mockTerminalInstance.options.fontSize).toBe(10));
|
||
await waitFor(() => expect(mockTerminalInstance.refresh).toHaveBeenCalledWith(0, 23));
|
||
await waitFor(() => expect(resizeForSmallFont).toHaveBeenCalledWith(80, 24));
|
||
|
||
act(() => {
|
||
for (const cb of onDataListeners) {
|
||
cb("❯ pnpm build\r\n@fusion/dashboard build complete main\r\n");
|
||
}
|
||
for (const cb of listeners.resize) cb();
|
||
});
|
||
|
||
await waitFor(() => expect(mockTerminalInstance.write).toHaveBeenCalledWith(expect.stringContaining("pnpm build")));
|
||
expectMeasurementSafeFontStack(mockTerminalInstance.options.fontFamily as string);
|
||
window.localStorage.removeItem(TERMINAL_PREFERENCES_KEY);
|
||
});
|
||
|
||
it("keeps initial folded keyboard-open terminal metrics before any unfold repair", async () => {
|
||
const { listeners } = simulateIOSSafari(true, 300);
|
||
const onDataListeners: Array<(data: string) => void> = [];
|
||
const resizeForFoldedKeyboard = vi.fn();
|
||
Object.defineProperty(document.documentElement, "clientHeight", {
|
||
value: 667,
|
||
configurable: true,
|
||
});
|
||
const helperTextarea = document.createElement("textarea");
|
||
document.body.appendChild(helperTextarea);
|
||
helperTextarea.focus();
|
||
|
||
mockUseTerminal.mockReturnValue(createMockTerminalState({
|
||
connectionStatus: "connected",
|
||
resize: resizeForFoldedKeyboard,
|
||
onData: vi.fn((cb: (data: string) => void) => {
|
||
onDataListeners.push(cb);
|
||
return vi.fn();
|
||
}),
|
||
onScrollback: vi.fn((cb: (data: string) => void) => {
|
||
onDataListeners.push(cb);
|
||
return vi.fn();
|
||
}),
|
||
}));
|
||
|
||
try {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
act(() => {
|
||
for (const cb of listeners.resize) cb();
|
||
});
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("367px");
|
||
expect(modal.style.getPropertyValue("--vv-height")).toBe("300px");
|
||
});
|
||
await waitFor(() => expect(onDataListeners.length).toBeGreaterThan(0));
|
||
act(() => {
|
||
for (const cb of onDataListeners) {
|
||
cb("❯ pnpm build\r\n✔ built packages/dashboard main\r\n");
|
||
}
|
||
});
|
||
await waitFor(() => expect(mockTerminalInstance.write).toHaveBeenCalledWith(expect.stringContaining("pnpm build")));
|
||
await waitFor(() => expect(resizeForFoldedKeyboard).toHaveBeenCalledWith(80, 24));
|
||
|
||
resizeForFoldedKeyboard.mockClear();
|
||
act(() => {
|
||
for (const cb of listeners.resize) cb();
|
||
for (const cb of listeners.resize) cb();
|
||
window.dispatchEvent(new Event("orientationchange"));
|
||
window.dispatchEvent(new Event("orientationchange"));
|
||
});
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("367px");
|
||
expect(modal.style.getPropertyValue("--vv-height")).toBe("300px");
|
||
expect(resizeForFoldedKeyboard).toHaveBeenCalledWith(80, 24);
|
||
});
|
||
} finally {
|
||
helperTextarea.remove();
|
||
}
|
||
});
|
||
|
||
it("fits initial iOS keyboard-open 12px terminal from the visible viewport before any repair event", async () => {
|
||
(window as any).ontouchstart = null;
|
||
window.localStorage.setItem(TERMINAL_PREFERENCES_KEY, JSON.stringify({
|
||
...DEFAULT_TERMINAL_PREFERENCES,
|
||
fontSize: 12,
|
||
}));
|
||
const originalScreen = window.screen;
|
||
const { listeners, mockVV } = simulateIOSSafari(true, 390);
|
||
Object.defineProperty(mockVV, "width", { value: 390, writable: true, configurable: true });
|
||
Object.defineProperty(window, "innerWidth", { value: 390, writable: true, configurable: true });
|
||
Object.defineProperty(document.documentElement, "clientWidth", {
|
||
value: 390,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(document.documentElement, "clientHeight", {
|
||
value: 390,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(window, "screen", {
|
||
configurable: true,
|
||
value: { width: 390, height: 844 },
|
||
});
|
||
const helperTextarea = document.createElement("textarea");
|
||
document.body.appendChild(helperTextarea);
|
||
helperTextarea.focus();
|
||
const onDataListeners: Array<(data: string) => void> = [];
|
||
const resizeForInitialIOSKeyboard = vi.fn();
|
||
mockUseTerminal.mockReturnValue(createMockTerminalState({
|
||
connectionStatus: "connected",
|
||
resize: resizeForInitialIOSKeyboard,
|
||
onData: vi.fn((cb: (data: string) => void) => {
|
||
onDataListeners.push(cb);
|
||
return vi.fn();
|
||
}),
|
||
onScrollback: vi.fn((cb: (data: string) => void) => {
|
||
onDataListeners.push(cb);
|
||
return vi.fn();
|
||
}),
|
||
}));
|
||
|
||
try {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => expect(screen.getByTestId("terminal-font-size-value")).toHaveTextContent("12px"));
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal).toHaveClass("terminal-modal--mobile");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("454px");
|
||
expect(modal.style.getPropertyValue("--vv-height")).toBe("390px");
|
||
expect(modal.style.getPropertyValue("--vv-width")).toBe("390px");
|
||
});
|
||
await waitFor(() => expect(onDataListeners.length).toBeGreaterThan(0));
|
||
|
||
act(() => {
|
||
for (const cb of onDataListeners) {
|
||
cb("❯ test\r\n❯ ls\r\nAGENTS.md README.md package.json main\r\n");
|
||
}
|
||
for (const cb of listeners.resize) cb();
|
||
});
|
||
|
||
await waitFor(() => expect(mockTerminalInstance.write).toHaveBeenCalledWith(expect.stringContaining("test")));
|
||
await waitFor(() => expect(mockTerminalInstance.write).toHaveBeenCalledWith(expect.stringContaining("AGENTS.md")));
|
||
await waitFor(() => expect(resizeForInitialIOSKeyboard).toHaveBeenCalledWith(80, 24));
|
||
expectMeasurementSafeFontStack(mockTerminalInstance.options.fontFamily as string);
|
||
expect(mockTerminalInstance.options.fontSize).toBe(12);
|
||
expectTextSizeAdjustmentDisabledForExactXtermMetrics(terminalModalCss);
|
||
} finally {
|
||
helperTextarea.remove();
|
||
Object.defineProperty(window, "screen", { configurable: true, value: originalScreen });
|
||
window.localStorage.removeItem(TERMINAL_PREFERENCES_KEY);
|
||
}
|
||
});
|
||
|
||
it("fits initial iOS keyboard-open 10px terminal when layout height already shrank", async () => {
|
||
(window as any).ontouchstart = null;
|
||
window.localStorage.setItem(TERMINAL_FONT_SIZE_KEY, "10");
|
||
const originalScreen = window.screen;
|
||
const { mockVV } = simulateIOSSafari(true, 390);
|
||
Object.defineProperty(mockVV, "width", { value: 390, writable: true, configurable: true });
|
||
Object.defineProperty(window, "innerWidth", { value: 390, writable: true, configurable: true });
|
||
Object.defineProperty(document.documentElement, "clientHeight", {
|
||
value: 390,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(window, "screen", {
|
||
configurable: true,
|
||
value: { width: 390, height: 844 },
|
||
});
|
||
const helperTextarea = document.createElement("textarea");
|
||
document.body.appendChild(helperTextarea);
|
||
helperTextarea.focus();
|
||
const resizeForInitialIOSSmallFont = vi.fn();
|
||
mockUseTerminal.mockReturnValue(createMockTerminalState({
|
||
connectionStatus: "connected",
|
||
resize: resizeForInitialIOSSmallFont,
|
||
}));
|
||
|
||
try {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => expect(screen.getByTestId("terminal-font-size-value")).toHaveTextContent("10px"));
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("454px");
|
||
expect(modal.style.getPropertyValue("--vv-height")).toBe("390px");
|
||
expect(modal.style.getPropertyValue("--vv-width")).toBe("390px");
|
||
});
|
||
await waitFor(() => expect(resizeForInitialIOSSmallFont).toHaveBeenCalledWith(80, 24));
|
||
expectMeasurementSafeFontStack(mockTerminalInstance.options.fontFamily as string);
|
||
expect(mockTerminalInstance.options.fontSize).toBe(10);
|
||
} finally {
|
||
helperTextarea.remove();
|
||
Object.defineProperty(window, "screen", { configurable: true, value: originalScreen });
|
||
window.localStorage.removeItem(TERMINAL_FONT_SIZE_KEY);
|
||
}
|
||
});
|
||
|
||
it("fits Android keyboard-open 10px terminal to visual viewport width before any repair event", async () => {
|
||
(window as any).ontouchstart = null;
|
||
window.localStorage.setItem(TERMINAL_FONT_SIZE_KEY, "10");
|
||
const listeners: Record<string, Array<() => void>> = { resize: [], scroll: [] };
|
||
const mockVV = {
|
||
width: 390,
|
||
height: 320,
|
||
offsetTop: 0,
|
||
offsetLeft: 0,
|
||
addEventListener: vi.fn((event: string, cb: () => void) => {
|
||
if (listeners[event]) listeners[event].push(cb);
|
||
}),
|
||
removeEventListener: vi.fn(),
|
||
};
|
||
Object.defineProperty(window, "visualViewport", {
|
||
value: mockVV,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(window, "innerWidth", {
|
||
value: 900,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(window, "innerHeight", {
|
||
value: 700,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(document.documentElement, "clientWidth", {
|
||
value: 900,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(document.documentElement, "clientHeight", {
|
||
value: 700,
|
||
configurable: true,
|
||
});
|
||
const onDataListeners: Array<(data: string) => void> = [];
|
||
const resizeForAndroidKeyboard = vi.fn();
|
||
mockUseTerminal.mockReturnValue(createMockTerminalState({
|
||
connectionStatus: "connected",
|
||
resize: resizeForAndroidKeyboard,
|
||
onData: vi.fn((cb: (data: string) => void) => {
|
||
onDataListeners.push(cb);
|
||
return vi.fn();
|
||
}),
|
||
onScrollback: vi.fn((cb: (data: string) => void) => {
|
||
onDataListeners.push(cb);
|
||
return vi.fn();
|
||
}),
|
||
}));
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => expect(screen.getByTestId("terminal-font-size-value")).toHaveTextContent("10px"));
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal).toHaveClass("terminal-modal--mobile");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("380px");
|
||
expect(modal.style.getPropertyValue("--vv-height")).toBe("320px");
|
||
expect(modal.style.getPropertyValue("--vv-width")).toBe("390px");
|
||
});
|
||
await waitFor(() => expect(onDataListeners.length).toBeGreaterThan(0));
|
||
|
||
act(() => {
|
||
for (const cb of onDataListeners) {
|
||
cb("❯ ls\r\nAGENTS.md CHANGELOG.md README.md eslint.config.mjs tsconfig.placeholder.d.ts main\r\n");
|
||
}
|
||
});
|
||
|
||
await waitFor(() => expect(mockTerminalInstance.write).toHaveBeenCalledWith(expect.stringContaining("AGENTS.md")));
|
||
await waitFor(() => expect(resizeForAndroidKeyboard).toHaveBeenCalledWith(80, 24));
|
||
expectMeasurementSafeFontStack(mockTerminalInstance.options.fontFamily as string);
|
||
expect(mockTerminalInstance.options.fontSize).toBe(10);
|
||
});
|
||
|
||
it("treats Android folded visualViewport width as mobile before initial terminal fit", async () => {
|
||
(window as any).ontouchstart = null;
|
||
window.localStorage.setItem(TERMINAL_FONT_SIZE_KEY, "10");
|
||
const listeners: Record<string, Array<() => void>> = { resize: [], scroll: [] };
|
||
const mockVV = {
|
||
width: 390,
|
||
height: 320,
|
||
offsetTop: 0,
|
||
offsetLeft: 0,
|
||
addEventListener: vi.fn((event: string, cb: () => void) => {
|
||
if (listeners[event]) listeners[event].push(cb);
|
||
}),
|
||
removeEventListener: vi.fn(),
|
||
};
|
||
Object.defineProperty(window, "visualViewport", {
|
||
value: mockVV,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(window, "innerWidth", {
|
||
value: 900,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(window, "innerHeight", {
|
||
value: 700,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(document.documentElement, "clientHeight", {
|
||
value: 700,
|
||
configurable: true,
|
||
});
|
||
const onDataListeners: Array<(data: string) => void> = [];
|
||
const resizeForFoldedAndroid = vi.fn();
|
||
mockUseTerminal.mockReturnValue(createMockTerminalState({
|
||
connectionStatus: "connected",
|
||
resize: resizeForFoldedAndroid,
|
||
onData: vi.fn((cb: (data: string) => void) => {
|
||
onDataListeners.push(cb);
|
||
return vi.fn();
|
||
}),
|
||
onScrollback: vi.fn((cb: (data: string) => void) => {
|
||
onDataListeners.push(cb);
|
||
return vi.fn();
|
||
}),
|
||
}));
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => expect(screen.getByTestId("terminal-font-size-value")).toHaveTextContent("10px"));
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal).not.toHaveClass("terminal-modal--docked");
|
||
expect(modal).not.toHaveClass("terminal-modal--floating");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("380px");
|
||
expect(modal.style.getPropertyValue("--vv-height")).toBe("320px");
|
||
});
|
||
|
||
await waitFor(() => expect(onDataListeners.length).toBeGreaterThan(0));
|
||
act(() => {
|
||
for (const cb of onDataListeners) {
|
||
cb("❯ pnpm build\r\n@fusion/dashboard build complete main\r\n");
|
||
}
|
||
for (const cb of listeners.resize) cb();
|
||
});
|
||
|
||
await waitFor(() => expect(mockTerminalInstance.write).toHaveBeenCalledWith(expect.stringContaining("pnpm build")));
|
||
await waitFor(() => expect(resizeForFoldedAndroid).toHaveBeenCalledWith(80, 24));
|
||
expectMeasurementSafeFontStack(mockTerminalInstance.options.fontFamily as string);
|
||
expect(mockTerminalInstance.options.fontSize).toBe(10);
|
||
});
|
||
|
||
it("detects keyboard on iOS Safari where innerHeight shrinks with visualViewport", async () => {
|
||
// On iOS Safari, both window.innerHeight and visualViewport.height shrink.
|
||
// The primary formula (innerHeight - vv.offsetTop - vv.height) returns 0
|
||
// because innerHeight == vv.height. The fallback should detect the gap
|
||
// from the cached initial viewport height.
|
||
//
|
||
// To properly test this, we start with full viewport (no keyboard),
|
||
// then simulate the keyboard opening by shrinking both values.
|
||
const { listeners, mockVV } = simulateIOSSafari(false, 667);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
// Initially no overlap
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||
});
|
||
|
||
// Now simulate keyboard opening: both innerHeight and vv shrink
|
||
Object.defineProperty(window, "innerHeight", {
|
||
value: 300,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(mockVV, "height", {
|
||
value: 300,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
|
||
act(() => {
|
||
for (const cb of listeners.resize) cb();
|
||
});
|
||
|
||
// Should detect overlap via the initialHeight fallback
|
||
// initialHeight was captured as 667, so overlap = 667 - 0 - 300 = 367
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("367px");
|
||
});
|
||
});
|
||
|
||
it("does not detect keyboard on iOS Safari when viewport is full height", async () => {
|
||
const { listeners } = simulateIOSSafari(false, 667);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||
});
|
||
});
|
||
|
||
it("sets --vv-height CSS variable to visualViewport.height when keyboard is open", async () => {
|
||
const { listeners } = simulateChromeAndroid(250);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
// --vv-height should be set to the visualViewport height (417px = 667 - 250)
|
||
expect(modal.style.getPropertyValue("--vv-height")).toBe("417px");
|
||
});
|
||
});
|
||
|
||
it("updates --vv-height when visualViewport height changes", async () => {
|
||
const { listeners, mockVV } = simulateChromeAndroid(250);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--vv-height")).toBe("417px");
|
||
});
|
||
|
||
// Keyboard partially closes: vv height increases from 417 to 567
|
||
Object.defineProperty(mockVV, "height", { value: 567, writable: true, configurable: true });
|
||
|
||
act(() => {
|
||
for (const cb of listeners.resize) cb();
|
||
});
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--vv-height")).toBe("567px");
|
||
});
|
||
});
|
||
|
||
it("re-baselines iOS keyboard overlap after folded posture settles before input", async () => {
|
||
const { listeners, mockVV } = simulateIOSSafari(false, 844);
|
||
Object.defineProperty(mockVV, "width", { value: 700, writable: true, configurable: true });
|
||
Object.defineProperty(window, "innerWidth", { value: 700, writable: true, configurable: true });
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-modal").style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||
});
|
||
|
||
// Device is folded/narrow while the keyboard is still closed; this settled
|
||
// viewport must replace the previous unfolded baseline before input opens.
|
||
Object.defineProperty(window, "innerWidth", { value: 375, writable: true, configurable: true });
|
||
Object.defineProperty(window, "innerHeight", { value: 667, writable: true, configurable: true });
|
||
Object.defineProperty(mockVV, "width", { value: 375, writable: true, configurable: true });
|
||
Object.defineProperty(mockVV, "height", { value: 667, writable: true, configurable: true });
|
||
|
||
act(() => {
|
||
for (const cb of listeners.resize) cb();
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-modal").style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||
});
|
||
|
||
Object.defineProperty(window, "innerHeight", { value: 300, writable: true, configurable: true });
|
||
Object.defineProperty(mockVV, "height", { value: 300, writable: true, configurable: true });
|
||
|
||
act(() => {
|
||
for (const cb of listeners.resize) cb();
|
||
});
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("367px");
|
||
expect(modal.style.getPropertyValue("--vv-height")).toBe("300px");
|
||
});
|
||
});
|
||
|
||
it("re-baselines keyboard-closed folded landscape samples below 480px", async () => {
|
||
const { listeners, mockVV } = simulateIOSSafari(false, 844);
|
||
Object.defineProperty(mockVV, "width", { value: 700, writable: true, configurable: true });
|
||
Object.defineProperty(window, "innerWidth", { value: 700, writable: true, configurable: true });
|
||
Object.defineProperty(document.documentElement, "clientHeight", {
|
||
value: 844,
|
||
configurable: true,
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-modal").style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||
});
|
||
|
||
Object.defineProperty(window, "innerWidth", { value: 375, writable: true, configurable: true });
|
||
Object.defineProperty(window, "innerHeight", { value: 375, writable: true, configurable: true });
|
||
Object.defineProperty(document.documentElement, "clientHeight", {
|
||
value: 375,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(mockVV, "width", { value: 375, writable: true, configurable: true });
|
||
Object.defineProperty(mockVV, "height", { value: 375, writable: true, configurable: true });
|
||
|
||
act(() => {
|
||
for (const cb of listeners.resize) cb();
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-modal").style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||
});
|
||
|
||
Object.defineProperty(window, "innerHeight", { value: 250, writable: true, configurable: true });
|
||
Object.defineProperty(document.documentElement, "clientHeight", {
|
||
value: 250,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(mockVV, "height", { value: 250, writable: true, configurable: true });
|
||
|
||
act(() => {
|
||
for (const cb of listeners.resize) cb();
|
||
});
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("125px");
|
||
expect(modal.style.getPropertyValue("--vv-height")).toBe("250px");
|
||
});
|
||
});
|
||
|
||
it("does not re-baseline folded viewport width changes from focused keyboard-open samples", async () => {
|
||
const { listeners, mockVV } = simulateIOSSafari(false, 844);
|
||
Object.defineProperty(mockVV, "width", { value: 700, writable: true, configurable: true });
|
||
Object.defineProperty(window, "innerWidth", { value: 700, writable: true, configurable: true });
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-modal").style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||
});
|
||
|
||
const helperTextarea = document.createElement("textarea");
|
||
document.body.appendChild(helperTextarea);
|
||
helperTextarea.focus();
|
||
|
||
try {
|
||
// Fold/orientation can deliver the first narrow width sample while the
|
||
// soft keyboard is already open. With iOS-style innerHeight==vv.height,
|
||
// re-baselining from this focused sample would make gap=0 and clear the
|
||
// terminal keyboard CSS vars that drive the final xterm fit.
|
||
Object.defineProperty(window, "innerWidth", { value: 375, writable: true, configurable: true });
|
||
Object.defineProperty(window, "innerHeight", { value: 520, writable: true, configurable: true });
|
||
Object.defineProperty(mockVV, "width", { value: 375, writable: true, configurable: true });
|
||
Object.defineProperty(mockVV, "height", { value: 520, writable: true, configurable: true });
|
||
|
||
act(() => {
|
||
for (const cb of listeners.resize) cb();
|
||
});
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("324px");
|
||
expect(modal.style.getPropertyValue("--vv-height")).toBe("520px");
|
||
});
|
||
} finally {
|
||
helperTextarea.remove();
|
||
}
|
||
});
|
||
|
||
it("does not set --vv-height when no keyboard overlap", async () => {
|
||
const { listeners } = simulateChromeAndroid(0);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
// No overlap → style should be undefined (no --vv-height set)
|
||
expect(modal.style.getPropertyValue("--vv-height")).toBe("");
|
||
});
|
||
});
|
||
|
||
it("calls fitAddon.fit() and resize when viewport changes with keyboard open", async () => {
|
||
const { listeners } = simulateChromeAndroid(250);
|
||
|
||
// Mock fit and resize to be trackable
|
||
const mockFit = vi.fn();
|
||
const mockFitAddon = { fit: mockFit, dispose: vi.fn() };
|
||
const fitAddonModule = await import("@xterm/addon-fit");
|
||
(fitAddonModule.FitAddon as unknown as ReturnType<typeof vi.fn>).mockImplementation(function FitAddonMock() {
|
||
return mockFitAddon;
|
||
});
|
||
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({ connectionStatus: "connected" })
|
||
);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
// Wait for xterm to initialize
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||
});
|
||
|
||
// Clear previous resize calls from initial setup
|
||
mockResize.mockClear();
|
||
|
||
// Trigger a viewport resize event (keyboard height changes)
|
||
act(() => {
|
||
for (const cb of listeners.resize) cb();
|
||
});
|
||
|
||
// fitAddon.fit() should have been called during viewport change
|
||
await waitFor(() => {
|
||
expect(mockFit).toHaveBeenCalled();
|
||
});
|
||
|
||
// resize should have been called with xterm dimensions
|
||
await waitFor(() => {
|
||
expect(mockResize).toHaveBeenCalledWith(80, 24);
|
||
});
|
||
});
|
||
|
||
it("clears --vv-height when keyboard closes", async () => {
|
||
const { listeners, mockVV } = simulateChromeAndroid(250);
|
||
|
||
const { rerender } = render(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} />,
|
||
);
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--vv-height")).toBe("417px");
|
||
});
|
||
|
||
// Keyboard closes: overlap becomes 0, vv height returns to full
|
||
Object.defineProperty(mockVV, "height", { value: 667, writable: true, configurable: true });
|
||
|
||
act(() => {
|
||
for (const cb of listeners.resize) cb();
|
||
});
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
// Both variables should be cleared when overlap is 0
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||
expect(modal.style.getPropertyValue("--vv-height")).toBe("");
|
||
});
|
||
});
|
||
|
||
it("handles rapid keyboard open/close transitions without stale state", async () => {
|
||
const { listeners, mockVV } = simulateChromeAndroid(250);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("250px");
|
||
});
|
||
|
||
// Rapid open → close → open sequence
|
||
// First: keyboard partially closes
|
||
Object.defineProperty(mockVV, "height", { value: 567, writable: true, configurable: true });
|
||
|
||
act(() => {
|
||
for (const cb of listeners.resize) cb();
|
||
});
|
||
|
||
// Then: keyboard fully closes
|
||
Object.defineProperty(mockVV, "height", { value: 667, writable: true, configurable: true });
|
||
|
||
act(() => {
|
||
for (const cb of listeners.resize) cb();
|
||
});
|
||
|
||
// Then: keyboard opens again with different height
|
||
Object.defineProperty(mockVV, "height", { value: 350, writable: true, configurable: true });
|
||
|
||
act(() => {
|
||
for (const cb of listeners.resize) cb();
|
||
});
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
// Should reflect the latest state: overlap = 667 - 350 = 317
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("317px");
|
||
expect(modal.style.getPropertyValue("--vv-height")).toBe("350px");
|
||
});
|
||
});
|
||
|
||
it("clears viewportHeight when modal closes on mobile", async () => {
|
||
const { listeners } = simulateChromeAndroid(250);
|
||
|
||
const { rerender } = render(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} />,
|
||
);
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--vv-height")).toBe("417px");
|
||
});
|
||
|
||
// Close the modal
|
||
act(() => {
|
||
rerender(<TerminalModal isOpen={false} onClose={mockOnClose} />);
|
||
});
|
||
|
||
// Modal is no longer rendered
|
||
expect(screen.queryByTestId("terminal-modal")).toBeNull();
|
||
});
|
||
|
||
// --- FN-1002: Lowered threshold (150 → 80) with 30px noise filter ---
|
||
it("detects keyboard with gap of 85px (above new 80px threshold)", async () => {
|
||
// Previously with the 150px threshold, 85px would NOT be detected.
|
||
// With the new 80px threshold, it should be detected.
|
||
const { listeners, mockVV } = simulateIOSSafari(false, 667);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
// Initially no overlap
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||
});
|
||
|
||
// Simulate keyboard opening with gap of 85px: vv.height = 667 - 85 = 582
|
||
Object.defineProperty(window, "innerHeight", {
|
||
value: 582,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(mockVV, "height", {
|
||
value: 582,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
|
||
act(() => {
|
||
for (const cb of listeners.resize) cb();
|
||
});
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("85px");
|
||
});
|
||
});
|
||
|
||
it("does not detect keyboard with very small gap of 20px (noise filter)", async () => {
|
||
// Gap of 20px is below the 30px noise filter — should return 0.
|
||
const { listeners, mockVV } = simulateIOSSafari(false, 667);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||
});
|
||
|
||
// Simulate tiny viewport change: gap = 20px, vv.height = 667 - 20 = 647
|
||
Object.defineProperty(window, "innerHeight", {
|
||
value: 647,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(mockVV, "height", {
|
||
value: 647,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
|
||
act(() => {
|
||
for (const cb of listeners.resize) cb();
|
||
});
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||
});
|
||
});
|
||
|
||
it("does not detect keyboard when gap is exactly 80px (boundary, not > 80)", async () => {
|
||
const { listeners, mockVV } = simulateIOSSafari(false, 667);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||
});
|
||
|
||
// gap = 80px exactly: vv.height = 667 - 80 = 587
|
||
Object.defineProperty(window, "innerHeight", {
|
||
value: 587,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(mockVV, "height", {
|
||
value: 587,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
|
||
act(() => {
|
||
for (const cb of listeners.resize) cb();
|
||
});
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
// 80 is NOT > 80, so should not be detected
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||
});
|
||
});
|
||
|
||
it("detects keyboard when gap is 81px (just above 80px boundary)", async () => {
|
||
const { listeners, mockVV } = simulateIOSSafari(false, 667);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("");
|
||
});
|
||
|
||
// gap = 81px: vv.height = 667 - 81 = 586
|
||
Object.defineProperty(window, "innerHeight", {
|
||
value: 586,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(mockVV, "height", {
|
||
value: 586,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
|
||
act(() => {
|
||
for (const cb of listeners.resize) cb();
|
||
});
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("81px");
|
||
});
|
||
});
|
||
|
||
it("scroll event on visualViewport also triggers keyboard overlap update", async () => {
|
||
const { listeners, mockVV } = simulateChromeAndroid(250);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("250px");
|
||
});
|
||
|
||
// Simulate scroll event changing viewport (e.g., keyboard changing position)
|
||
Object.defineProperty(mockVV, "height", { value: 500, writable: true, configurable: true });
|
||
|
||
act(() => {
|
||
for (const cb of listeners.scroll) cb();
|
||
});
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("167px");
|
||
});
|
||
});
|
||
|
||
/**
|
||
* Regression (FN-1025): terminal moves up when keyboard is open but not
|
||
* high enough — bottom still overlapped.
|
||
*
|
||
* The root cause was that the CSS only set max-height (not height) in the
|
||
* keyboard-open selector, and the inherited min-height: 90vh from desktop
|
||
* prevented the modal from shrinking to fit above the keyboard.
|
||
*
|
||
* This test verifies the component correctly sets BOTH --keyboard-overlap
|
||
* and --vv-height CSS variables so the CSS contract can constrain the modal
|
||
* to the visual viewport height (via height + max-height + min-height: auto).
|
||
*/
|
||
it("FN-1025: sets both --keyboard-overlap and --vv-height for partial overlap (moves up but still overlapped)", async () => {
|
||
// Simulate a keyboard that partially covers the terminal — the classic
|
||
// "moves up but not enough" scenario. Overlap of 150px on a 667px screen
|
||
// means the modal should shrink to 517px (vv.height).
|
||
const { listeners, mockVV } = simulateChromeAndroid(150);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
// --keyboard-overlap must be set so the CSS selector matches
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("150px");
|
||
// --vv-height must be set so height/max-height resolve correctly
|
||
// vv.height = 667 - 150 = 517
|
||
expect(modal.style.getPropertyValue("--vv-height")).toBe("517px");
|
||
});
|
||
});
|
||
|
||
it("FN-1025: updates both CSS variables when keyboard height changes", async () => {
|
||
const { listeners, mockVV } = simulateChromeAndroid(150);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("150px");
|
||
expect(modal.style.getPropertyValue("--vv-height")).toBe("517px");
|
||
});
|
||
|
||
// Keyboard grows taller: overlap increases from 150 to 300
|
||
Object.defineProperty(mockVV, "height", { value: 367, writable: true, configurable: true });
|
||
|
||
act(() => {
|
||
for (const cb of listeners.resize) cb();
|
||
});
|
||
|
||
await waitFor(() => {
|
||
const modal = screen.getByTestId("terminal-modal");
|
||
// overlap = 667 - 367 = 300
|
||
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("300px");
|
||
expect(modal.style.getPropertyValue("--vv-height")).toBe("367px");
|
||
});
|
||
});
|
||
});
|
||
|
||
// --- xterm focus initialization regression tests ---
|
||
describe("TerminalModal — xterm focus initialization (FN-1602)", () => {
|
||
const mockOnClose = vi.fn();
|
||
const mockSendInput = vi.fn();
|
||
const mockResize = vi.fn();
|
||
const mockReconnect = vi.fn();
|
||
|
||
const createMockTerminalState = (overrides = {}) => ({
|
||
connectionStatus: "disconnected" as const,
|
||
sendInput: mockSendInput,
|
||
resize: mockResize,
|
||
onData: vi.fn(() => vi.fn()),
|
||
onExit: vi.fn(() => vi.fn()),
|
||
onConnect: vi.fn(() => vi.fn()),
|
||
onScrollback: vi.fn(() => vi.fn()),
|
||
reconnect: mockReconnect,
|
||
onSessionInvalid: vi.fn(() => vi.fn()),
|
||
...overrides,
|
||
});
|
||
|
||
beforeEach(async () => {
|
||
const fitAddonModule = await import("@xterm/addon-fit");
|
||
vi.mocked(fitAddonModule.FitAddon).mockImplementation(function FitAddonMock() {
|
||
return {
|
||
fit: mockFitAddonFit,
|
||
dispose: vi.fn(),
|
||
};
|
||
} as never);
|
||
vi.clearAllMocks();
|
||
terminalKeyEventHandler = null;
|
||
terminalDataHandler = null;
|
||
mockTerminalInstance.onData.mockImplementation((cb: (data: string) => void) => {
|
||
terminalDataHandler = cb;
|
||
return { dispose: vi.fn() };
|
||
});
|
||
Object.defineProperty(document, "fonts", {
|
||
value: undefined,
|
||
configurable: true,
|
||
});
|
||
mockCreateTerminalSession.mockResolvedValue({
|
||
sessionId: "test-session-123",
|
||
shell: "/bin/bash",
|
||
cwd: "/project",
|
||
});
|
||
mockKillPtyTerminalSession.mockResolvedValue({ killed: true });
|
||
mockUseTerminal.mockReturnValue(createMockTerminalState());
|
||
mockUseTerminalSessions.mockReturnValue(defaultSessionState);
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
/**
|
||
* Regression: terminal text entry not working after xterm initialization.
|
||
*
|
||
* The original bug occurred because xterm's programmatic focus() call did not
|
||
* properly trigger xterm's internal focus tracking. xterm.js relies on
|
||
* canvas click events to set up focus handling, so we now:
|
||
* 1. Focus the helper textarea directly after terminal.open()
|
||
* 2. Dispatch a synthetic click on the container to trigger xterm's
|
||
* internal focus tracking
|
||
*/
|
||
it("renders terminal container after xterm is ready", async () => {
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({ connectionStatus: "connected" })
|
||
);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||
});
|
||
|
||
// Terminal container should be rendered
|
||
expect(screen.getByTestId("terminal-xterm")).toBeTruthy();
|
||
});
|
||
|
||
it("handles dispatchEvent errors gracefully in non-browser environments", async () => {
|
||
// Simulate dispatchEvent throwing an error (e.g., in jsdom without proper setup)
|
||
const originalDispatchEvent = Element.prototype.dispatchEvent;
|
||
Element.prototype.dispatchEvent = vi.fn(() => {
|
||
throw new Error("dispatchEvent not supported");
|
||
});
|
||
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({ connectionStatus: "connected" })
|
||
);
|
||
|
||
// Should not throw despite dispatchEvent failing
|
||
expect(() => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
}).not.toThrow();
|
||
|
||
// Restore original method
|
||
Element.prototype.dispatchEvent = originalDispatchEvent;
|
||
});
|
||
|
||
it("continues to work when connection status changes after initial render", async () => {
|
||
// Start with disconnected
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({ connectionStatus: "disconnected" })
|
||
);
|
||
|
||
const { rerender } = render(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} />
|
||
);
|
||
|
||
// xterm should still initialize
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||
});
|
||
|
||
// Now simulate connection becoming established
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({ connectionStatus: "connected" })
|
||
);
|
||
|
||
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
// Modal should still render correctly
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
it("forwards xterm onData input to sendInput", async () => {
|
||
let terminalInputCallback: ((data: string) => void) | null = null;
|
||
mockTerminalInstance.onData.mockImplementation((cb: (data: string) => void) => {
|
||
terminalInputCallback = cb;
|
||
return { dispose: vi.fn() };
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(terminalInputCallback).not.toBeNull();
|
||
});
|
||
|
||
act(() => {
|
||
terminalInputCallback?.("echo hello\r");
|
||
});
|
||
|
||
expect(mockSendInput).toHaveBeenCalledWith("echo hello\r");
|
||
});
|
||
|
||
it.each([
|
||
["mac", "MacIntel", { metaKey: true }],
|
||
["non-mac", "Win32", { ctrlKey: true }],
|
||
] as const)("copies selected terminal text on platform copy modifier+c and blocks sigint on %s", async (_name, platform, modifier) => {
|
||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||
Object.defineProperty(navigator, "platform", {
|
||
value: platform,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(navigator, "clipboard", {
|
||
value: { writeText },
|
||
configurable: true,
|
||
});
|
||
mockTerminalInstance.hasSelection.mockReturnValue(true);
|
||
mockTerminalInstance.getSelection.mockReturnValue("copied output");
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(terminalKeyEventHandler).not.toBeNull();
|
||
});
|
||
|
||
const handled = terminalKeyEventHandler?.(
|
||
new KeyboardEvent("keydown", { key: "c", ...modifier }),
|
||
);
|
||
|
||
expect(handled).toBe(false);
|
||
expect(writeText).toHaveBeenCalledWith("copied output");
|
||
expect(mockSendInput).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it.each([
|
||
["mac ctrl", "MacIntel", { ctrlKey: true }],
|
||
["mac platform copy modifier", "MacIntel", { metaKey: true }],
|
||
["non-mac ctrl", "Win32", { ctrlKey: true }],
|
||
] as const)("preserves sigint on %s+c when nothing is selected", async (_name, platform, modifier) => {
|
||
Object.defineProperty(navigator, "platform", {
|
||
value: platform,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(navigator, "clipboard", {
|
||
value: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||
configurable: true,
|
||
});
|
||
mockTerminalInstance.hasSelection.mockReturnValue(false);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(terminalKeyEventHandler).not.toBeNull();
|
||
});
|
||
|
||
const handled = terminalKeyEventHandler?.(
|
||
new KeyboardEvent("keydown", { key: "c", ...modifier }),
|
||
);
|
||
|
||
expect(handled).toBe(true);
|
||
});
|
||
|
||
it.each([
|
||
["mac", "MacIntel", { metaKey: true }],
|
||
["non-mac", "Win32", { ctrlKey: true }],
|
||
] as const)(
|
||
"delivers physical keyboard paste exactly once from clipboard on %s",
|
||
async (_name, platform, modifier) => {
|
||
const readText = vi.fn().mockResolvedValue("npm test\n");
|
||
Object.defineProperty(navigator, "platform", {
|
||
value: platform,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(navigator, "clipboard", {
|
||
value: { readText },
|
||
configurable: true,
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(terminalKeyEventHandler).not.toBeNull();
|
||
expect(terminalDataHandler).not.toBeNull();
|
||
});
|
||
|
||
const handled = terminalKeyEventHandler?.(
|
||
new KeyboardEvent("keydown", { key: "v", ...modifier }),
|
||
);
|
||
|
||
expect(handled).toBe(false);
|
||
await waitFor(() => expect(readText).toHaveBeenCalledTimes(1));
|
||
expect(mockSendInput).toHaveBeenCalledTimes(1);
|
||
expect(mockSendInput).toHaveBeenCalledWith("npm test\n");
|
||
},
|
||
);
|
||
|
||
it.each([
|
||
["missing clipboard", undefined],
|
||
["rejected clipboard", { readText: vi.fn().mockRejectedValue(new DOMException("denied")) }],
|
||
["empty clipboard", { readText: vi.fn().mockResolvedValue("") }],
|
||
] as const)("fails safely for %s physical paste while preserving xterm input", async (_label, clipboard) => {
|
||
Object.defineProperty(navigator, "platform", {
|
||
value: "Win32",
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(navigator, "clipboard", {
|
||
value: clipboard,
|
||
configurable: true,
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(terminalKeyEventHandler).not.toBeNull();
|
||
expect(terminalDataHandler).not.toBeNull();
|
||
});
|
||
|
||
const handled = terminalKeyEventHandler?.(
|
||
new KeyboardEvent("keydown", { key: "v", ctrlKey: true }),
|
||
);
|
||
|
||
expect(handled).toBe(false);
|
||
if (clipboard?.readText) {
|
||
await waitFor(() => expect(clipboard.readText).toHaveBeenCalledTimes(1));
|
||
}
|
||
await act(async () => {
|
||
await Promise.resolve();
|
||
});
|
||
expect(mockSendInput).not.toHaveBeenCalled();
|
||
|
||
act(() => {
|
||
terminalDataHandler?.("typed input");
|
||
});
|
||
expect(mockSendInput).toHaveBeenCalledTimes(1);
|
||
expect(mockSendInput).toHaveBeenCalledWith("typed input");
|
||
});
|
||
|
||
it("delivers native helper-textarea paste exactly once without the shortcut handler", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(terminalDataHandler).not.toBeNull();
|
||
});
|
||
|
||
act(() => {
|
||
terminalDataHandler?.("line one\nline two\n");
|
||
});
|
||
|
||
expect(mockSendInput).toHaveBeenCalledTimes(1);
|
||
expect(mockSendInput).toHaveBeenCalledWith("line one\nline two\n");
|
||
});
|
||
|
||
it("refits xterm after the async terminal font loads", async () => {
|
||
let resolveFontLoad: (value: FontFace[]) => void = () => {};
|
||
const load = vi.fn(
|
||
() =>
|
||
new Promise<FontFace[]>((resolve) => {
|
||
resolveFontLoad = resolve;
|
||
}),
|
||
);
|
||
Object.defineProperty(document, "fonts", {
|
||
value: {
|
||
load,
|
||
ready: Promise.resolve(),
|
||
},
|
||
configurable: true,
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||
expect(load).toHaveBeenCalledWith(expect.stringContaining("MesloLGS NF"));
|
||
expect(load).not.toHaveBeenCalledWith(
|
||
expect.stringContaining("Fusion Terminal Nerd Font Symbols"),
|
||
);
|
||
});
|
||
|
||
const fitCallBaseline = mockFitAddonFit.mock.calls.length;
|
||
|
||
await act(async () => {
|
||
resolveFontLoad([]);
|
||
await Promise.resolve();
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(mockFitAddonFit.mock.calls.length).toBeGreaterThan(fitCallBaseline);
|
||
expect(mockResize).toHaveBeenCalledWith(
|
||
mockTerminalInstance.cols,
|
||
mockTerminalInstance.rows,
|
||
);
|
||
});
|
||
});
|
||
|
||
it("still refits xterm when iOS rejects the multi-family font-load shorthand", async () => {
|
||
const load = vi.fn(() => Promise.reject(new DOMException("Invalid font shorthand")));
|
||
Object.defineProperty(document, "fonts", {
|
||
value: {
|
||
load,
|
||
ready: Promise.resolve(),
|
||
},
|
||
configurable: true,
|
||
});
|
||
|
||
const fitCallBaseline = mockFitAddonFit.mock.calls.length;
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||
expect(load).toHaveBeenCalledWith(expect.stringContaining("MesloLGS NF"));
|
||
expect(load).not.toHaveBeenCalledWith(
|
||
expect.stringContaining("Fusion Terminal Nerd Font Symbols"),
|
||
);
|
||
});
|
||
|
||
await act(async () => {
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.options.fontFamily).toBe(XTERM_FONT_FAMILY);
|
||
expectMeasurementSafeFontStack(mockTerminalInstance.options.fontFamily as string);
|
||
expect(mockTerminalInstance.options.fontSize).toBe(DEFAULT_TERMINAL_PREFERENCES.fontSize);
|
||
expect(mockFitAddonFit.mock.calls.length).toBeGreaterThan(fitCallBaseline);
|
||
expect(mockResize).toHaveBeenCalledWith(
|
||
mockTerminalInstance.cols,
|
||
mockTerminalInstance.rows,
|
||
);
|
||
expect(mockTerminalInstance.refresh).toHaveBeenCalledWith(0, mockTerminalInstance.rows - 1);
|
||
});
|
||
});
|
||
|
||
it("leaves unrelated key handling untouched", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(terminalKeyEventHandler).not.toBeNull();
|
||
});
|
||
|
||
const handled = terminalKeyEventHandler?.(
|
||
new KeyboardEvent("keydown", { key: "x", ctrlKey: true }),
|
||
);
|
||
|
||
expect(handled).toBe(true);
|
||
});
|
||
|
||
it("keeps terminal input forwarding active after active-tab title rerenders", async () => {
|
||
let terminalInputCallback: ((data: string) => void) | null = null;
|
||
const disposeInputHandler = vi.fn();
|
||
|
||
mockTerminalInstance.onData.mockImplementation((cb: (data: string) => void) => {
|
||
terminalInputCallback = cb;
|
||
return { dispose: disposeInputHandler };
|
||
});
|
||
|
||
const { rerender } = render(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} />,
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(terminalInputCallback).not.toBeNull();
|
||
});
|
||
|
||
// Simulate tab metadata update (title change) that should not tear down
|
||
// input forwarding for the same session.
|
||
const renamedTab = {
|
||
...defaultTab,
|
||
title: "bash (connected)",
|
||
isActive: true,
|
||
};
|
||
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [renamedTab],
|
||
activeTab: renamedTab,
|
||
});
|
||
|
||
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
expect(disposeInputHandler).not.toHaveBeenCalled();
|
||
expect(mockTerminalInstance.onData).toHaveBeenCalledTimes(1);
|
||
|
||
act(() => {
|
||
terminalInputCallback?.("pwd\r");
|
||
});
|
||
|
||
expect(mockSendInput).toHaveBeenCalledWith("pwd\r");
|
||
});
|
||
|
||
it("keeps terminal input forwarding active after connection status transitions", async () => {
|
||
let terminalInputCallback: ((data: string) => void) | null = null;
|
||
const disposeInputHandler = vi.fn();
|
||
|
||
mockTerminalInstance.onData.mockImplementation((cb: (data: string) => void) => {
|
||
terminalInputCallback = cb;
|
||
return { dispose: disposeInputHandler };
|
||
});
|
||
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({ connectionStatus: "disconnected" }),
|
||
);
|
||
|
||
const { rerender } = render(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} />,
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(terminalInputCallback).not.toBeNull();
|
||
});
|
||
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({ connectionStatus: "connected" }),
|
||
);
|
||
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
expect(disposeInputHandler).not.toHaveBeenCalled();
|
||
expect(mockTerminalInstance.onData).toHaveBeenCalledTimes(1);
|
||
|
||
act(() => {
|
||
terminalInputCallback?.("ls\r");
|
||
});
|
||
|
||
expect(mockSendInput).toHaveBeenCalledWith("ls\r");
|
||
});
|
||
|
||
it("focuses xterm helper textarea on user pointer gesture", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||
});
|
||
|
||
const terminalDiv = screen.getByTestId("terminal-xterm");
|
||
const helperTextarea = document.createElement("textarea");
|
||
helperTextarea.className = "xterm-helper-textarea";
|
||
const focusSpy = vi.spyOn(helperTextarea, "focus");
|
||
const setSelectionRangeSpy = vi.spyOn(helperTextarea, "setSelectionRange");
|
||
terminalDiv.appendChild(helperTextarea);
|
||
|
||
fireEvent.pointerDown(terminalDiv);
|
||
|
||
expect(mockTerminalInstance.focus).toHaveBeenCalled();
|
||
expect(focusSpy).toHaveBeenCalled();
|
||
expect(setSelectionRangeSpy).toHaveBeenCalledWith(0, 0);
|
||
expect(helperTextarea.getAttribute("inputmode")).toBe("text");
|
||
});
|
||
|
||
it("focuses xterm helper textarea on touch gesture", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||
});
|
||
|
||
const terminalDiv = screen.getByTestId("terminal-xterm");
|
||
const helperTextarea = document.createElement("textarea");
|
||
helperTextarea.className = "xterm-helper-textarea";
|
||
const focusSpy = vi.spyOn(helperTextarea, "focus");
|
||
terminalDiv.appendChild(helperTextarea);
|
||
|
||
fireEvent.touchStart(terminalDiv);
|
||
|
||
expect(focusSpy).toHaveBeenCalled();
|
||
expect(helperTextarea.autocapitalize).toBe("off");
|
||
expect(helperTextarea.autocomplete).toBe("off");
|
||
expect(helperTextarea.autocorrect).toBe("off");
|
||
expect(helperTextarea.spellcheck).toBe(false);
|
||
});
|
||
|
||
// On touch-primary devices (iOS, Android), the CSS sizes the helper textarea
|
||
// to cover the whole terminal so iOS focuses it natively on tap. Re-focusing
|
||
// in the bubble-phase gesture handler disrupts iOS input-event attribution
|
||
// and causes typed keys to be silently dropped. The handler must be a no-op
|
||
// in that environment — see commit c7266b7f for prior iOS input fix context.
|
||
it("no-ops gesture focus handler on touch-primary devices", async () => {
|
||
const matchMediaSpy = vi
|
||
.spyOn(window, "matchMedia")
|
||
.mockImplementation((query: string) => ({
|
||
matches: query === "(hover: none) and (pointer: coarse)",
|
||
media: query,
|
||
onchange: null,
|
||
addListener: vi.fn(),
|
||
removeListener: vi.fn(),
|
||
addEventListener: vi.fn(),
|
||
removeEventListener: vi.fn(),
|
||
dispatchEvent: vi.fn(),
|
||
}));
|
||
|
||
try {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||
});
|
||
|
||
const terminalDiv = screen.getByTestId("terminal-xterm");
|
||
const helperTextarea = document.createElement("textarea");
|
||
helperTextarea.className = "xterm-helper-textarea";
|
||
const focusSpy = vi.spyOn(helperTextarea, "focus");
|
||
const setSelectionRangeSpy = vi.spyOn(helperTextarea, "setSelectionRange");
|
||
terminalDiv.appendChild(helperTextarea);
|
||
|
||
mockTerminalInstance.focus.mockClear();
|
||
fireEvent.touchStart(terminalDiv);
|
||
|
||
expect(mockTerminalInstance.focus).not.toHaveBeenCalled();
|
||
expect(focusSpy).not.toHaveBeenCalled();
|
||
expect(setSelectionRangeSpy).not.toHaveBeenCalled();
|
||
} finally {
|
||
matchMediaSpy.mockRestore();
|
||
}
|
||
});
|
||
});
|
||
|
||
// --- FN-1765: Project-context propagation ---
|
||
describe("TerminalModal — project-context propagation (FN-1765)", () => {
|
||
const mockOnClose = vi.fn();
|
||
const mockSendInput = vi.fn();
|
||
const mockResize = vi.fn();
|
||
const mockReconnect = vi.fn();
|
||
|
||
const createMockTerminalState = (overrides = {}) => ({
|
||
connectionStatus: "connected" as const,
|
||
sendInput: mockSendInput,
|
||
resize: mockResize,
|
||
onData: vi.fn(() => vi.fn()),
|
||
onExit: vi.fn(() => vi.fn()),
|
||
onConnect: vi.fn(() => vi.fn()),
|
||
onScrollback: vi.fn(() => vi.fn()),
|
||
reconnect: mockReconnect,
|
||
onSessionInvalid: vi.fn(() => vi.fn()),
|
||
...overrides,
|
||
});
|
||
|
||
const defaultTab = {
|
||
id: "tab-1",
|
||
sessionId: "session-1",
|
||
title: "bash",
|
||
isActive: true,
|
||
createdAt: Date.now(),
|
||
};
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
||
matches: false,
|
||
media: query,
|
||
onchange: null,
|
||
addListener: vi.fn(),
|
||
removeListener: vi.fn(),
|
||
addEventListener: vi.fn(),
|
||
removeEventListener: vi.fn(),
|
||
dispatchEvent: vi.fn(),
|
||
}));
|
||
mockTerminalInstance.open.mockClear();
|
||
mockTerminalInstance.dispose.mockClear();
|
||
mockTerminalInstance.clear.mockClear();
|
||
mockUseTerminal.mockReturnValue(createMockTerminalState());
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
tabs: [defaultTab],
|
||
activeTab: defaultTab,
|
||
isReady: true,
|
||
bootstrapError: null,
|
||
createTab: vi.fn(),
|
||
closeTab: vi.fn(),
|
||
setActiveTab: vi.fn(),
|
||
updateTabTitle: vi.fn(),
|
||
restartActiveTab: vi.fn(),
|
||
retryBootstrap: vi.fn(),
|
||
replaceActiveTabSession: vi.fn(),
|
||
});
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
it("passes projectId to useTerminal hook", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} projectId="proj-123" />);
|
||
|
||
await waitFor(() => {
|
||
expect(mockUseTerminal).toHaveBeenCalledWith("session-1", "proj-123");
|
||
});
|
||
});
|
||
|
||
it("passes undefined projectId to useTerminal when not provided", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => {
|
||
expect(mockUseTerminal).toHaveBeenCalledWith("session-1", undefined);
|
||
});
|
||
});
|
||
|
||
it("re-invokes useTerminal with new projectId when projectId prop changes", async () => {
|
||
const { rerender } = render(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} projectId="proj-A" />
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(mockUseTerminal).toHaveBeenCalledWith("session-1", "proj-A");
|
||
});
|
||
|
||
// Simulate project switch
|
||
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} projectId="proj-B" />);
|
||
|
||
await waitFor(() => {
|
||
// useTerminal should be called with the new projectId
|
||
expect(mockUseTerminal).toHaveBeenCalledWith(expect.any(String), "proj-B");
|
||
});
|
||
});
|
||
|
||
it("disposes xterm when projectId changes", async () => {
|
||
// Project A has session-1
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
tabs: [defaultTab],
|
||
activeTab: defaultTab,
|
||
isReady: true,
|
||
bootstrapError: null,
|
||
createTab: vi.fn(),
|
||
closeTab: vi.fn(),
|
||
setActiveTab: vi.fn(),
|
||
updateTabTitle: vi.fn(),
|
||
restartActiveTab: vi.fn(),
|
||
retryBootstrap: vi.fn(),
|
||
replaceActiveTabSession: vi.fn(),
|
||
});
|
||
|
||
const { rerender } = render(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} projectId="proj-A" />
|
||
);
|
||
|
||
// Wait for initial xterm to be created
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||
});
|
||
|
||
// Clear mock to track disposal separately
|
||
mockTerminalInstance.dispose.mockClear();
|
||
|
||
// Project B has a different session (simulating project-scoped sessions)
|
||
const projBSession = {
|
||
id: "tab-1",
|
||
sessionId: "session-2",
|
||
title: "zsh",
|
||
isActive: true,
|
||
createdAt: Date.now(),
|
||
};
|
||
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
tabs: [projBSession],
|
||
activeTab: projBSession,
|
||
isReady: true,
|
||
bootstrapError: null,
|
||
createTab: vi.fn(),
|
||
closeTab: vi.fn(),
|
||
setActiveTab: vi.fn(),
|
||
updateTabTitle: vi.fn(),
|
||
restartActiveTab: vi.fn(),
|
||
retryBootstrap: vi.fn(),
|
||
replaceActiveTabSession: vi.fn(),
|
||
});
|
||
|
||
// Switch project
|
||
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} projectId="proj-B" />);
|
||
|
||
// xterm should be disposed when project changes (different session triggers cleanup)
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.dispose).toHaveBeenCalled();
|
||
});
|
||
|
||
// New xterm should be initialized for the new project's session
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
it("uses fresh useTerminal session for new project after project switch", async () => {
|
||
// Initial project A
|
||
const { rerender } = render(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} projectId="proj-A" />
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(mockUseTerminal).toHaveBeenCalledWith("session-1", "proj-A");
|
||
});
|
||
|
||
// Simulate project B having different sessions
|
||
const projBTab = {
|
||
id: "tab-1",
|
||
sessionId: "session-2", // Different session for project B
|
||
title: "zsh",
|
||
isActive: true,
|
||
createdAt: Date.now(),
|
||
};
|
||
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
tabs: [projBTab],
|
||
activeTab: projBTab,
|
||
isReady: true,
|
||
bootstrapError: null,
|
||
createTab: vi.fn(),
|
||
closeTab: vi.fn(),
|
||
setActiveTab: vi.fn(),
|
||
updateTabTitle: vi.fn(),
|
||
restartActiveTab: vi.fn(),
|
||
retryBootstrap: vi.fn(),
|
||
replaceActiveTabSession: vi.fn(),
|
||
});
|
||
|
||
// Switch to project B
|
||
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} projectId="proj-B" />);
|
||
|
||
// useTerminal should be called with the new project's session
|
||
await waitFor(() => {
|
||
expect(mockUseTerminal).toHaveBeenCalledWith("session-2", "proj-B");
|
||
});
|
||
});
|
||
|
||
it("does not dispose xterm when projectId stays the same but session changes", async () => {
|
||
const { rerender } = render(
|
||
<TerminalModal isOpen={true} onClose={mockOnClose} projectId="proj-A" />
|
||
);
|
||
|
||
// Wait for initial xterm to be created
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||
});
|
||
|
||
// Clear mock to track disposal
|
||
mockTerminalInstance.dispose.mockClear();
|
||
|
||
// Create a new tab (session change, but same project)
|
||
const newTab = {
|
||
id: "tab-2",
|
||
sessionId: "session-2",
|
||
title: "zsh",
|
||
isActive: true,
|
||
createdAt: Date.now(),
|
||
};
|
||
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
tabs: [
|
||
{ ...defaultTab, isActive: false },
|
||
newTab,
|
||
],
|
||
activeTab: newTab,
|
||
isReady: true,
|
||
bootstrapError: null,
|
||
createTab: vi.fn(),
|
||
closeTab: vi.fn(),
|
||
setActiveTab: vi.fn(),
|
||
updateTabTitle: vi.fn(),
|
||
restartActiveTab: vi.fn(),
|
||
retryBootstrap: vi.fn(),
|
||
replaceActiveTabSession: vi.fn(),
|
||
});
|
||
|
||
// Switch tab (not project)
|
||
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} projectId="proj-A" />);
|
||
|
||
// xterm should be disposed for tab switch
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.dispose).toHaveBeenCalled();
|
||
});
|
||
|
||
// New xterm should be initialized for the new session
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||
});
|
||
});
|
||
});
|
||
|
||
/*
|
||
FNXC:Terminal 2026-07-04-09:20:
|
||
FN-7561 root cause: after FN-7456/FN-7460 disabled `text-size-adjust` and waited
|
||
for `document.fonts.ready`, both the initial xterm-init settle path and the live
|
||
preferences-apply settle path reapply `terminal.options.fontFamily`/`fontSize`
|
||
by assigning the ALREADY-RESOLVED value back onto the option. Real xterm's
|
||
OptionsService setter is a no-op when the new value strictly equals the current
|
||
value (no `onOptionChange` fires), so CharSizeService's canvas/DOM character
|
||
measurement and DomRenderer's `_setDefaultSpacing()` letter-spacing compensation
|
||
are never actually recomputed against the web font that only finished loading
|
||
AFTER xterm's initial (pre-load, fallback-font) measurement. The stale
|
||
pre-load cell metrics + compensation persist as visible excess inter-character
|
||
gaps until an unrelated event (resize/orientation/DPR change) happens to force
|
||
a genuine value change. This suite proves the app now forces a genuine
|
||
value-changing remeasure every time font metrics settle, not just a same-value
|
||
reassignment.
|
||
*/
|
||
describe("TerminalModal — FN-7561 mobile inter-character spacing (xterm no-op remeasure)", () => {
|
||
const mockOnClose = vi.fn();
|
||
const mockSendInput = vi.fn();
|
||
const mockResize = vi.fn();
|
||
const mockReconnect = vi.fn();
|
||
|
||
const createMockTerminalState = (overrides = {}) => ({
|
||
connectionStatus: "connected" as const,
|
||
sendInput: mockSendInput,
|
||
resize: mockResize,
|
||
onData: vi.fn(() => vi.fn()),
|
||
onExit: vi.fn(() => vi.fn()),
|
||
onConnect: vi.fn(() => vi.fn()),
|
||
onScrollback: vi.fn(() => vi.fn()),
|
||
reconnect: mockReconnect,
|
||
onSessionInvalid: vi.fn(() => vi.fn()),
|
||
...overrides,
|
||
});
|
||
|
||
let previousInnerWidth: number;
|
||
let previousOntouchstart: unknown;
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
resetFontRemeasureCount();
|
||
resetMockTerminalGeometry();
|
||
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
||
matches: false,
|
||
media: query,
|
||
onchange: null,
|
||
addListener: vi.fn(),
|
||
removeListener: vi.fn(),
|
||
addEventListener: vi.fn(),
|
||
removeEventListener: vi.fn(),
|
||
dispatchEvent: vi.fn(),
|
||
}));
|
||
previousInnerWidth = window.innerWidth;
|
||
previousOntouchstart = window.ontouchstart;
|
||
// Real reported device: a narrow touch-primary mobile viewport.
|
||
Object.defineProperty(window, "innerWidth", { value: 390, configurable: true });
|
||
Object.defineProperty(window, "ontouchstart", { value: null, configurable: true });
|
||
mockTerminalInstance.options.fontFamily = XTERM_FONT_FAMILY;
|
||
mockTerminalInstance.options.fontSize = 12;
|
||
mockTerminalInstance.options.cursorStyle = "block";
|
||
mockTerminalInstance.options.cursorBlink = true;
|
||
resetFontRemeasureCount();
|
||
resetMockTerminalGeometry();
|
||
mockUseTerminal.mockReturnValue(createMockTerminalState());
|
||
mockUseTerminalSessions.mockReturnValue(defaultSessionState);
|
||
mockUseWorkspaces.mockReturnValue({
|
||
projectName: "kb",
|
||
workspaces: [],
|
||
loading: false,
|
||
error: null,
|
||
});
|
||
mockCreateTerminalSession.mockResolvedValue({
|
||
sessionId: "test-session-123",
|
||
shell: "/bin/bash",
|
||
cwd: "/project",
|
||
});
|
||
});
|
||
|
||
afterEach(() => {
|
||
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
|
||
if (previousOntouchstart === undefined) {
|
||
delete (window as unknown as { ontouchstart?: unknown }).ontouchstart;
|
||
} else {
|
||
Object.defineProperty(window, "ontouchstart", { value: previousOntouchstart, configurable: true });
|
||
}
|
||
vi.useRealTimers();
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
it("forces a genuine xterm character-metric remeasure after the mobile web font settles later than xterm's initial measurement", async () => {
|
||
// Model the font not being ready yet at xterm construction (the real
|
||
// recurrence: the custom web font loads asynchronously, AFTER xterm's
|
||
// initial fallback-font character measurement), then settling shortly
|
||
// after. `waitForTerminalFontMetrics` awaits exactly this `load`/`ready`
|
||
// pair before reapplying font options.
|
||
let resolveLoad: (() => void) | undefined;
|
||
let resolveReady: (() => void) | undefined;
|
||
const load = vi.fn(
|
||
() =>
|
||
new Promise<void>((resolve) => {
|
||
resolveLoad = resolve;
|
||
}),
|
||
);
|
||
Object.defineProperty(document, "fonts", {
|
||
value: {
|
||
load,
|
||
ready: new Promise<void>((resolve) => {
|
||
resolveReady = resolve;
|
||
}),
|
||
},
|
||
configurable: true,
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
|
||
expectMeasurementSafeFontStack(mockTerminalInstance.options.fontFamily as string);
|
||
|
||
// Nothing else has changed the resolved font/size at this point, so any
|
||
// remeasure count observed so far merely reflects the initial synchronous
|
||
// application done during xterm construction/effect setup — reset it and
|
||
// isolate exactly what happens once the deferred font-load settles.
|
||
resetFontRemeasureCount();
|
||
resetMockTerminalGeometry();
|
||
|
||
await act(async () => {
|
||
resolveLoad?.();
|
||
resolveReady?.();
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
});
|
||
|
||
// The resolved fontFamily/fontSize the app wants after settle is identical
|
||
// to what was already applied before the font finished loading (the user
|
||
// never touched terminal preferences). A naive "reassign the resolved
|
||
// value" is therefore a no-op against real xterm's OptionsService
|
||
// (identical-value assignments never fire onOptionChange), so
|
||
// CharSizeService/DomRenderer would silently keep stale pre-load cell
|
||
// metrics forever. The fix must force at least one genuine (distinct
|
||
// value) fontFamily/fontSize transition here so xterm actually
|
||
// remeasures against the now-loaded font — this is the invariant
|
||
// FN-7456/FN-7460's `text-size-adjust`/`--keyboard-overlap`/`--vv-height`
|
||
// assertions never covered.
|
||
await waitFor(() => {
|
||
expect(getFontRemeasureCount()).toBeGreaterThan(0);
|
||
});
|
||
|
||
// The terminal must still land on the correct, symbols-free, resolved
|
||
// font after the forced remeasure settles.
|
||
expectMeasurementSafeFontStack(mockTerminalInstance.options.fontFamily as string);
|
||
expect(mockTerminalInstance.options.fontFamily).toBe(XTERM_FONT_FAMILY);
|
||
});
|
||
|
||
it("also forces the remeasure when the mobile keyboard is already open at initial render", async () => {
|
||
const mockVV = {
|
||
width: 375,
|
||
height: 300,
|
||
offsetTop: 0,
|
||
offsetLeft: 0,
|
||
addEventListener: vi.fn(),
|
||
removeEventListener: vi.fn(),
|
||
};
|
||
Object.defineProperty(window, "visualViewport", {
|
||
value: mockVV,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(window, "innerHeight", { value: 300, writable: true, configurable: true });
|
||
|
||
let resolveLoad: (() => void) | undefined;
|
||
let resolveReady: (() => void) | undefined;
|
||
Object.defineProperty(document, "fonts", {
|
||
value: {
|
||
load: vi.fn(
|
||
() =>
|
||
new Promise<void>((resolve) => {
|
||
resolveLoad = resolve;
|
||
}),
|
||
),
|
||
ready: new Promise<void>((resolve) => {
|
||
resolveReady = resolve;
|
||
}),
|
||
},
|
||
configurable: true,
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
|
||
resetFontRemeasureCount();
|
||
resetMockTerminalGeometry();
|
||
|
||
await act(async () => {
|
||
resolveLoad?.();
|
||
resolveReady?.();
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(getFontRemeasureCount()).toBeGreaterThan(0);
|
||
});
|
||
expectMeasurementSafeFontStack(mockTerminalInstance.options.fontFamily as string);
|
||
|
||
Object.defineProperty(window, "visualViewport", { value: undefined, writable: true, configurable: true });
|
||
});
|
||
});
|
||
|
||
/*
|
||
FNXC:Terminal 2026-07-04-11:20:
|
||
FN-7567 (recurrence #4 of mobile terminal inter-character spacing, after
|
||
FN-7456's DOM glyph-fallback fix, FN-7460's `text-size-adjust: none`, and
|
||
FN-7561's `forceTerminalFontRemeasure`) root cause: real xterm.js's
|
||
`DomRenderer._setDefaultSpacing()` bakes the letter-spacing compensation used
|
||
to correct rounding drift between the measured character width and the
|
||
computed cell width (`dimensions.css.cell.width`, which is itself derived from
|
||
the container width divided by the CURRENT column count). That bake only runs
|
||
from `handleCharSizeChanged()` (wired to `CharSizeService.onCharSizeChange`,
|
||
i.e. any genuine `fontFamily`/`fontSize` option transition) and from
|
||
`handleDevicePixelRatioChange()` \u2014 NEVER from `handleResize()`, which is what
|
||
`fitAddon.fit()` -> `terminal.resize(cols, rows)` triggers. Both mobile
|
||
settle sites (`remeasureAfterTerminalFontLoad` in TerminalModal.tsx and its
|
||
SessionTerminal.tsx sibling, plus the live-preferences-apply settle path in
|
||
both files) call `forceTerminalFontRemeasure()` \u2014 which correctly forces a
|
||
genuine option transition and DOES bake letter-spacing \u2014 but they bake it
|
||
using the STALE column count that predates `fitAddon.fit()`. `fitAddon.fit()`
|
||
then changes the column count (and therefore the true cell width) but never
|
||
re-bakes the letter-spacing, so the terminal keeps rendering with a spacing
|
||
value computed against a column count that no longer matches reality. This
|
||
produces genuinely excessive inter-character gaps that persist until an
|
||
unrelated later event (device-pixel-ratio change, orientation, reconnect)
|
||
happens to force another *genuine* option/DPR-triggered remeasure \u2014 exactly
|
||
matching the "only repairs itself after an incidental refit" report. See
|
||
`docs/solutions/ui-bugs/xterm-options-noop-remeasure-after-font-settle.md`
|
||
recurrence-#4 addendum.
|
||
|
||
This suite asserts the actual rendered geometry invariant (baked letter-spacing
|
||
== 0, i.e. cell width matches the settled glyph advance width) using a
|
||
xterm-internals-accurate model (`mockHandleCharSizeChanged`/`mockFitAddonFit`),
|
||
not a re-assertion of the FN-7456/FN-7460/FN-7561 CSS-property/call-count
|
||
checks. It fails on pre-fix code (stale pre-fit letter-spacing survives the
|
||
settle) and passes once the fix re-bakes spacing AFTER `fitAddon.fit()`
|
||
settles the column count.
|
||
*/
|
||
describe("TerminalModal — FN-7567 mobile inter-character spacing (stale post-fit letter-spacing bake)", () => {
|
||
const mockOnClose = vi.fn();
|
||
const mockSendInput = vi.fn();
|
||
const mockResize = vi.fn();
|
||
const mockReconnect = vi.fn();
|
||
|
||
const createMockTerminalState = (overrides = {}) => ({
|
||
connectionStatus: "connected" as const,
|
||
sendInput: mockSendInput,
|
||
resize: mockResize,
|
||
onData: vi.fn(() => vi.fn()),
|
||
onExit: vi.fn(() => vi.fn()),
|
||
onConnect: vi.fn(() => vi.fn()),
|
||
onScrollback: vi.fn(() => vi.fn()),
|
||
reconnect: mockReconnect,
|
||
onSessionInvalid: vi.fn(() => vi.fn()),
|
||
...overrides,
|
||
});
|
||
|
||
let previousInnerWidth: number;
|
||
let previousOntouchstart: unknown;
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
resetFontRemeasureCount();
|
||
resetMockTerminalGeometry();
|
||
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
||
matches: false,
|
||
media: query,
|
||
onchange: null,
|
||
addListener: vi.fn(),
|
||
removeListener: vi.fn(),
|
||
addEventListener: vi.fn(),
|
||
removeEventListener: vi.fn(),
|
||
dispatchEvent: vi.fn(),
|
||
}));
|
||
previousInnerWidth = window.innerWidth;
|
||
previousOntouchstart = window.ontouchstart;
|
||
// Real reported device: a narrow touch-primary mobile viewport.
|
||
Object.defineProperty(window, "innerWidth", { value: 390, configurable: true });
|
||
Object.defineProperty(window, "ontouchstart", { value: null, configurable: true });
|
||
mockTerminalInstance.options.fontFamily = XTERM_FONT_FAMILY;
|
||
mockTerminalInstance.options.fontSize = 12;
|
||
mockTerminalInstance.options.cursorStyle = "block";
|
||
mockTerminalInstance.options.cursorBlink = true;
|
||
resetFontRemeasureCount();
|
||
resetMockTerminalGeometry();
|
||
mockUseTerminal.mockReturnValue(createMockTerminalState());
|
||
mockUseTerminalSessions.mockReturnValue(defaultSessionState);
|
||
mockUseWorkspaces.mockReturnValue({
|
||
projectName: "kb",
|
||
workspaces: [],
|
||
loading: false,
|
||
error: null,
|
||
});
|
||
mockCreateTerminalSession.mockResolvedValue({
|
||
sessionId: "test-session-123",
|
||
shell: "/bin/bash",
|
||
cwd: "/project",
|
||
});
|
||
});
|
||
|
||
afterEach(() => {
|
||
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
|
||
if (previousOntouchstart === undefined) {
|
||
delete (window as unknown as { ontouchstart?: unknown }).ontouchstart;
|
||
} else {
|
||
Object.defineProperty(window, "ontouchstart", { value: previousOntouchstart, configurable: true });
|
||
}
|
||
vi.useRealTimers();
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
it("renders contiguous monospace cells (zero baked letter-spacing) once the mobile web font settles after xterm's initial fit", async () => {
|
||
let resolveLoad: (() => void) | undefined;
|
||
let resolveReady: (() => void) | undefined;
|
||
Object.defineProperty(document, "fonts", {
|
||
value: {
|
||
load: vi.fn(
|
||
() =>
|
||
new Promise<void>((resolve) => {
|
||
resolveLoad = resolve;
|
||
}),
|
||
),
|
||
ready: new Promise<void>((resolve) => {
|
||
resolveReady = resolve;
|
||
}),
|
||
},
|
||
configurable: true,
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
|
||
resetFontRemeasureCount();
|
||
|
||
// Simulate the real recurrence: the custom web font finishes downloading
|
||
// AFTER xterm's initial fallback-font measurement/fit already ran and
|
||
// baked a letter-spacing value that was internally consistent for the
|
||
// FALLBACK font at that (stale) column count.
|
||
settleMockTerminalFontForCharSize();
|
||
|
||
await act(async () => {
|
||
resolveLoad?.();
|
||
resolveReady?.();
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(getFontRemeasureCount()).toBeGreaterThan(0);
|
||
});
|
||
|
||
// The measured geometry invariant: once font metrics settle and xterm
|
||
// refits to the correct column count for the SETTLED font, the baked
|
||
// letter-spacing compensation must be recomputed against that FINAL
|
||
// column count, not the stale pre-fit one. A nonzero value here means
|
||
// rendered cells are wider (or narrower) than the glyph advance width \u2014
|
||
// exactly the reported "characters spread across cells" symptom \u2014 and
|
||
// this assertion fails on pre-fix code, which bakes spacing only BEFORE
|
||
// `fitAddon.fit()` runs and never re-bakes it afterward.
|
||
await waitFor(() => {
|
||
expect(getMockBakedLetterSpacingPx()).toBeCloseTo(0, 5);
|
||
});
|
||
});
|
||
|
||
it("also settles to zero baked letter-spacing at persisted 10px with the mobile keyboard already open at initial render", async () => {
|
||
window.localStorage.setItem(TERMINAL_FONT_SIZE_KEY, "10");
|
||
|
||
const mockVV = {
|
||
width: 375,
|
||
height: 300,
|
||
offsetTop: 0,
|
||
offsetLeft: 0,
|
||
addEventListener: vi.fn(),
|
||
removeEventListener: vi.fn(),
|
||
};
|
||
Object.defineProperty(window, "visualViewport", {
|
||
value: mockVV,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(window, "innerHeight", { value: 300, writable: true, configurable: true });
|
||
|
||
let resolveLoad: (() => void) | undefined;
|
||
let resolveReady: (() => void) | undefined;
|
||
Object.defineProperty(document, "fonts", {
|
||
value: {
|
||
load: vi.fn(
|
||
() =>
|
||
new Promise<void>((resolve) => {
|
||
resolveLoad = resolve;
|
||
}),
|
||
),
|
||
ready: new Promise<void>((resolve) => {
|
||
resolveReady = resolve;
|
||
}),
|
||
},
|
||
configurable: true,
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
|
||
resetFontRemeasureCount();
|
||
settleMockTerminalFontForCharSize();
|
||
|
||
await act(async () => {
|
||
resolveLoad?.();
|
||
resolveReady?.();
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(getFontRemeasureCount()).toBeGreaterThan(0);
|
||
});
|
||
|
||
// Same measured geometry invariant, but with the keyboard already
|
||
// constraining the viewport at initial render and a persisted 10px font —
|
||
// the exact FN-7460 screenshot conditions — to prove the fix is not
|
||
// accidentally scoped to only the default font-size/no-keyboard case.
|
||
await waitFor(() => {
|
||
expect(getMockBakedLetterSpacingPx()).toBeCloseTo(0, 5);
|
||
});
|
||
|
||
Object.defineProperty(window, "visualViewport", { value: undefined, writable: true, configurable: true });
|
||
});
|
||
|
||
it("also settles to zero baked letter-spacing when a live font-size preference change resettles after fit", async () => {
|
||
Object.defineProperty(document, "fonts", {
|
||
value: {
|
||
load: vi.fn(() => Promise.resolve()),
|
||
ready: Promise.resolve(),
|
||
},
|
||
configurable: true,
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
|
||
|
||
// Let the initial-open settle path finish and reach a consistent baseline.
|
||
await act(async () => {
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
});
|
||
settleMockTerminalFontForCharSize();
|
||
resetFontRemeasureCount();
|
||
|
||
fireEvent.click(await screen.findByTestId("terminal-font-size-increase"));
|
||
|
||
await waitFor(() => {
|
||
expect(getFontRemeasureCount()).toBeGreaterThan(0);
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(getMockBakedLetterSpacingPx()).toBeCloseTo(0, 5);
|
||
});
|
||
});
|
||
});
|
||
|
||
/*
|
||
FNXC:Terminal 2026-07-05-12:50:
|
||
FN-7603 (recurrence #5 of mobile terminal inter-character spacing, after
|
||
FN-7456's DOM glyph-fallback fix, FN-7460's `text-size-adjust: none`,
|
||
FN-7561's `forceTerminalFontRemeasure`, and FN-7567's post-fit re-bake) root
|
||
cause, grounded against the installed `@xterm/xterm@5.5.0` source (see task
|
||
document key="xterm-source-audit" on FN-7603): xterm's `CharSizeService`
|
||
selects ONE of two independent measurement strategies the moment
|
||
`terminal.open()` runs — a Canvas/`OffscreenCanvas` strategy (chosen whenever
|
||
`OffscreenCanvas` + the required `TextMetrics` fields are available, i.e.
|
||
virtually every real modern mobile browser) or a DOM fallback strategy (only
|
||
selected if the canvas strategy's constructor throws). `dimensions.css.cell.width`
|
||
(which feeds `FitAddon.fit()`'s column count AND `DomRenderer.
|
||
_setDefaultSpacing()`'s baked letter-spacing) derives from whichever strategy
|
||
CharSizeService picked. Separately, `WidthCache` (used by both
|
||
`_setDefaultSpacing()` and `DomRendererRowFactory`'s per-glyph override) is
|
||
ALWAYS DOM-based. Real glyphs are painted 100% through the DOM, so
|
||
`_setDefaultSpacing()`'s `cell.width - widthCache.get('W')` formula only
|
||
converges to zero — i.e. tight, contiguous monospace cells — when BOTH
|
||
operands are measured through the SAME pipeline. Canvas 2D text measurement
|
||
and DOM/CSS text layout are two different browser rendering pipelines that can
|
||
disagree by a small but visible amount for the same font on the same device —
|
||
a divergence none of FN-7456/FN-7460/FN-7561/FN-7567 (or their test doubles)
|
||
ever modeled, because all four assumed a single unified character-width
|
||
measurement. This is why the reported symptom survived every prior remedy:
|
||
none of them touched WHICH measurement pipeline xterm's cell geometry is
|
||
computed from, only WHEN it recomputes.
|
||
|
||
The fix (`withDomBasedTerminalCharacterMeasurement` in terminalPreferences.ts)
|
||
makes `window.OffscreenCanvas` transiently unavailable for the synchronous
|
||
duration of `terminal.open()`, forcing `CharSizeService`'s constructor
|
||
try-block to throw and self-select its own DOM fallback strategy — unifying
|
||
`dimensions.css.cell.width` and `WidthCache.get('W')` onto the SAME
|
||
measurement pipeline instead of adding any hardcoded letter-spacing
|
||
compensation.
|
||
|
||
This suite extends the FN-7567 geometry-accurate mock
|
||
(`mockHandleCharSizeChanged`/`mockFitAddonFit`) to model the Canvas-vs-DOM
|
||
divergence explicitly (`mockCanvasCharWidthPx` vs `mockDomCharWidthPx`, gated
|
||
on `window.OffscreenCanvas` availability observed at the mock's `open()` call
|
||
— exactly mirroring the real CharSizeService constructor's strategy
|
||
selection), which the FN-7567 double could not represent (it modeled both
|
||
measurements as a single shared value). It fails on pre-fix code — where
|
||
`window.OffscreenCanvas` stays available throughout `open()`, so the mock
|
||
selects its "Canvas strategy" and the baked letter-spacing settles to a
|
||
persistent NONZERO value even after the full settle + pre/post-fit remeasure
|
||
sequence FN-7561/FN-7567 added — and passes once the fix hides
|
||
`OffscreenCanvas` around `open()`, converging the bake to exactly zero.
|
||
See `docs/solutions/ui-bugs/xterm-options-noop-remeasure-after-font-settle.md`
|
||
recurrence-#5 section.
|
||
*/
|
||
describe("TerminalModal — FN-7603 mobile inter-character spacing (Canvas vs DOM CharSizeService measurement divergence)", () => {
|
||
const mockOnClose = vi.fn();
|
||
const mockSendInput = vi.fn();
|
||
const mockResize = vi.fn();
|
||
const mockReconnect = vi.fn();
|
||
|
||
const createMockTerminalState = (overrides = {}) => ({
|
||
connectionStatus: "connected" as const,
|
||
sendInput: mockSendInput,
|
||
resize: mockResize,
|
||
onData: vi.fn(() => vi.fn()),
|
||
onExit: vi.fn(() => vi.fn()),
|
||
onConnect: vi.fn(() => vi.fn()),
|
||
onScrollback: vi.fn(() => vi.fn()),
|
||
reconnect: mockReconnect,
|
||
onSessionInvalid: vi.fn(() => vi.fn()),
|
||
...overrides,
|
||
});
|
||
|
||
let previousInnerWidth: number;
|
||
let previousOntouchstart: unknown;
|
||
let previousOffscreenCanvas: unknown;
|
||
let hadOwnOffscreenCanvas: boolean;
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
resetFontRemeasureCount();
|
||
resetMockTerminalGeometry();
|
||
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
||
matches: false,
|
||
media: query,
|
||
onchange: null,
|
||
addListener: vi.fn(),
|
||
removeListener: vi.fn(),
|
||
addEventListener: vi.fn(),
|
||
removeEventListener: vi.fn(),
|
||
dispatchEvent: vi.fn(),
|
||
}));
|
||
previousInnerWidth = window.innerWidth;
|
||
previousOntouchstart = window.ontouchstart;
|
||
hadOwnOffscreenCanvas = Object.prototype.hasOwnProperty.call(window, "OffscreenCanvas");
|
||
previousOffscreenCanvas = (window as unknown as { OffscreenCanvas?: unknown }).OffscreenCanvas;
|
||
// Real reported device: a narrow touch-primary mobile viewport with a
|
||
// modern engine that supports OffscreenCanvas (true on essentially every
|
||
// real mobile Safari/Chrome), so xterm's CharSizeService would pick its
|
||
// Canvas measurement strategy absent the fix.
|
||
Object.defineProperty(window, "innerWidth", { value: 390, configurable: true });
|
||
Object.defineProperty(window, "ontouchstart", { value: null, configurable: true });
|
||
Object.defineProperty(window, "OffscreenCanvas", {
|
||
value: class MockOffscreenCanvas {},
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
window.localStorage.removeItem(TERMINAL_FONT_SIZE_KEY);
|
||
window.localStorage.removeItem(TERMINAL_PREFERENCES_KEY);
|
||
mockTerminalInstance.options.fontFamily = XTERM_FONT_FAMILY;
|
||
mockTerminalInstance.options.fontSize = 12;
|
||
mockTerminalInstance.options.cursorStyle = "block";
|
||
mockTerminalInstance.options.cursorBlink = true;
|
||
resetFontRemeasureCount();
|
||
resetMockTerminalGeometry();
|
||
mockUseTerminal.mockReturnValue(createMockTerminalState());
|
||
mockUseTerminalSessions.mockReturnValue(defaultSessionState);
|
||
mockUseWorkspaces.mockReturnValue({
|
||
projectName: "kb",
|
||
workspaces: [],
|
||
loading: false,
|
||
error: null,
|
||
});
|
||
mockCreateTerminalSession.mockResolvedValue({
|
||
sessionId: "test-session-123",
|
||
shell: "/bin/bash",
|
||
cwd: "/project",
|
||
});
|
||
});
|
||
|
||
afterEach(() => {
|
||
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
|
||
if (previousOntouchstart === undefined) {
|
||
delete (window as unknown as { ontouchstart?: unknown }).ontouchstart;
|
||
} else {
|
||
Object.defineProperty(window, "ontouchstart", { value: previousOntouchstart, configurable: true });
|
||
}
|
||
if (hadOwnOffscreenCanvas) {
|
||
Object.defineProperty(window, "OffscreenCanvas", {
|
||
value: previousOffscreenCanvas,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
} else {
|
||
delete (window as unknown as { OffscreenCanvas?: unknown }).OffscreenCanvas;
|
||
}
|
||
vi.useRealTimers();
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
it("converges baked letter-spacing to exactly zero by forcing xterm off its Canvas character-measurement strategy during open()", async () => {
|
||
let resolveLoad: (() => void) | undefined;
|
||
let resolveReady: (() => void) | undefined;
|
||
Object.defineProperty(document, "fonts", {
|
||
value: {
|
||
load: vi.fn(
|
||
() =>
|
||
new Promise<void>((resolve) => {
|
||
resolveLoad = resolve;
|
||
}),
|
||
),
|
||
ready: new Promise<void>((resolve) => {
|
||
resolveReady = resolve;
|
||
}),
|
||
},
|
||
configurable: true,
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
|
||
resetFontRemeasureCount();
|
||
|
||
settleMockTerminalFontForCharSize();
|
||
|
||
await act(async () => {
|
||
resolveLoad?.();
|
||
resolveReady?.();
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(getFontRemeasureCount()).toBeGreaterThan(0);
|
||
});
|
||
|
||
/*
|
||
* The decisive geometry assertion for this recurrence: on pre-fix code,
|
||
* `window.OffscreenCanvas` stays defined throughout `terminal.open()`, so
|
||
* the mock's CharSizeService strategy selects "Canvas" and
|
||
* `mockCanvasCharWidthPx` diverges from `mockDomCharWidthPx` by
|
||
* `MOCK_CANVAS_DOM_DIVERGENCE_PX`. Even after the full FN-7561/FN-7567
|
||
* settle + pre/post-fit remeasure sequence runs to completion, the baked
|
||
* letter-spacing does NOT converge to zero — it settles at a persistent,
|
||
* nonzero residual driven purely by the Canvas-vs-DOM measurement
|
||
* mismatch, exactly matching "still spaced apart even after every prior
|
||
* fix ran correctly". This assertion fails on HEAD before this task's fix
|
||
* and passes once `withDomBasedTerminalCharacterMeasurement` hides
|
||
* `OffscreenCanvas` around `open()`.
|
||
*/
|
||
await waitFor(() => {
|
||
expect(getMockBakedLetterSpacingPx()).toBeCloseTo(0, 5);
|
||
});
|
||
});
|
||
|
||
it("also converges to zero with the mobile keyboard already open and a persisted 10px font", async () => {
|
||
window.localStorage.setItem(TERMINAL_FONT_SIZE_KEY, "10");
|
||
|
||
const mockVV = {
|
||
width: 375,
|
||
height: 300,
|
||
offsetTop: 0,
|
||
offsetLeft: 0,
|
||
addEventListener: vi.fn(),
|
||
removeEventListener: vi.fn(),
|
||
};
|
||
Object.defineProperty(window, "visualViewport", {
|
||
value: mockVV,
|
||
writable: true,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(window, "innerHeight", { value: 300, writable: true, configurable: true });
|
||
|
||
let resolveLoad: (() => void) | undefined;
|
||
let resolveReady: (() => void) | undefined;
|
||
Object.defineProperty(document, "fonts", {
|
||
value: {
|
||
load: vi.fn(
|
||
() =>
|
||
new Promise<void>((resolve) => {
|
||
resolveLoad = resolve;
|
||
}),
|
||
),
|
||
ready: new Promise<void>((resolve) => {
|
||
resolveReady = resolve;
|
||
}),
|
||
},
|
||
configurable: true,
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
|
||
resetFontRemeasureCount();
|
||
settleMockTerminalFontForCharSize();
|
||
|
||
await act(async () => {
|
||
resolveLoad?.();
|
||
resolveReady?.();
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(getFontRemeasureCount()).toBeGreaterThan(0);
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(getMockBakedLetterSpacingPx()).toBeCloseTo(0, 5);
|
||
});
|
||
|
||
Object.defineProperty(window, "visualViewport", { value: undefined, writable: true, configurable: true });
|
||
});
|
||
|
||
it("does not regress when the real browser has no OffscreenCanvas support (xterm already self-selects the DOM strategy)", async () => {
|
||
window.localStorage.removeItem(TERMINAL_FONT_SIZE_KEY);
|
||
delete (window as unknown as { OffscreenCanvas?: unknown }).OffscreenCanvas;
|
||
settleMockTerminalFontForCharSize();
|
||
|
||
Object.defineProperty(document, "fonts", {
|
||
value: {
|
||
load: vi.fn(() => Promise.resolve()),
|
||
ready: Promise.resolve(),
|
||
},
|
||
configurable: true,
|
||
});
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
|
||
|
||
await act(async () => {
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(getMockBakedLetterSpacingPx()).toBeCloseTo(0, 5);
|
||
});
|
||
});
|
||
});
|
||
|
||
/*
|
||
FNXC:Terminal 2026-07-06-09:15:
|
||
FN-7620: "terminal renders nothing on mobile" is a DIFFERENT symptom than the
|
||
FN-7456->FN-7603 inter-character-spacing family above — a total blank render,
|
||
not visible-but-spaced ASCII. Root cause: real `FitAddon.proposeDimensions()`
|
||
(@xterm/addon-fit@0.10.0) reads `getComputedStyle(terminal.element.
|
||
parentElement)` height/width and floors to a degenerate `{cols: 2, rows: 1}`
|
||
grid — rather than bailing out — whenever that resolves to a zero box (e.g.
|
||
the mobile fullscreen/keyboard-overlap CSS height cascade has not settled by
|
||
the time the first post-open `fitAddon.fit()` runs). Only the WHOLE MODAL
|
||
(`modalRef`) had a ResizeObserver; nothing watched the xterm CONTAINER
|
||
(`terminalRef`) itself, so if the container's OWN box was still zero at the
|
||
single scheduled deferred-fit check, no later trigger ever re-measured it —
|
||
the terminal stayed a near-invisible 2x1 grid clipped inside a genuinely
|
||
zero-height box. This models that exact mechanism: `mockFitAddonFit`'s
|
||
implementation is temporarily swapped for a variant that reads the REAL
|
||
container's clientWidth/clientHeight (mirroring the real addon's
|
||
`getComputedStyle(...).width/height` read) instead of the shared fixed-width
|
||
constant the spacing-family tests above use, and a captured
|
||
`new ResizeObserver(cb)` lets the test fire the SAME notification a real
|
||
browser would deliver the instant the container's box changes size — without
|
||
calling any keyboard-toggle/orientation/reconnect/manual-refit production
|
||
handler. See docs/solutions/ui-bugs/mobile-terminal-blank-render-zero-geometry-container.md.
|
||
*/
|
||
describe("TerminalModal — FN-7620 mobile blank render (zero-geometry xterm container)", () => {
|
||
const mockOnClose = vi.fn();
|
||
const mockSendInput = vi.fn();
|
||
const mockResize = vi.fn();
|
||
const mockReconnect = vi.fn();
|
||
|
||
const createMockTerminalState = (overrides = {}) => ({
|
||
connectionStatus: "connected" as const,
|
||
sendInput: mockSendInput,
|
||
resize: mockResize,
|
||
onData: vi.fn(() => vi.fn()),
|
||
onExit: vi.fn(() => vi.fn()),
|
||
onConnect: vi.fn(() => vi.fn()),
|
||
onScrollback: vi.fn(() => vi.fn()),
|
||
reconnect: mockReconnect,
|
||
onSessionInvalid: vi.fn(() => vi.fn()),
|
||
...overrides,
|
||
});
|
||
|
||
type CapturedResizeObserverEntry = { target: Element; callback: ResizeObserverCallback };
|
||
let capturedResizeObservers: CapturedResizeObserverEntry[] = [];
|
||
|
||
class MockResizeObserver {
|
||
private readonly callback: ResizeObserverCallback;
|
||
constructor(callback: ResizeObserverCallback) {
|
||
this.callback = callback;
|
||
}
|
||
observe(target: Element): void {
|
||
capturedResizeObservers.push({ target, callback: this.callback });
|
||
}
|
||
unobserve(): void {}
|
||
disconnect(): void {}
|
||
}
|
||
|
||
/**
|
||
* Fires the captured ResizeObserver callback(s) for the element with the
|
||
* given `data-testid`, mirroring exactly what a real browser delivers when
|
||
* that element's own box changes size. Returns false if no ResizeObserver
|
||
* ever observed that element (the pre-fix state for "terminal-xterm").
|
||
*/
|
||
function fireResizeObserverFor(testId: string): boolean {
|
||
const target = screen.getByTestId(testId);
|
||
const matches = capturedResizeObservers.filter((entry) => entry.target === target);
|
||
if (matches.length === 0) return false;
|
||
for (const entry of matches) {
|
||
entry.callback(
|
||
[] as unknown as ResizeObserverEntry[],
|
||
entry as unknown as ResizeObserver,
|
||
);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// Mirrors real FitAddon.proposeDimensions()'s degenerate-floor formula
|
||
// (`Math.max(2, floor(width/cellWidth))` / `Math.max(1, floor(height/
|
||
// cellHeight))`) against the REAL xterm container element's clientWidth/
|
||
// clientHeight — unlike the shared fixed-width mock the spacing-family
|
||
// tests above use, this is what lets the test distinguish "the container is
|
||
// still a zero/degenerate box" from "the container has settled to a real,
|
||
// usable box".
|
||
const CHAR_WIDTH_PX = 9;
|
||
const CHAR_HEIGHT_PX = 17;
|
||
const geometryAwareFitAddonFit = vi.fn(() => {
|
||
const container = screen.queryByTestId("terminal-xterm");
|
||
const width = container?.clientWidth ?? 0;
|
||
const height = container?.clientHeight ?? 0;
|
||
mockTerminalInstance.cols = Math.max(2, Math.floor(width / CHAR_WIDTH_PX));
|
||
mockTerminalInstance.rows = Math.max(1, Math.floor(height / CHAR_HEIGHT_PX));
|
||
});
|
||
|
||
const originalMockFitAddonFitImpl = mockFitAddonFit.getMockImplementation();
|
||
let previousInnerWidth: number;
|
||
let previousOntouchstart: unknown;
|
||
let originalWindowResizeObserver: unknown;
|
||
let originalGlobalResizeObserver: unknown;
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
resetMockTerminalGeometry();
|
||
resetFontRemeasureCount();
|
||
capturedResizeObservers = [];
|
||
mockFitAddonFit.mockImplementation(geometryAwareFitAddonFit);
|
||
|
||
originalWindowResizeObserver = (window as unknown as { ResizeObserver?: unknown }).ResizeObserver;
|
||
originalGlobalResizeObserver = (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver;
|
||
(window as unknown as { ResizeObserver: unknown }).ResizeObserver = MockResizeObserver;
|
||
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = MockResizeObserver;
|
||
|
||
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
||
matches: false,
|
||
media: query,
|
||
onchange: null,
|
||
addListener: vi.fn(),
|
||
removeListener: vi.fn(),
|
||
addEventListener: vi.fn(),
|
||
removeEventListener: vi.fn(),
|
||
dispatchEvent: vi.fn(),
|
||
}));
|
||
previousInnerWidth = window.innerWidth;
|
||
previousOntouchstart = window.ontouchstart;
|
||
// Real reported device: a narrow touch-primary mobile viewport.
|
||
Object.defineProperty(window, "innerWidth", { value: 390, configurable: true });
|
||
Object.defineProperty(window, "ontouchstart", { value: null, configurable: true });
|
||
|
||
window.localStorage.removeItem(TERMINAL_FONT_SIZE_KEY);
|
||
window.localStorage.removeItem(TERMINAL_PREFERENCES_KEY);
|
||
mockTerminalInstance.options.fontFamily = XTERM_FONT_FAMILY;
|
||
mockTerminalInstance.options.fontSize = 14;
|
||
mockTerminalInstance.cols = 80;
|
||
mockTerminalInstance.rows = 24;
|
||
|
||
mockUseTerminal.mockReturnValue(createMockTerminalState());
|
||
mockUseTerminalSessions.mockReturnValue(defaultSessionState);
|
||
mockUseWorkspaces.mockReturnValue({
|
||
projectName: "kb",
|
||
workspaces: [],
|
||
loading: false,
|
||
error: null,
|
||
});
|
||
mockCreateTerminalSession.mockResolvedValue({
|
||
sessionId: "test-session-123",
|
||
shell: "/bin/bash",
|
||
cwd: "/project",
|
||
});
|
||
});
|
||
|
||
afterEach(() => {
|
||
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
|
||
if (previousOntouchstart === undefined) {
|
||
delete (window as unknown as { ontouchstart?: unknown }).ontouchstart;
|
||
} else {
|
||
Object.defineProperty(window, "ontouchstart", { value: previousOntouchstart, configurable: true });
|
||
}
|
||
if (originalWindowResizeObserver) {
|
||
(window as unknown as { ResizeObserver: unknown }).ResizeObserver = originalWindowResizeObserver;
|
||
} else {
|
||
delete (window as unknown as { ResizeObserver?: unknown }).ResizeObserver;
|
||
}
|
||
if (originalGlobalResizeObserver) {
|
||
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = originalGlobalResizeObserver;
|
||
} else {
|
||
delete (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver;
|
||
}
|
||
mockFitAddonFit.mockImplementation(originalMockFitAddonFitImpl ?? (() => {}));
|
||
Object.defineProperty(window, "visualViewport", { value: undefined, writable: true, configurable: true });
|
||
vi.useRealTimers();
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
function overrideContainerBox(container: HTMLElement, box: { width: number; height: number }): void {
|
||
Object.defineProperty(container, "clientWidth", { configurable: true, get: () => box.width });
|
||
Object.defineProperty(container, "clientHeight", { configurable: true, get: () => box.height });
|
||
}
|
||
|
||
it("recovers from a zero-geometry xterm container on the INITIAL mobile layout with the keyboard CLOSED — no reconnect/orientation/refit", async () => {
|
||
let capturedDataCallback: ((data: string) => void) | null = null;
|
||
let capturedScrollbackCallback: ((data: string) => void) | null = null;
|
||
mockUseTerminal.mockReturnValue(
|
||
createMockTerminalState({
|
||
onData: vi.fn((cb: (data: string) => void) => {
|
||
capturedDataCallback = cb;
|
||
return vi.fn();
|
||
}),
|
||
onScrollback: vi.fn((cb: (data: string) => void) => {
|
||
capturedScrollbackCallback = cb;
|
||
return vi.fn();
|
||
}),
|
||
}),
|
||
);
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
|
||
|
||
const container = screen.getByTestId("terminal-xterm");
|
||
const box = { width: 0, height: 0 };
|
||
overrideContainerBox(container, box);
|
||
|
||
// Let the initial fit()+deferred-refit sequence run against a genuinely
|
||
// zero container box — the real-device race this task fixes.
|
||
await act(async () => {
|
||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||
await Promise.resolve();
|
||
});
|
||
|
||
// Pre-condition: with the container still reporting 0x0, xterm has
|
||
// collapsed to FitAddon's degenerate floor — exactly the "nothing
|
||
// renders" mechanism (a 2x1 grid clipped inside a 0px box).
|
||
expect(mockTerminalInstance.cols).toBe(2);
|
||
expect(mockTerminalInstance.rows).toBe(1);
|
||
|
||
// The real device settles the container to its actual, stable box a
|
||
// moment later — no user action, CSS/layout/font settle only.
|
||
box.width = 360;
|
||
box.height = 480;
|
||
|
||
// Decisive step: fire the SAME notification a real browser's
|
||
// ResizeObserver would deliver for THIS container the instant its box
|
||
// changed — not a manual fitAddon.fit()/reconnect/keyboard-toggle call.
|
||
// Pre-fix, no ResizeObserver ever observes this container (only the
|
||
// whole modal), so this assertion fails outright.
|
||
const observed = fireResizeObserverFor("terminal-xterm");
|
||
expect(observed).toBe(true);
|
||
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.cols).toBeGreaterThan(2);
|
||
expect(mockTerminalInstance.rows).toBeGreaterThan(1);
|
||
});
|
||
|
||
// Rendered-state invariant: a non-zero, stable measured box AND real
|
||
// (non-degenerate) rows/cols — not merely that fit()/open() was
|
||
// attempted.
|
||
expect(container.clientWidth).toBe(360);
|
||
expect(container.clientHeight).toBe(480);
|
||
expect(mockTerminalInstance.cols).toBe(Math.max(2, Math.floor(360 / CHAR_WIDTH_PX)));
|
||
expect(mockTerminalInstance.rows).toBe(Math.max(1, Math.floor(480 / CHAR_HEIGHT_PX)));
|
||
|
||
// Output/prompt actually renders (write() receives real data), not just
|
||
// an init attempt.
|
||
mockTerminalInstance.write.mockClear();
|
||
act(() => {
|
||
capturedScrollbackCallback?.("user@host:~$ ");
|
||
capturedDataCallback?.("ls\r\n");
|
||
});
|
||
expect(mockTerminalInstance.write).toHaveBeenCalledWith("user@host:~$ ");
|
||
expect(mockTerminalInstance.write).toHaveBeenCalledWith("ls\r\n");
|
||
});
|
||
|
||
it("recovers from a zero-geometry xterm container on the INITIAL mobile layout with the keyboard already OPEN", async () => {
|
||
const mockVV = {
|
||
width: 360,
|
||
height: 300,
|
||
offsetTop: 0,
|
||
offsetLeft: 0,
|
||
addEventListener: vi.fn(),
|
||
removeEventListener: vi.fn(),
|
||
};
|
||
Object.defineProperty(window, "visualViewport", { value: mockVV, writable: true, configurable: true });
|
||
Object.defineProperty(window, "innerHeight", { value: 300, writable: true, configurable: true });
|
||
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
|
||
|
||
const container = screen.getByTestId("terminal-xterm");
|
||
const box = { width: 0, height: 0 };
|
||
overrideContainerBox(container, box);
|
||
|
||
await act(async () => {
|
||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||
await Promise.resolve();
|
||
});
|
||
|
||
expect(mockTerminalInstance.cols).toBe(2);
|
||
expect(mockTerminalInstance.rows).toBe(1);
|
||
|
||
box.width = 360;
|
||
box.height = 260;
|
||
|
||
const observed = fireResizeObserverFor("terminal-xterm");
|
||
expect(observed).toBe(true);
|
||
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.cols).toBeGreaterThan(2);
|
||
expect(mockTerminalInstance.rows).toBeGreaterThan(1);
|
||
});
|
||
|
||
expect(container.clientWidth).toBe(360);
|
||
expect(container.clientHeight).toBe(260);
|
||
expect(mockTerminalInstance.cols).toBe(Math.max(2, Math.floor(360 / CHAR_WIDTH_PX)));
|
||
expect(mockTerminalInstance.rows).toBe(Math.max(1, Math.floor(260 / CHAR_HEIGHT_PX)));
|
||
});
|
||
|
||
it("re-establishes the container ResizeObserver against the NEW container node after a tab-switch remount", async () => {
|
||
const secondTab = {
|
||
id: "tab-2",
|
||
sessionId: "session-2",
|
||
title: "Terminal 2",
|
||
isActive: true,
|
||
createdAt: Date.now(),
|
||
};
|
||
|
||
const { rerender } = render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalledTimes(1));
|
||
|
||
const firstContainer = screen.getByTestId("terminal-xterm");
|
||
expect(fireResizeObserverFor("terminal-xterm")).toBe(true);
|
||
|
||
mockUseTerminalSessions.mockReturnValue({
|
||
...defaultSessionState,
|
||
tabs: [{ ...defaultTab, isActive: false }, secondTab],
|
||
activeTab: secondTab,
|
||
});
|
||
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
|
||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalledTimes(2));
|
||
|
||
const secondContainer = screen.getByTestId("terminal-xterm");
|
||
expect(secondContainer).not.toBe(firstContainer);
|
||
// The container remounted with a new sessionId key — the observer must
|
||
// now target the NEW node, not the stale/unmounted one.
|
||
expect(fireResizeObserverFor("terminal-xterm")).toBe(true);
|
||
});
|
||
|
||
it("coalesces duplicate/rapid container resize notifications into a single settled fit", async () => {
|
||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
|
||
|
||
const container = screen.getByTestId("terminal-xterm");
|
||
|
||
const box = { width: 0, height: 0 };
|
||
overrideContainerBox(container, box);
|
||
|
||
await act(async () => {
|
||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||
await Promise.resolve();
|
||
});
|
||
expect(mockTerminalInstance.cols).toBe(2);
|
||
expect(mockTerminalInstance.rows).toBe(1);
|
||
|
||
box.width = 640;
|
||
box.height = 400;
|
||
|
||
// Two rapid duplicate notifications (e.g. layout + font settle firing in
|
||
// the same tick) must coalesce to one corrective fit, not double-apply.
|
||
mockFitAddonFit.mockClear();
|
||
expect(fireResizeObserverFor("terminal-xterm")).toBe(true);
|
||
expect(fireResizeObserverFor("terminal-xterm")).toBe(true);
|
||
|
||
await waitFor(() => {
|
||
expect(mockTerminalInstance.cols).toBe(Math.max(2, Math.floor(640 / CHAR_WIDTH_PX)));
|
||
expect(mockTerminalInstance.rows).toBe(Math.max(1, Math.floor(400 / CHAR_HEIGHT_PX)));
|
||
});
|
||
});
|
||
});
|