Files
Termix/plugins/ssh-terminal/tests/frontend/terminal/TerminalToolbar.test.tsx
T

1062 lines
33 KiB
TypeScript

import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StrictMode } from "react";
import type { Host } from "../../../src/frontend/types";
const mobileApi = vi.hoisted(() => ({
isMobile: false as boolean | undefined,
}));
const slots = vi.hoisted(() => ({
toolbar: [] as Array<{ actionId: string; titleKey: string }>,
invoked: [] as unknown[][],
hostActions: [] as Array<Record<string, unknown>>,
tabs: { openTab: vi.fn(), openSingletonTab: vi.fn() },
}));
const labels: Record<string, string> = {
"terminalToolbar.detachTmux": "Detach tmux",
"terminalToolbar.detachTmuxDescription": "Detach tmux session",
"terminalToolbar.image": "Image actions (images only)",
"terminalToolbar.upload": "Upload image",
"terminalToolbar.uploadImage": "Upload an image to the host",
"terminalToolbar.uploadingImage": "Uploading image…",
"terminalToolbar.paste": "Paste image",
"terminalToolbar.pasteImage": "Paste image from clipboard (images only)",
"terminalToolbar.showActions": "Show toolbar",
"terminalToolbar.showToolbar": "Expand toolbar",
"terminalToolbar.hideToolbar": "Hide toolbar",
"terminalToolbar.layout": "Toolbar display mode",
"terminalToolbar.layoutIcon": "Icon only",
"terminalToolbar.layoutLabeled": "Icons and labels",
"terminalToolbar.layoutExpanded": "Expanded with metrics",
"terminalToolbar.moveToolbar": "Move toolbar",
"terminalToolbar.moveToolbarHint": "Drag to reposition the toolbar",
"terminalToolbar.imageActionFailed": "Image action failed",
"terminalToolbar.retry": "Retry",
"terminalToolbar.dismissError": "Dismiss image error",
"ai.assistant": "AI Assistant",
};
vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useTranslation: () => ({
t: (key: string) => labels[key] ?? key,
language: "en",
}),
useSlotContributions: (slotId: string) =>
slotId === "terminal.toolbar" ? slots.toolbar : [],
useHostActions: () => slots.hostActions,
useTabs: () => slots.tabs,
usePluginUiPreferences: () => ({
values: uiPrefs.values,
set: uiPrefs.set,
}),
}));
const uiPrefs = vi.hoisted(() => {
const values: Record<string, unknown> = {};
return {
values,
set: vi.fn((key: string, value: unknown) => {
values[key] = value;
}),
};
});
vi.mock("@termix/plugin-sdk/ui", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useIsMobile: () => mobileApi.isMobile,
// The real slot reads the action registry; the toolbar only decides where
// it goes and what it is handed.
ActionSlot: ({
context,
enabled,
}: {
context: () => unknown[];
enabled?: boolean;
}) =>
enabled === false
? null
: slots.toolbar.map((contribution) => (
<button
key={contribution.actionId}
type="button"
onClick={() => slots.invoked.push(context())}
>
{labels[contribution.titleKey] ?? contribution.titleKey}
</button>
)),
ComponentSlot: () => null,
}));
import { TerminalToolbar } from "../../../src/frontend/terminal/TerminalToolbar";
import {
clampToolbarPosition,
getResponsiveToolbarDensity,
} from "../../../src/frontend/terminal/toolbar-geometry";
const host = { id: 7, connectionType: "ssh" } as unknown as Host;
function renderToolbar(
overrides: Partial<React.ComponentProps<typeof TerminalToolbar>> = {},
) {
const props: React.ComponentProps<typeof TerminalToolbar> = {
host,
isConnected: true,
isTmuxAttached: true,
onTmuxDetach: vi.fn(),
isImageUploading: false,
onUploadImage: vi.fn(),
onPasteImage: vi.fn(),
isFocused: true,
...overrides,
};
return { ...render(<TerminalToolbar {...props} />), props };
}
// jsdom lacks the pointer APIs Radix Select needs to open its popup.
beforeEach(() => {
for (const key of Object.keys(uiPrefs.values)) delete uiPrefs.values[key];
uiPrefs.set.mockClear();
Element.prototype.hasPointerCapture ??= () => false;
Element.prototype.setPointerCapture ??= () => {};
Element.prototype.releasePointerCapture ??= () => {};
Element.prototype.scrollIntoView ??= () => {};
vi.clearAllMocks();
mobileApi.isMobile = false;
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: 1024,
});
localStorage.clear();
slots.toolbar = [];
slots.invoked = [];
slots.hostActions = [];
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("responsive toolbar density", () => {
it("enters below 110 percent and restores at 115 percent", () => {
expect(getResponsiveToolbarDensity("labeled", "labeled", 1099, 1000)).toBe(
"icon",
);
expect(getResponsiveToolbarDensity("labeled", "labeled", 1100, 1000)).toBe(
"labeled",
);
expect(getResponsiveToolbarDensity("labeled", "icon", 1149, 1000)).toBe(
"icon",
);
expect(getResponsiveToolbarDensity("labeled", "icon", 1150, 1000)).toBe(
"labeled",
);
});
it("never falls back when Icon is explicitly selected", () => {
expect(getResponsiveToolbarDensity("icon", "icon", 10, 1000)).toBe("icon");
expect(getResponsiveToolbarDensity("icon", "icon", 2000, 1000)).toBe(
"icon",
);
});
it("retains the prior effective state while measurement is unavailable", () => {
expect(getResponsiveToolbarDensity("expanded", "expanded", 0, 1000)).toBe(
"expanded",
);
expect(getResponsiveToolbarDensity("expanded", "icon", 0, 1000)).toBe(
"icon",
);
});
});
describe("TerminalToolbar Phase 1", () => {
it("clamps vertical movement and preserves over-tall recovery", () => {
const hostRect = {
left: 0,
right: 400,
top: 0,
bottom: 200,
width: 400,
height: 200,
};
const toolbarRect = {
left: 100,
right: 300,
top: 140,
bottom: 190,
width: 200,
height: 50,
};
expect(
clampToolbarPosition({ x: 0, y: -999 }, toolbarRect, hostRect, {
x: 0,
y: 0,
}).y,
).toBe(-132);
expect(
clampToolbarPosition({ x: 0, y: 999 }, toolbarRect, hostRect, {
x: 0,
y: 0,
}).y,
).toBe(2);
expect(
clampToolbarPosition(
{ x: 0, y: -999 },
{ ...toolbarRect, top: 150, bottom: 350, height: 200 },
hostRect,
{ x: 0, y: 0 },
).y,
).toBe(-298);
});
it("keeps horizontal outputs unchanged during simultaneous movement", () => {
const hostRect = {
left: 100,
right: 500,
top: 50,
bottom: 350,
width: 400,
height: 300,
};
const toolbarRect = {
left: 250,
right: 450,
top: 280,
bottom: 330,
width: 200,
height: 50,
};
expect(
clampToolbarPosition({ x: -999, y: -100 }, toolbarRect, hostRect, {
x: 0,
y: 0,
}),
).toEqual({ x: -142, y: -100 });
expect(
clampToolbarPosition({ x: 999, y: 12 }, toolbarRect, hostRect, {
x: 0,
y: 0,
}),
).toEqual({ x: 42, y: 12 });
});
it("fills the full terminal surface while remaining bottom-centered and pointer transparent", () => {
const { container } = renderToolbar();
const toolbarHost = container.querySelector("[data-terminal-toolbar-host]");
const toolbar = container.querySelector("[data-terminal-toolbar-wide]");
expect(toolbarHost).toHaveClass(
"absolute",
"inset-0",
"flex",
"items-end",
"justify-center",
"pointer-events-none",
"pb-2",
);
expect(toolbarHost).not.toHaveClass("inset-x-2", "bottom-2");
expect(toolbar).toHaveClass("pointer-events-auto");
expect(toolbar).toHaveStyle({ transform: "translate(0px, 0px)" });
});
it("clamps every edge and preserves recovery for an over-wide toolbar", () => {
const hostRect = {
left: 100,
right: 500,
top: 50,
bottom: 350,
width: 400,
height: 300,
};
const toolbar = {
left: 250,
right: 450,
top: 280,
bottom: 330,
width: 200,
height: 50,
};
expect(
clampToolbarPosition({ x: -500, y: 0 }, toolbar, hostRect, {
x: 0,
y: 0,
}),
).toEqual({
x: -142,
y: 0,
});
expect(
clampToolbarPosition({ x: 500, y: 0 }, toolbar, hostRect, { x: 0, y: 0 }),
).toEqual({
x: 42,
y: 0,
});
expect(
clampToolbarPosition({ x: 0, y: -500 }, toolbar, hostRect, {
x: 0,
y: 0,
}),
).toEqual({
x: 0,
y: -222,
});
expect(
clampToolbarPosition({ x: 0, y: 500 }, toolbar, hostRect, { x: 0, y: 0 }),
).toEqual({
x: 0,
y: 12,
});
expect(
clampToolbarPosition(
{ x: -999, y: 0 },
{ ...toolbar, left: -100, right: 600, width: 700 },
hostRect,
{ x: 0, y: 0 },
).x,
).toBe(-448);
});
it("clamps an off-screen restored position to actual host bounds", async () => {
localStorage.setItem(
"termix-terminal-toolbar-position-v2",
JSON.stringify({ x: 9000, y: -9000 }),
);
const original = HTMLElement.prototype.getBoundingClientRect;
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
function () {
if (this.hasAttribute("data-terminal-toolbar-host")) {
return DOMRect.fromRect({ x: 100, y: 50, width: 500, height: 300 });
}
if (this.hasAttribute("data-terminal-toolbar-wide")) {
const match = this.style.transform.match(
/translate\(([-\d.]+)px, ([-\d.]+)px\)/,
);
const x = Number(match?.[1] ?? 0);
const y = Number(match?.[2] ?? 0);
return DOMRect.fromRect({
x: 250 + x,
y: 250 + y,
width: 250,
height: 50,
});
}
return original.call(this);
},
);
const { container } = renderToolbar();
await waitFor(() =>
expect(
(container.querySelector("[data-terminal-toolbar-wide]") as HTMLElement)
.style.transform,
).toBe("translate(0px, 0px)"),
);
});
it("renders the PC toolbar and Image group with adjacent actions", () => {
const { container } = renderToolbar();
expect(container.querySelector("[data-terminal-toolbar-host]")).toHaveClass(
"@container",
);
expect(container.querySelector("[data-terminal-toolbar-wide]")).toHaveClass(
"pointer-events-auto",
);
expect(
container.querySelector("[data-terminal-toolbar-narrow]"),
).toBeNull();
const imageGroup = screen.getAllByRole("group", {
name: "Image actions (images only)",
})[0];
expect(imageGroup.querySelectorAll("button, label")).toHaveLength(2);
expect(
screen.getAllByRole("button", { name: "Upload an image to the host" })[0],
).toBeVisible();
expect(
screen.getAllByRole("button", {
name: "Paste image from clipboard (images only)",
})[0],
).toBeVisible();
});
it("uses explicit labels, conditional tmux, and compact interactive targets", () => {
const { rerender, props } = renderToolbar();
expect(
screen.getAllByRole("button", { name: "Detach tmux session" })[0],
).toHaveTextContent("Detach tmux");
expect(
screen.getAllByRole("button", { name: "Detach tmux session" })[0],
).toHaveClass("min-h-8", "min-w-8");
rerender(<TerminalToolbar {...props} isTmuxAttached={false} />);
expect(
screen.queryByRole("button", { name: "Detach tmux session" }),
).not.toBeInTheDocument();
});
it("exposes density as a select and applies the chosen mode", async () => {
const user = userEvent.setup();
renderToolbar();
const trigger = screen.getByRole("combobox", {
name: "Toolbar display mode",
});
expect(trigger).toHaveTextContent("");
await user.click(trigger);
const options = await screen.findAllByRole("option");
expect(options.map((option) => option.textContent)).toEqual([
"Icon only",
"Icons and labels",
"Expanded with metrics",
]);
await user.click(
screen.getByRole("option", { name: "Expanded with metrics" }),
);
await waitFor(() =>
expect(uiPrefs.set).toHaveBeenCalledWith("toolbarDensity", "expanded"),
);
});
it("keeps one file input mounted, resets repeated selections, and exposes loading", () => {
const onUploadImage = vi.fn();
const { container, rerender, props } = renderToolbar({ onUploadImage });
const input = container.querySelector(
'input[type="file"]',
) as HTMLInputElement;
expect(container.querySelectorAll('input[type="file"]')).toHaveLength(1);
const file = new File(["image"], "same.png", { type: "image/png" });
fireEvent.change(input, { target: { files: [file] } });
fireEvent.change(input, { target: { files: [file] } });
expect(onUploadImage).toHaveBeenCalledTimes(2);
rerender(<TerminalToolbar {...props} isImageUploading />);
expect(container.querySelector('input[type="file"]')).toBe(input);
expect(screen.getByRole("status")).toHaveTextContent("Uploading image…");
expect(
screen.getAllByRole("button", { name: "Upload an image to the host" })[0],
).toBeDisabled();
expect(
screen.getAllByRole("button", {
name: "Paste image from clipboard (images only)",
})[0],
).toBeDisabled();
});
it("hides clearly, leaves recovery, and closes open UI on disconnect", async () => {
const user = userEvent.setup();
const { rerender, props } = renderToolbar();
await user.click(screen.getByRole("button", { name: "Hide toolbar" }));
const collapsedMover = screen.getByRole("button", {
name: "Move toolbar",
});
expect(collapsedMover).toHaveAttribute("aria-label", "Move toolbar");
await user.click(screen.getByRole("button", { name: "Expand toolbar" }));
expect(
screen.getByRole("combobox", { name: "Toolbar display mode" }),
).toBeInTheDocument();
rerender(<TerminalToolbar {...props} isConnected={false} />);
expect(
screen.queryByRole("combobox", { name: "Toolbar display mode" }),
).not.toBeInTheDocument();
});
it("calls paste synchronously in the activation handler and does not cancel outside pointerdown", () => {
const onPasteImage = vi.fn();
renderToolbar({ onPasteImage });
const paste = screen.getAllByRole("button", {
name: "Paste image from clipboard (images only)",
})[0];
fireEvent.click(paste);
expect(onPasteImage).toHaveBeenCalledOnce();
const outside = new PointerEvent("pointerdown", {
bubbles: true,
cancelable: true,
});
document.body.dispatchEvent(outside);
expect(outside.defaultPrevented).toBe(false);
});
it("shows an actionable image error and retries the failed activation", async () => {
const user = userEvent.setup();
const onPasteImage = vi
.fn<() => Promise<void>>()
.mockRejectedValueOnce(new Error("Clipboard permission denied"))
.mockResolvedValueOnce();
renderToolbar({ onPasteImage });
await user.click(
screen.getAllByRole("button", {
name: "Paste image from clipboard (images only)",
})[0],
);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Clipboard permission denied",
);
await user.click(screen.getByRole("button", { name: "Retry" }));
expect(onPasteImage).toHaveBeenCalledTimes(2);
await waitFor(() =>
expect(screen.queryByRole("alert")).not.toBeInTheDocument(),
);
});
it("routes synchronous image failures through the alert flow", async () => {
renderToolbar({
onPasteImage: vi.fn(() => {
throw new Error("Synchronous clipboard failure");
}),
});
fireEvent.click(
screen.getByRole("button", {
name: "Paste image from clipboard (images only)",
}),
);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Synchronous clipboard failure",
);
});
it("orders desktop actions and keeps the grab handle persistent", () => {
const { container } = renderToolbar();
const labels = Array.from(
container.querySelectorAll("[data-terminal-toolbar-wide] button"),
).map((button) => button.getAttribute("aria-label"));
expect(labels).toEqual([
"Detach tmux session",
"Upload an image to the host",
"Paste image from clipboard (images only)",
"Toolbar display mode",
"Hide toolbar",
"Move toolbar",
]);
});
it("shows three modes and fades the expanded toolbar", async () => {
const user = userEvent.setup();
const { container } = renderToolbar();
const toolbar = container.querySelector(
"[data-terminal-toolbar-wide]",
) as HTMLElement;
expect(toolbar).toHaveClass(
"opacity-30",
"transition-opacity",
"hover:opacity-100",
"focus-within:opacity-100",
);
await user.click(
screen.getByRole("combobox", { name: "Toolbar display mode" }),
);
expect(await screen.findAllByRole("option")).toHaveLength(3);
});
it("gives the collapsed toolbar idle opacity and keyboard recovery", async () => {
const user = userEvent.setup();
const { container } = renderToolbar();
await user.click(screen.getByRole("button", { name: "Hide toolbar" }));
const toolbar = container.querySelector(
"[data-terminal-toolbar-wide]",
) as HTMLElement;
expect(toolbar).toHaveClass(
"opacity-100",
"hover:opacity-100",
"focus-within:opacity-100",
);
expect(
screen.getByRole("button", { name: "Expand toolbar" }),
).toBeVisible();
const collapsedMover = screen.getByRole("button", {
name: "Move toolbar",
});
expect(collapsedMover).toBeVisible();
expect(
container.querySelector("[data-toolbar-collapsed]"),
).toBeInTheDocument();
collapsedMover.focus();
expect(toolbar).toHaveClass("focus-within:opacity-100");
const expandButton = screen.getByRole("button", {
name: "Expand toolbar",
});
expandButton.focus();
await user.keyboard("{Enter}");
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Hide toolbar" }),
).toHaveFocus(),
);
}, 15_000);
it("keeps the mover on the right edge across collapse and expansion", async () => {
const original = HTMLElement.prototype.getBoundingClientRect;
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
function () {
if (this.hasAttribute("data-terminal-toolbar-host")) {
return DOMRect.fromRect({ x: 100, y: 50, width: 500, height: 300 });
}
if (this.hasAttribute("data-terminal-toolbar-wide")) {
const match = this.style.transform.match(
/translate\(([-\d.]+)px, ([-\d.]+)px\)/,
);
const collapsed = !this.querySelector(
'button[aria-label="Hide toolbar"]',
);
return DOMRect.fromRect({
x: 225 + Number(match?.[1] ?? 0),
y: 290 + Number(match?.[2] ?? 0),
width: collapsed ? 44 : 250,
height: collapsed ? 44 : 50,
});
}
return original.call(this);
},
);
localStorage.setItem(
"termix-terminal-toolbar-position-v2",
JSON.stringify({ x: 117, y: 2 }),
);
const { container } = renderToolbar();
const toolbar = container.querySelector(
"[data-terminal-toolbar-wide]",
) as HTMLElement;
await userEvent
.setup()
.click(screen.getByRole("button", { name: "Hide toolbar" }));
expect(toolbar.style.transform).toBe("translate(323px, 2px)");
await userEvent
.setup()
.click(screen.getByRole("button", { name: "Expand toolbar" }));
expect(toolbar.style.transform).toBe("translate(117px, 2px)");
});
it("moves and persists the toolbar position, then stops after pointerup", () => {
const { container, unmount } = renderToolbar();
const grab = screen.getByRole("button", { name: "Move toolbar" });
const toolbar = container.querySelector(
"[data-terminal-toolbar-wide]",
) as HTMLElement;
fireEvent.pointerDown(grab, {
pointerId: 1,
button: 0,
clientX: 100,
clientY: 100,
});
fireEvent.pointerMove(grab, {
pointerId: 1,
clientX: 140,
clientY: 90,
});
expect(toolbar.style.transform).toBe("translate(40px, -10px)");
fireEvent.pointerUp(grab, { pointerId: 1 });
expect(
JSON.parse(localStorage.getItem("termix-terminal-toolbar-position-v2")!),
).toEqual({ x: 40, y: -10 });
fireEvent.pointerMove(grab, {
pointerId: 1,
clientX: 500,
clientY: 500,
});
expect(toolbar.style.transform).toBe("translate(40px, -10px)");
unmount();
const second = renderToolbar();
expect(
(
second.container.querySelector(
"[data-terminal-toolbar-wide]",
) as HTMLElement
).style.transform,
).toBe("translate(40px, -10px)");
});
it("moves on both axes, clamps safely, ignores a different pointer ID, and persists", () => {
const original = HTMLElement.prototype.getBoundingClientRect;
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
function () {
if (this.hasAttribute("data-terminal-toolbar-host")) {
return DOMRect.fromRect({ x: 100, y: 50, width: 500, height: 300 });
}
if (this.hasAttribute("data-terminal-toolbar-wide")) {
const match = this.style.transform.match(
/translate\(([-\d.]+)px, ([-\d.]+)px\)/,
);
return DOMRect.fromRect({
x: 225 + Number(match?.[1] ?? 0),
y: 290 + Number(match?.[2] ?? 0),
width: 250,
height: 50,
});
}
return original.call(this);
},
);
const { container } = renderToolbar();
const grab = screen.getByRole("button", { name: "Move toolbar" });
const toolbar = container.querySelector(
"[data-terminal-toolbar-wide]",
) as HTMLElement;
fireEvent.pointerDown(grab, {
pointerId: 11,
button: 0,
clientX: 10,
clientY: 100,
});
fireEvent.pointerMove(grab, {
pointerId: 12,
clientX: 30,
clientY: -500,
});
expect(toolbar).toHaveStyle({ transform: "translate(0px, 0px)" });
fireEvent.pointerMove(grab, {
pointerId: 11,
clientX: 30,
clientY: 120,
});
expect(toolbar.style.transform).toBe("translate(20px, 2px)");
fireEvent.pointerMove(grab, {
pointerId: 11,
clientX: 500,
clientY: 500,
});
expect(toolbar.style.transform).toBe("translate(117px, 2px)");
fireEvent.pointerMove(grab, {
pointerId: 11,
clientX: 500,
clientY: 500,
});
expect(toolbar.style.transform).toBe("translate(117px, 2px)");
fireEvent.pointerUp(grab, { pointerId: 11 });
expect(
JSON.parse(localStorage.getItem("termix-terminal-toolbar-position-v2")!),
).toEqual({ x: 117, y: 2 });
});
it("avoids ResizeObserver write churn and writes one real reclamp", async () => {
let resizeCallback: ResizeObserverCallback | undefined;
class ControlledResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
}
observe() {}
disconnect() {}
unobserve() {}
}
vi.stubGlobal("ResizeObserver", ControlledResizeObserver);
let hostWidth = 500;
const original = HTMLElement.prototype.getBoundingClientRect;
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
function () {
if (this.hasAttribute("data-terminal-toolbar-host")) {
return DOMRect.fromRect({
x: 0,
y: 0,
width: hostWidth,
height: 300,
});
}
if (this.hasAttribute("data-terminal-toolbar-wide")) {
const match = this.style.transform.match(
/translate\(([-\d.]+)px, ([-\d.]+)px\)/,
);
return DOMRect.fromRect({
x: 125 + Number(match?.[1] ?? 0),
y: 242 + Number(match?.[2] ?? 0),
width: 250,
height: 50,
});
}
return original.call(this);
},
);
const writes = vi.spyOn(Storage.prototype, "setItem");
renderToolbar();
writes.mockClear();
act(() => {
resizeCallback?.([], {} as ResizeObserver);
resizeCallback?.([], {} as ResizeObserver);
});
expect(writes).not.toHaveBeenCalledWith(
"termix-terminal-toolbar-position-v2",
expect.any(String),
);
hostWidth = 200;
act(() => resizeCallback?.([], {} as ResizeObserver));
expect(writes).not.toHaveBeenCalledWith(
"termix-terminal-toolbar-position-v2",
expect.any(String),
);
expect(
writes.mock.calls.filter(
([key]) => key === "termix-terminal-toolbar-position-v2",
),
).toHaveLength(0);
});
it.each([undefined, true])(
"does not mount or run desktop side effects when mobile is %s",
(isMobile) => {
mobileApi.isMobile = isMobile;
localStorage.setItem("termix-terminal-toolbar-density", "expanded");
const writes = vi.spyOn(Storage.prototype, "setItem");
const addWindowListener = vi.spyOn(window, "addEventListener");
const resizeObserver = vi.fn();
vi.stubGlobal("ResizeObserver", resizeObserver);
const { container } = renderToolbar();
expect(
container.querySelector("[data-terminal-toolbar-host]"),
).not.toBeInTheDocument();
expect(writes).not.toHaveBeenCalled();
expect(resizeObserver).not.toHaveBeenCalled();
expect(addWindowListener).not.toHaveBeenCalledWith(
"resize",
expect.any(Function),
);
},
);
it("ends dragging on pointer cancellation and lost capture, and rejects touch", () => {
const { container } = renderToolbar();
const grab = screen.getByRole("button", { name: "Move toolbar" });
const toolbar = container.querySelector(
"[data-terminal-toolbar-wide]",
) as HTMLElement;
fireEvent.pointerDown(grab, {
pointerId: 2,
pointerType: "touch",
button: 0,
clientX: 10,
clientY: 10,
});
fireEvent.pointerMove(grab, { pointerId: 2, clientX: 50, clientY: 50 });
expect(toolbar.style.transform).toBe("translate(0px, 0px)");
fireEvent.pointerDown(grab, {
pointerId: 3,
button: 0,
clientX: 10,
clientY: 10,
});
fireEvent.pointerCancel(grab, { pointerId: 3 });
fireEvent.pointerMove(grab, { pointerId: 3, clientX: 50, clientY: 50 });
expect(toolbar.style.transform).toBe("translate(0px, 0px)");
fireEvent.pointerDown(grab, {
pointerId: 4,
button: 0,
clientX: 10,
clientY: 10,
});
fireEvent.lostPointerCapture(grab, { pointerId: 4 });
fireEvent.pointerMove(grab, { pointerId: 4, clientX: 50, clientY: 50 });
expect(toolbar.style.transform).toBe("translate(0px, 0px)");
});
it("ignores stale image completions after an instance change in Strict Mode", async () => {
let reject!: (error: Error) => void;
const pending = new Promise<void>((_, rejectPromise) => {
reject = rejectPromise;
});
const props = {
host,
isConnected: true,
isTmuxAttached: false,
onTmuxDetach: vi.fn(),
isImageUploading: false,
onUploadImage: vi.fn(),
onPasteImage: vi.fn(() => pending),
isFocused: true,
};
const { rerender } = render(
<StrictMode>
<TerminalToolbar {...props} />
</StrictMode>,
);
fireEvent.click(
screen.getByRole("button", {
name: "Paste image from clipboard (images only)",
}),
);
rerender(
<StrictMode>
<TerminalToolbar
{...props}
host={{ ...host, id: 8 } as unknown as Host}
/>
</StrictMode>,
);
reject(new Error("old instance"));
await Promise.resolve();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
describe("host quick links", () => {
const Icon = () => null;
it("offers other plugins' open actions for this host", async () => {
const run = vi.fn();
slots.hostActions = [
{
id: "docker",
titleKey: "Docker",
icon: Icon,
kind: "open",
when: () => true,
run,
},
{
id: "gone",
titleKey: "Hidden",
icon: Icon,
kind: "open",
when: () => false,
run,
},
{
id: "terminal",
titleKey: "Terminal",
icon: Icon,
kind: "connect",
when: () => true,
tabType: "terminal",
},
{
id: "tunnel",
titleKey: "Tunnels",
icon: Icon,
kind: "open",
when: () => true,
tabType: "tunnel",
},
];
renderToolbar();
expect(screen.queryByRole("button", { name: "Hidden" })).toBeNull();
expect(screen.queryByRole("button", { name: "Terminal" })).toBeNull();
await userEvent.click(screen.getByRole("button", { name: "Docker" }));
expect(run).toHaveBeenCalledTimes(1);
await userEvent.click(screen.getByRole("button", { name: "Tunnels" }));
expect(slots.tabs.openTab).toHaveBeenCalledWith(
expect.anything(),
"tunnel",
);
});
});
describe("contributed toolbar actions", () => {
it("shows no contributed button and no stray separator when nothing contributed", () => {
const { container } = renderToolbar();
expect(screen.queryByRole("button", { name: "AI Assistant" })).toBeNull();
const separatorsWithout = container.querySelectorAll(".w-px").length;
cleanup();
slots.toolbar = [
{ actionId: "ai.openWithContext", titleKey: "ai.assistant" },
];
const withButton = renderToolbar().container;
expect(withButton.querySelectorAll(".w-px").length).toBe(
separatorsWithout + 1,
);
});
it("hands the action the terminal slot api", async () => {
slots.toolbar = [
{ actionId: "ai.openWithContext", titleKey: "ai.assistant" },
];
const slotApi = {
host: undefined,
getBufferText: vi.fn(() => "$ whoami\nroot"),
sessionId: () => null,
openSidePanel: vi.fn(),
runCommand: vi.fn(),
getShareTarget: () => null,
};
renderToolbar({ slotApi });
await userEvent.click(
screen.getByRole("button", { name: "AI Assistant" }),
);
expect(slots.invoked).toEqual([[slotApi]]);
});
it("hides the slot when the host has contributed actions disabled", () => {
slots.toolbar = [
{ actionId: "ai.openWithContext", titleKey: "ai.assistant" },
];
renderToolbar({ actionsEnabled: false });
expect(screen.queryByRole("button", { name: "AI Assistant" })).toBeNull();
});
});
describe("host toolbar settings", () => {
const withSettings = (values: Record<string, unknown>) =>
({
...host,
pluginSettings: { "ssh-terminal": values },
}) as unknown as Host;
it("anchors the toolbar where the host asks", () => {
const { container } = renderToolbar({
host: withSettings({ terminalToolbarPosition: "top-right" }),
});
const toolbarHost = container.querySelector(
"[data-terminal-toolbar-host]",
);
expect(toolbarHost).toHaveClass("items-start", "justify-end", "pt-2");
expect(toolbarHost).not.toHaveClass("items-end", "justify-center");
});
it("keeps a separate drag offset per anchor", () => {
localStorage.setItem(
"termix-terminal-toolbar-position-v2",
JSON.stringify({ x: 0, y: -40 }),
);
const { container } = renderToolbar({
host: withSettings({ terminalToolbarPosition: "top-left" }),
});
expect(
container.querySelector("[data-terminal-toolbar-wide]"),
).toHaveStyle({ transform: "translate(0px, 0px)" });
});
it("starts minimized when the host asks", () => {
renderToolbar({
host: withSettings({ terminalToolbarStartState: "collapsed" }),
});
expect(
screen.getByRole("button", { name: "Expand toolbar" }),
).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Hide toolbar" })).toBeNull();
});
it("uses the host display mode without overwriting the saved one", () => {
uiPrefs.values.toolbarDensity = "labeled";
uiPrefs.set.mockClear();
renderToolbar({ host: withSettings({ terminalToolbarDisplay: "icon" }) });
expect(screen.queryByText("Upload image")).toBeNull();
expect(uiPrefs.set).not.toHaveBeenCalled();
});
it("moves a density saved in this browser into the plugin preferences", () => {
localStorage.setItem("termix-terminal-toolbar-density", "icon");
uiPrefs.set.mockClear();
renderToolbar();
expect(uiPrefs.set).toHaveBeenCalledWith("toolbarDensity", "icon");
expect(
localStorage.getItem("termix-terminal-toolbar-density"),
).toBeNull();
});
it("stays fully visible when fading is off", () => {
const { container } = renderToolbar({
host: withSettings({ terminalToolbarFade: false }),
});
const toolbar = container.querySelector("[data-terminal-toolbar-wide]");
expect(toolbar).toHaveClass("opacity-100");
expect(toolbar).not.toHaveClass("opacity-30");
});
});
});