mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
1062 lines
33 KiB
TypeScript
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");
|
|
});
|
|
});
|
|
});
|