mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
feat: port the demo redesign with Manage and Settings tabs
This commit is contained in:
1 parent
f8151cb885
commit
35c954b79a
128 files changed
+13262
-8143
No files matched your search
@@ -114,9 +114,9 @@ describe("Auth local desktop flow", () => {
|
||||
renderAuth();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Local desktop signed out")).toBeTruthy();
|
||||
expect(screen.getByText("auth.localDesktopSignedOut")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByText("Continue with local desktop")).toBeTruthy();
|
||||
expect(screen.getByText("auth.continueLocalDesktop")).toBeTruthy();
|
||||
expect(screen.queryByText("common.register")).toBeNull();
|
||||
expect(screen.queryByLabelText("common.username")).toBeNull();
|
||||
expect(screen.queryByText("auth.forgotPassword")).toBeNull();
|
||||
@@ -134,11 +134,9 @@ describe("Auth local desktop flow", () => {
|
||||
renderAuth();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("Local desktop session unavailable"),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("auth.localDesktopUnavailable")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByText("Retry local desktop session")).toBeTruthy();
|
||||
expect(screen.getByText("auth.retryLocalDesktop")).toBeTruthy();
|
||||
expect(screen.queryByText("common.register")).toBeNull();
|
||||
expect(screen.queryByLabelText("common.username")).toBeNull();
|
||||
expect(mainAxios.requestDesktopAutoSession).toHaveBeenCalled();
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { masonryColumns } from "@/components/card-masonry";
|
||||
|
||||
describe("masonryColumns", () => {
|
||||
it("stacks into one column when narrow", () => {
|
||||
expect(masonryColumns(400, 4)).toBe(1);
|
||||
});
|
||||
|
||||
it("grows with the container up to the preference", () => {
|
||||
expect(masonryColumns(700, 3)).toBe(2);
|
||||
expect(masonryColumns(1000, 3)).toBe(3);
|
||||
expect(masonryColumns(1300, 3)).toBe(3);
|
||||
expect(masonryColumns(1300, 4)).toBe(4);
|
||||
});
|
||||
|
||||
it("falls back to three for an unknown preference", () => {
|
||||
expect(masonryColumns(1300, 9)).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
} from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import {
|
||||
ConnectionGate,
|
||||
type ConnectionGateController,
|
||||
} from "@/components/connection/ConnectionGate";
|
||||
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("ConnectionGate", () => {
|
||||
it("shows the children once connected", async () => {
|
||||
render(
|
||||
<ConnectionGate
|
||||
message="docker.connecting"
|
||||
connect={(gate) => gate.markConnected()}
|
||||
>
|
||||
<span>containers</span>
|
||||
</ConnectionGate>,
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(screen.getByText("containers")).toBeTruthy();
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the screen up with the reason and a retry when connect throws", async () => {
|
||||
const connect = vi
|
||||
.fn<(gate: ConnectionGateController) => Promise<void> | void>()
|
||||
.mockRejectedValueOnce(new Error("No route to host"))
|
||||
.mockImplementation((gate) => gate.markConnected());
|
||||
render(
|
||||
<ConnectionGate message="files.connecting" connect={connect}>
|
||||
<span>tree</span>
|
||||
</ConnectionGate>,
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(screen.queryByText("tree")).toBeNull();
|
||||
expect(screen.getAllByText("No route to host").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("connection.failedRetrying")).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("connection.retryNow"));
|
||||
});
|
||||
expect(screen.getByText("tree")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not retry on its own when unavailable", async () => {
|
||||
const connect = vi.fn((gate: ConnectionGateController) =>
|
||||
gate.markUnavailable({ title: "docker.notEnabled" }),
|
||||
);
|
||||
render(
|
||||
<ConnectionGate message="docker.connecting" connect={connect}>
|
||||
<span>containers</span>
|
||||
</ConnectionGate>,
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(screen.getByText("docker.notEnabled")).toBeTruthy();
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(60_000);
|
||||
});
|
||||
expect(connect).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByText("containers")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps children mounted under the screen when asked", async () => {
|
||||
render(
|
||||
<ConnectionGate
|
||||
message="serial.connecting"
|
||||
keepMounted
|
||||
connect={() => new Promise(() => {})}
|
||||
>
|
||||
<span>xterm</span>
|
||||
</ConnectionGate>,
|
||||
);
|
||||
await act(async () => {});
|
||||
expect(screen.getByText("xterm")).toBeTruthy();
|
||||
expect(screen.getByRole("status")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
@@ -25,16 +25,16 @@ describe("ConnectionScreen", () => {
|
||||
});
|
||||
|
||||
it("renders the disconnected state without a provider", () => {
|
||||
expect(() =>
|
||||
render(
|
||||
<ConnectionScreen
|
||||
status="disconnected"
|
||||
message="remoteDesktop.hostNotFound"
|
||||
/>,
|
||||
),
|
||||
).not.toThrow();
|
||||
render(
|
||||
<ConnectionScreen
|
||||
status="disconnected"
|
||||
message="common.loading"
|
||||
disconnectedMessage="terminal.connectionLost"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("remoteDesktop.hostNotFound")).toBeTruthy();
|
||||
expect(screen.getByText("terminal.connectionLost")).toBeTruthy();
|
||||
expect(screen.queryByText("common.loading")).toBeNull();
|
||||
expect(screen.getByRole("status").getAttribute("data-status")).toBe(
|
||||
"disconnected",
|
||||
);
|
||||
@@ -49,4 +49,66 @@ describe("ConnectionScreen", () => {
|
||||
|
||||
expect(screen.getByText(/sshAuth\.connectionLogTitle/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("offers retry and the reason once a connect fails", () => {
|
||||
const retry = vi.fn();
|
||||
render(
|
||||
<ConnectionScreen
|
||||
status="error"
|
||||
message="common.loading"
|
||||
errorDetail="Authentication failed"
|
||||
onManualRetry={retry}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("connection.failed")).toBeTruthy();
|
||||
expect(screen.getByText("Authentication failed")).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("connection.reconnect"));
|
||||
expect(retry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("lets the user skip the wait while it retries on its own", () => {
|
||||
const retry = vi.fn();
|
||||
render(
|
||||
<ConnectionScreen
|
||||
status="error"
|
||||
attempt={2}
|
||||
maxAttempts={8}
|
||||
nextRetryInMs={4000}
|
||||
onManualRetry={retry}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("connection.failedRetrying")).toBeTruthy();
|
||||
expect(screen.getByText("connection.retryingIn")).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("connection.retryNow"));
|
||||
expect(retry).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows an unavailable state with its hint, action and retry", () => {
|
||||
render(
|
||||
<ConnectionScreen
|
||||
status="connected"
|
||||
unavailable={{
|
||||
title: "docker.notEnabled",
|
||||
hint: "docker.notEnabledHint",
|
||||
action: <button>open editor</button>,
|
||||
}}
|
||||
onManualRetry={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("status").getAttribute("data-status")).toBe(
|
||||
"unavailable",
|
||||
);
|
||||
expect(screen.getByText("docker.notEnabled")).toBeTruthy();
|
||||
expect(screen.getByText("docker.notEnabledHint")).toBeTruthy();
|
||||
expect(screen.getByText("open editor")).toBeTruthy();
|
||||
expect(screen.getByText("connection.reconnect")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders nothing once connected", () => {
|
||||
const { container } = render(<ConnectionScreen status="connected" />);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { DataView } from "@/components/data-view";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const items = [
|
||||
{ id: "a", name: "alpha" },
|
||||
{ id: "b", name: "beta" },
|
||||
];
|
||||
|
||||
function renderView(view: "grid" | "list", onRowClick = vi.fn()) {
|
||||
return render(
|
||||
<DataView
|
||||
items={items}
|
||||
view={view}
|
||||
density="compact"
|
||||
getKey={(item) => item.id}
|
||||
renderCard={(item) => <div>card {item.name}</div>}
|
||||
columns={{ base: 1, md: 2 }}
|
||||
listColumns={[
|
||||
{ key: "name", header: "Name", width: "1fr", cell: (i) => i.name },
|
||||
]}
|
||||
onRowClick={onRowClick}
|
||||
empty={<span>nothing</span>}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("DataView", () => {
|
||||
it("renders cards in grid mode", () => {
|
||||
renderView("grid");
|
||||
expect(screen.getByText("card alpha")).toBeTruthy();
|
||||
expect(screen.queryByText("Name")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a header and clickable rows in list mode", () => {
|
||||
const onRowClick = vi.fn();
|
||||
renderView("list", onRowClick);
|
||||
expect(screen.getByText("Name")).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("beta"));
|
||||
expect(onRowClick).toHaveBeenCalledWith(items[1]);
|
||||
});
|
||||
|
||||
it("shows the empty state with no items", () => {
|
||||
render(
|
||||
<DataView
|
||||
items={[]}
|
||||
view="grid"
|
||||
getKey={() => ""}
|
||||
renderCard={() => null}
|
||||
columns={{}}
|
||||
listColumns={[]}
|
||||
empty={<span>nothing</span>}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("nothing")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import {
|
||||
Facts,
|
||||
PanelSearch,
|
||||
PanelShell,
|
||||
Segmented,
|
||||
ViewToggle,
|
||||
} from "@/components/panel-layout";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("PanelSearch", () => {
|
||||
it("clears with the button and with Esc", () => {
|
||||
const onChange = vi.fn();
|
||||
const { rerender } = render(
|
||||
<PanelSearch value="web" onChange={onChange} />,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("panel.clearSearch"));
|
||||
expect(onChange).toHaveBeenLastCalledWith("");
|
||||
|
||||
rerender(<PanelSearch value="db" onChange={onChange} />);
|
||||
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" });
|
||||
expect(onChange).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("hides the clear button while empty", () => {
|
||||
render(<PanelSearch value="" onChange={() => {}} />);
|
||||
expect(screen.queryByLabelText("panel.clearSearch")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Segmented", () => {
|
||||
it("marks the active option and reports a pick", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<Segmented
|
||||
value="hosts"
|
||||
onChange={onChange}
|
||||
options={[
|
||||
{ value: "hosts", label: "Hosts", count: 3 },
|
||||
{ value: "credentials", label: "Credentials" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const radios = screen.getAllByRole("radio");
|
||||
expect(radios[0].getAttribute("aria-checked")).toBe("true");
|
||||
fireEvent.click(radios[1]);
|
||||
expect(onChange).toHaveBeenCalledWith("credentials");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ViewToggle", () => {
|
||||
it("switches view and density", () => {
|
||||
const onView = vi.fn();
|
||||
const onDensity = vi.fn();
|
||||
render(
|
||||
<ViewToggle
|
||||
view="grid"
|
||||
onView={onView}
|
||||
density="comfortable"
|
||||
onDensity={onDensity}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("panel.listView"));
|
||||
expect(onView).toHaveBeenCalledWith("list");
|
||||
fireEvent.click(screen.getByLabelText("panel.compactRows"));
|
||||
expect(onDensity).toHaveBeenCalledWith("compact");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Facts", () => {
|
||||
it("puts a rule only between facts", () => {
|
||||
const { container } = render(
|
||||
<Facts>
|
||||
<span>one</span>
|
||||
{null}
|
||||
<span>two</span>
|
||||
</Facts>,
|
||||
);
|
||||
expect(container.querySelectorAll("[aria-hidden]")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PanelShell", () => {
|
||||
it("draws the header, toolbar and footer", () => {
|
||||
render(
|
||||
<PanelShell
|
||||
title="Docker"
|
||||
status="2 running"
|
||||
toolbar={<span>toolbar</span>}
|
||||
footer={<span>footer</span>}
|
||||
>
|
||||
body
|
||||
</PanelShell>,
|
||||
);
|
||||
expect(screen.getByText("Docker")).toBeTruthy();
|
||||
expect(screen.getByText("2 running")).toBeTruthy();
|
||||
expect(screen.getByText("toolbar")).toBeTruthy();
|
||||
expect(screen.getByText("footer")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
} from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import {
|
||||
InlineView,
|
||||
PanePrompt,
|
||||
SurfaceScope,
|
||||
useConfirm,
|
||||
type ConfirmOptions,
|
||||
} from "@/components/surface/surface-scope";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function Asker({
|
||||
options,
|
||||
onResult,
|
||||
}: {
|
||||
options: ConfirmOptions;
|
||||
onResult: (ok: boolean) => void;
|
||||
}) {
|
||||
const confirm = useConfirm();
|
||||
return (
|
||||
<button onClick={() => void confirm(options).then(onResult)}>ask</button>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useConfirm", () => {
|
||||
it("resolves true when confirmed with Enter", async () => {
|
||||
const onResult = vi.fn();
|
||||
render(
|
||||
<SurfaceScope>
|
||||
<Asker options={{ title: "Delete web-01?" }} onResult={onResult} />
|
||||
</SurfaceScope>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("ask"));
|
||||
expect(screen.getByRole("alertdialog")).toBeTruthy();
|
||||
expect(screen.getByText("Delete web-01?")).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(window, { key: "Enter" });
|
||||
});
|
||||
expect(onResult).toHaveBeenCalledWith(true);
|
||||
expect(screen.queryByRole("alertdialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves false on Esc and on Cancel", async () => {
|
||||
const onResult = vi.fn();
|
||||
render(
|
||||
<SurfaceScope>
|
||||
<Asker options={{ title: "Remove?" }} onResult={onResult} />
|
||||
</SurfaceScope>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("ask"));
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
});
|
||||
expect(onResult).toHaveBeenLastCalledWith(false);
|
||||
|
||||
fireEvent.click(screen.getByText("ask"));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("common.cancel"));
|
||||
});
|
||||
expect(onResult).toHaveBeenCalledTimes(2);
|
||||
expect(onResult).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it("stops Enter from reaching anything listening underneath", async () => {
|
||||
const underneath = vi.fn();
|
||||
window.addEventListener("keydown", underneath);
|
||||
render(
|
||||
<SurfaceScope>
|
||||
<Asker options={{ title: "Close?" }} onResult={() => {}} />
|
||||
</SurfaceScope>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("ask"));
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(window, { key: "Enter" });
|
||||
});
|
||||
expect(underneath).not.toHaveBeenCalled();
|
||||
window.removeEventListener("keydown", underneath);
|
||||
});
|
||||
|
||||
it("uses a neutral confirm button when not destructive", () => {
|
||||
render(
|
||||
<SurfaceScope>
|
||||
<Asker
|
||||
options={{ title: "Apply?", destructive: false }}
|
||||
onResult={() => {}}
|
||||
/>
|
||||
</SurfaceScope>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("ask"));
|
||||
expect(screen.getByText("common.confirm")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
function Editor({ onBeforeClose }: { onBeforeClose?: () => boolean }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [nested, setNested] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setOpen(true)}>open</button>
|
||||
<InlineView
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Edit folder"
|
||||
onBeforeClose={onBeforeClose}
|
||||
>
|
||||
<span>folder form</span>
|
||||
<button onClick={() => setNested(true)}>nested</button>
|
||||
<InlineView
|
||||
open={nested}
|
||||
onOpenChange={setNested}
|
||||
title="Pick icon"
|
||||
width="wide"
|
||||
>
|
||||
<span>icon grid</span>
|
||||
</InlineView>
|
||||
</InlineView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("InlineView", () => {
|
||||
it("takes over the surface and reports the widening", () => {
|
||||
const onEditingChange = vi.fn();
|
||||
render(
|
||||
<SurfaceScope onEditingChange={onEditingChange}>
|
||||
<Editor />
|
||||
</SurfaceScope>,
|
||||
);
|
||||
expect(onEditingChange).toHaveBeenLastCalledWith(false);
|
||||
fireEvent.click(screen.getByText("open"));
|
||||
expect(screen.getByText("folder form")).toBeTruthy();
|
||||
expect(onEditingChange).toHaveBeenLastCalledWith(true);
|
||||
|
||||
fireEvent.click(screen.getByText("nested"));
|
||||
expect(screen.getByText("icon grid")).toBeTruthy();
|
||||
expect(onEditingChange).toHaveBeenLastCalledWith("wide");
|
||||
});
|
||||
|
||||
it("goes back one view at a time", async () => {
|
||||
render(
|
||||
<SurfaceScope>
|
||||
<Editor />
|
||||
</SurfaceScope>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("open"));
|
||||
fireEvent.click(screen.getByText("nested"));
|
||||
const backs = screen.getAllByLabelText("common.back");
|
||||
await act(async () => {
|
||||
fireEvent.click(backs[backs.length - 1]);
|
||||
});
|
||||
expect(screen.queryByText("icon grid")).toBeNull();
|
||||
expect(screen.getByText("folder form")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("stays open when the close guard says no", async () => {
|
||||
render(
|
||||
<SurfaceScope>
|
||||
<Editor onBeforeClose={() => false} />
|
||||
</SurfaceScope>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("open"));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText("common.back"));
|
||||
});
|
||||
expect(screen.getByText("folder form")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PanePrompt", () => {
|
||||
it("dims the pane by default and closes on a backdrop click", () => {
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<PanePrompt open title="Title" onCancel={onCancel}>
|
||||
<p>body</p>
|
||||
</PanePrompt>,
|
||||
);
|
||||
const card = screen.getByRole("dialog", { name: "Title" });
|
||||
const layer = card.parentElement!;
|
||||
expect(layer.className).toContain("z-50");
|
||||
fireEvent.mouseDown(card);
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
fireEvent.mouseDown(layer);
|
||||
expect(onCancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("covers a connecting surface with an opaque layer", () => {
|
||||
render(
|
||||
<PanePrompt
|
||||
open
|
||||
title="Login"
|
||||
layer="connection"
|
||||
backgroundColor="rgb(1, 2, 3)"
|
||||
/>,
|
||||
);
|
||||
const layer = screen.getByRole("dialog", { name: "Login" }).parentElement!;
|
||||
expect(layer.className).toContain("z-500");
|
||||
expect(layer.style.backgroundColor).toBe("rgb(1, 2, 3)");
|
||||
});
|
||||
|
||||
it("renders nothing when closed", () => {
|
||||
render(<PanePrompt open={false} title="Hidden" />);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -6,7 +6,7 @@ import {
|
||||
screen,
|
||||
} from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { TabStrip } from "@/sidebar/HostManagerTabs";
|
||||
import { TabStrip } from "@/components/tab-strip";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
@@ -29,12 +29,12 @@ describe("VersionBadge", () => {
|
||||
expect(link.textContent).toBe(UPDATE_TEXT);
|
||||
});
|
||||
|
||||
it("keeps the yellow update treatment on the link", () => {
|
||||
it("keeps the warning update treatment on the link", () => {
|
||||
render(<VersionBadge status="requires_update" releaseUrl={RELEASE_URL} />);
|
||||
|
||||
const className = screen.getByRole("link").className;
|
||||
expect(className).toContain("bg-yellow-500/20");
|
||||
expect(className).toContain("text-yellow-400");
|
||||
expect(className).toContain("bg-warning/20");
|
||||
expect(className).toContain("text-warning");
|
||||
expect(className).toContain("hover:underline");
|
||||
});
|
||||
|
||||
@@ -74,7 +74,7 @@ describe("VersionBadge", () => {
|
||||
|
||||
const className = screen.getByRole("link").className;
|
||||
expect(className).toContain("w-fit");
|
||||
expect(className).toContain("bg-yellow-500/20");
|
||||
expect(className).toContain("bg-warning/20");
|
||||
});
|
||||
|
||||
it("applies the className on the inert span too", () => {
|
||||
|
||||
@@ -27,16 +27,17 @@ describe("HostStatusCard", () => {
|
||||
|
||||
const name = screen.getByText(host.name);
|
||||
const ip = screen.getByText(host.ip);
|
||||
const identity = name.parentElement?.parentElement;
|
||||
const row = identity?.parentElement?.parentElement;
|
||||
const metrics = row?.lastElementChild;
|
||||
const row = name.closest("[role=button]");
|
||||
const badge = screen.getByText("hosts.status.offline");
|
||||
|
||||
expect(name.className).toContain("truncate");
|
||||
expect(name.getAttribute("title")).toBe(host.name);
|
||||
expect(ip.className).toContain("truncate");
|
||||
expect(ip.getAttribute("title")).toBe(host.ip);
|
||||
expect(identity?.className).toContain("min-w-0");
|
||||
expect(metrics?.className).toContain("shrink-0");
|
||||
expect(row?.firstElementChild?.nextElementSibling?.className).toContain(
|
||||
"min-w-0",
|
||||
);
|
||||
expect(badge.className).toContain("shrink-0");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { usageBarColor, usageColor } from "@/lib/usage-color";
|
||||
|
||||
describe("usage color ramp", () => {
|
||||
it("uses the brand color below 75", () => {
|
||||
expect(usageColor(10)).toBe("text-accent-brand");
|
||||
expect(usageBarColor(74.9)).toBe("bg-accent-brand");
|
||||
});
|
||||
|
||||
it("warns from 75 and turns destructive from 90", () => {
|
||||
expect(usageColor(75)).toBe("text-warning");
|
||||
expect(usageBarColor(89)).toBe("bg-warning");
|
||||
expect(usageColor(90)).toBe("text-destructive");
|
||||
expect(usageBarColor(100)).toBe("bg-destructive");
|
||||
});
|
||||
|
||||
it("mutes an unknown value", () => {
|
||||
expect(usageColor(null)).toBe("text-muted-foreground");
|
||||
expect(usageBarColor(null)).toBe("bg-muted-foreground/30");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import { ManageList } from "@/manage/ManageList";
|
||||
import type { Credential, Host } from "@/types/ui-types";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const hosts = [
|
||||
{ id: "1", name: "web-01", ip: "10.0.0.1", folder: "Prod", tags: [] },
|
||||
{ id: "2", name: "db-01", ip: "10.0.0.2", folder: "Prod", tags: ["sql"] },
|
||||
] as unknown as Host[];
|
||||
const credentials = [
|
||||
{ id: "c1", name: "deploy", username: "ci", folder: "" },
|
||||
] as Credential[];
|
||||
|
||||
function renderList(overrides: Partial<Parameters<typeof ManageList>[0]> = {}) {
|
||||
const props = {
|
||||
editing: false,
|
||||
mode: "hosts" as const,
|
||||
onMode: vi.fn(),
|
||||
hosts,
|
||||
credentials,
|
||||
canEditInstanceDefaults: false,
|
||||
isOnline: () => false,
|
||||
selectedKey: null,
|
||||
onPickHost: vi.fn(),
|
||||
onPickCredential: vi.fn(),
|
||||
onPickDefaults: vi.fn(),
|
||||
onAdd: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
render(<ManageList {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
describe("ManageList", () => {
|
||||
it("lists hosts under their folder and picks one", () => {
|
||||
const props = renderList();
|
||||
expect(screen.getByText("Prod")).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("db-01"));
|
||||
expect(props.onPickHost).toHaveBeenCalledWith(hosts[1]);
|
||||
});
|
||||
|
||||
it("filters by tags as well as names", () => {
|
||||
renderList();
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "sql" } });
|
||||
expect(screen.queryByText("web-01")).toBeNull();
|
||||
expect(screen.getByText("db-01")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("switches mode and adds", () => {
|
||||
const props = renderList();
|
||||
fireEvent.click(screen.getByText("nav.credentials"));
|
||||
expect(props.onMode).toHaveBeenCalledWith("credentials");
|
||||
fireEvent.click(screen.getByLabelText("hosts.addHost"));
|
||||
expect(props.onAdd).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists levels of defaults", () => {
|
||||
const props = renderList({ mode: "defaults" });
|
||||
fireEvent.click(screen.getByText("hostDefaults.titleUser"));
|
||||
expect(props.onPickDefaults).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "user" }),
|
||||
);
|
||||
expect(screen.getByText("Prod")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultsRows } from "@/manage/defaults-rows";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
|
||||
const t = (key: string) => key;
|
||||
|
||||
describe("defaultsRows", () => {
|
||||
it("lists instance defaults only for those who can edit them", () => {
|
||||
const hosts = [{ folder: "A / B" }, { folder: "" }] as Host[];
|
||||
expect(defaultsRows(hosts, false, t).map((r) => r.key)).toEqual([
|
||||
"user",
|
||||
"folder:A",
|
||||
"folder:A / B",
|
||||
]);
|
||||
expect(defaultsRows(hosts, true, t)[0]).toMatchObject({
|
||||
key: "admin",
|
||||
level: "admin",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateHostForm } from "@/manage/host-validation";
|
||||
|
||||
describe("validateHostForm", () => {
|
||||
it("needs an address", () => {
|
||||
expect(
|
||||
validateHostForm({ ip: " ", sshPort: 22 }, { enableSsh: true }),
|
||||
).toEqual({
|
||||
ip: "manage.errorAddressRequired",
|
||||
});
|
||||
});
|
||||
|
||||
it("checks the SSH port only when SSH is on", () => {
|
||||
expect(
|
||||
validateHostForm({ ip: "h", sshPort: 70000 }, { enableSsh: true }),
|
||||
).toEqual({ sshPort: "manage.errorPortRange" });
|
||||
expect(
|
||||
validateHostForm({ ip: "h", sshPort: 70000 }, { enableSsh: false }),
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
MANAGE_REQUEST_EVENT,
|
||||
requestFromLegacyEvent,
|
||||
requestManage,
|
||||
resetManageRequests,
|
||||
takePendingManageRequest,
|
||||
} from "@/manage/manage-requests";
|
||||
|
||||
afterEach(resetManageRequests);
|
||||
|
||||
describe("manage requests", () => {
|
||||
it("parks a request until the tab claims it, once", () => {
|
||||
const listener = vi.fn();
|
||||
window.addEventListener(MANAGE_REQUEST_EVENT, listener);
|
||||
requestManage({ kind: "host", hostId: "7" });
|
||||
window.removeEventListener(MANAGE_REQUEST_EVENT, listener);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(takePendingManageRequest()).toEqual({ kind: "host", hostId: "7" });
|
||||
expect(takePendingManageRequest()).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps only the latest request", () => {
|
||||
requestManage({ kind: "browse", mode: "credentials" });
|
||||
requestManage({ kind: "credential", credentialId: null });
|
||||
expect(takePendingManageRequest()).toEqual({
|
||||
kind: "credential",
|
||||
credentialId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("translates the old host manager events", () => {
|
||||
expect(requestFromLegacyEvent("host-manager:add-host")).toEqual({
|
||||
kind: "host",
|
||||
hostId: null,
|
||||
draft: undefined,
|
||||
});
|
||||
expect(
|
||||
requestFromLegacyEvent("host-manager:add-host", { name: "web" }),
|
||||
).toEqual({ kind: "host", hostId: null, draft: { name: "web" } });
|
||||
expect(requestFromLegacyEvent("host-manager:edit-host", 12)).toEqual({
|
||||
kind: "host",
|
||||
hostId: "12",
|
||||
});
|
||||
expect(requestFromLegacyEvent("host-manager:edit-host")).toBeNull();
|
||||
expect(requestFromLegacyEvent("host-manager:add-credential")).toEqual({
|
||||
kind: "credential",
|
||||
credentialId: null,
|
||||
});
|
||||
expect(
|
||||
requestFromLegacyEvent("termix:open-host-defaults", { level: "admin" }),
|
||||
).toEqual({ kind: "defaults", level: "admin", folderName: undefined });
|
||||
expect(
|
||||
requestFromLegacyEvent("host-manager:edit-defaults", {
|
||||
level: "folder",
|
||||
folderName: "Prod",
|
||||
}),
|
||||
).toEqual({ kind: "defaults", level: "folder", folderName: "Prod" });
|
||||
expect(requestFromLegacyEvent("something-else")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
allFolderPaths,
|
||||
buildCredentialManageTree,
|
||||
buildHostManageTree,
|
||||
flattenTree,
|
||||
} from "@/manage/manage-tree";
|
||||
import type { Credential, Host } from "@/types/ui-types";
|
||||
|
||||
function host(id: string, folder: string, extra: Partial<Host> = {}): Host {
|
||||
return {
|
||||
id,
|
||||
name: `host-${id}`,
|
||||
username: "root",
|
||||
ip: `10.0.0.${id}`,
|
||||
port: 22,
|
||||
folder,
|
||||
online: false,
|
||||
cpu: null,
|
||||
ram: null,
|
||||
lastAccess: "",
|
||||
authType: "password",
|
||||
...extra,
|
||||
} as Host;
|
||||
}
|
||||
|
||||
describe("manage tree", () => {
|
||||
it("nests folder paths and sub-hosts", () => {
|
||||
const tree = buildHostManageTree([
|
||||
host("1", "Prod / EU"),
|
||||
host("2", "Prod"),
|
||||
host("3", "", { parentHostId: "2" }),
|
||||
]);
|
||||
expect(allFolderPaths(tree)).toEqual(["Prod", "Prod / EU"]);
|
||||
const prod = tree.folders[0];
|
||||
expect(prod.folders[0].items[0].id).toBe("1");
|
||||
expect(prod.items[0].children[0].id).toBe("3");
|
||||
});
|
||||
|
||||
it("drops a parent loop back to folder placement", () => {
|
||||
const tree = buildHostManageTree([
|
||||
host("1", "", { parentHostId: "2" }),
|
||||
host("2", "", { parentHostId: "1" }),
|
||||
]);
|
||||
const total =
|
||||
tree.items.length + tree.items.flatMap((i) => i.children).length;
|
||||
expect(total).toBe(2);
|
||||
});
|
||||
|
||||
it("uses the online check it is given", () => {
|
||||
const tree = buildHostManageTree([host("1", "")], () => true);
|
||||
expect(tree.items[0].online).toBe(true);
|
||||
});
|
||||
|
||||
it("opens folders holding a search match and hides the rest", () => {
|
||||
const tree = buildHostManageTree([
|
||||
host("1", "Prod / EU", { tags: ["db"] }),
|
||||
host("2", "Staging"),
|
||||
]);
|
||||
const rows = flattenTree(tree, new Set(), "db");
|
||||
expect(
|
||||
rows.map((r) => (r.kind === "folder" ? r.folder.path : r.item.id)),
|
||||
).toEqual(["Prod", "Prod / EU", "1"]);
|
||||
});
|
||||
|
||||
it("respects collapsed folders without a search", () => {
|
||||
const tree = buildHostManageTree([host("1", "Prod")]);
|
||||
expect(flattenTree(tree, new Set(), "")).toHaveLength(1);
|
||||
expect(flattenTree(tree, new Set(["Prod"]), "")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("groups credentials by folder", () => {
|
||||
const tree = buildCredentialManageTree([
|
||||
{ id: "c1", name: "deploy", username: "ci", folder: "Keys" },
|
||||
{ id: "c2", name: "root", username: "root", folder: "" },
|
||||
] as Credential[]);
|
||||
expect(tree.folders[0].items[0].note).toBe("ci");
|
||||
expect(tree.items[0].id).toBe("c2");
|
||||
});
|
||||
});
|
||||
@@ -155,11 +155,7 @@ describe("FeatureSettingsSection", () => {
|
||||
const save = screen.getByText("common.save").closest("button")!;
|
||||
expect(save.disabled).toBe(true);
|
||||
|
||||
fireEvent.click(
|
||||
screen
|
||||
.getAllByRole("button")
|
||||
.find((b) => b !== save && b.textContent === "")!,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("switch"));
|
||||
fireEvent.click(save);
|
||||
|
||||
await waitFor(() =>
|
||||
|
||||
+13
-4
@@ -9,7 +9,16 @@ vi.mock("@/api/open-tabs-api", async (importOriginal) => ({
|
||||
}));
|
||||
vi.mock("@/main-axios", () => ({ saveUserPreferences: vi.fn() }));
|
||||
|
||||
const { KeybindingsDialog } = await import("../../sidebar/KeybindingsDialog");
|
||||
const { KeybindingsSettings } = await import("@/settings/KeybindingsSettings");
|
||||
const { SurfaceScope } = await import("@/components/surface/surface-scope");
|
||||
|
||||
function renderSettings() {
|
||||
return render(
|
||||
<SurfaceScope>
|
||||
<KeybindingsSettings />
|
||||
</SurfaceScope>,
|
||||
);
|
||||
}
|
||||
const {
|
||||
registerKeybindingAction,
|
||||
registerKeybindingDefault,
|
||||
@@ -31,7 +40,7 @@ const combo = {
|
||||
meta: false,
|
||||
};
|
||||
|
||||
describe("KeybindingsDialog", () => {
|
||||
describe("KeybindingsSettings", () => {
|
||||
it("lists registered defaults and keeps a binding whose plugin is off", async () => {
|
||||
registerKeybindingDefault({
|
||||
id: "default-x",
|
||||
@@ -49,7 +58,7 @@ describe("KeybindingsDialog", () => {
|
||||
updatedAt: "",
|
||||
},
|
||||
]);
|
||||
render(<KeybindingsDialog open onOpenChange={() => {}} />);
|
||||
renderSettings();
|
||||
expect(await screen.findByText("sample:builtInX")).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByText("newUi.sidebar.keybindings.unavailableAction"),
|
||||
@@ -64,7 +73,7 @@ describe("KeybindingsDialog", () => {
|
||||
scope: "session",
|
||||
editor: () => <span>sample editor</span>,
|
||||
});
|
||||
render(<KeybindingsDialog open onOpenChange={() => {}} />);
|
||||
renderSettings();
|
||||
fireEvent.click(
|
||||
await screen.findByText("newUi.sidebar.keybindings.addBinding"),
|
||||
);
|
||||
@@ -55,7 +55,7 @@ describe("field types", () => {
|
||||
{ enabled: false },
|
||||
);
|
||||
|
||||
const toggle = screen.getByRole("button");
|
||||
const toggle = screen.getByRole("switch");
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(setValue).toHaveBeenCalledWith("enabled", true);
|
||||
|
||||
@@ -297,7 +297,7 @@ describe("HostPluginSections", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button")[0]);
|
||||
fireEvent.click(screen.getAllByRole("switch")[0]);
|
||||
|
||||
expect(setValue).toHaveBeenCalledWith("docker", "enableDocker", true);
|
||||
});
|
||||
@@ -373,7 +373,7 @@ describe("HostFeatureFields", () => {
|
||||
expect(screen.getAllByText("Docker")).toHaveLength(1);
|
||||
expect(screen.queryByText("Warpgate")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button")[0]);
|
||||
fireEvent.click(screen.getAllByRole("switch")[0]);
|
||||
const patch = updateForm.mock.calls[0][0];
|
||||
expect(patch(form).pluginSettings).toEqual({
|
||||
docker: { enableDocker: true },
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import en from "@/locales/en.json";
|
||||
import {
|
||||
CORE_SETTINGS_PAGES,
|
||||
searchSettingsPages,
|
||||
visibleSettingsPages,
|
||||
} from "@/settings/settings-pages";
|
||||
import { SETTINGS_SEARCH_INDEX } from "@/settings/settings-search-index";
|
||||
|
||||
function translate(key: string): string {
|
||||
let node: unknown = en;
|
||||
for (const part of key.split(".")) {
|
||||
node = (node as Record<string, unknown> | undefined)?.[part];
|
||||
}
|
||||
return typeof node === "string" ? node : key;
|
||||
}
|
||||
|
||||
describe("settings pages", () => {
|
||||
it("hides admin pages from users and browser-only pages on desktop", () => {
|
||||
const user = visibleSettingsPages(CORE_SETTINGS_PAGES, {
|
||||
isAdmin: false,
|
||||
isElectron: true,
|
||||
}).map((page) => page.id);
|
||||
expect(user).not.toContain("admin-users");
|
||||
expect(user).not.toContain("security");
|
||||
expect(user).toContain("account");
|
||||
|
||||
const admin = visibleSettingsPages(CORE_SETTINGS_PAGES, {
|
||||
isAdmin: true,
|
||||
isElectron: false,
|
||||
}).map((page) => page.id);
|
||||
expect(admin).toContain("admin-users");
|
||||
expect(admin).toContain("security");
|
||||
});
|
||||
|
||||
it("finds a page by a label shown on it, not only its name", () => {
|
||||
const hits = searchSettingsPages(CORE_SETTINGS_PAGES, "theme", translate);
|
||||
expect(hits.some((hit) => hit.page.id === "appearance")).toBe(true);
|
||||
expect(
|
||||
hits.find((hit) => hit.page.id === "appearance")?.match,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("lists every page for an empty query", () => {
|
||||
expect(
|
||||
searchSettingsPages(CORE_SETTINGS_PAGES, " ", translate),
|
||||
).toHaveLength(CORE_SETTINGS_PAGES.length);
|
||||
});
|
||||
|
||||
it("gives every core page labels to search, all of them real keys", () => {
|
||||
for (const page of CORE_SETTINGS_PAGES) {
|
||||
expect(translate(page.labelKey)).not.toBe(page.labelKey);
|
||||
expect(page.searchKeys.length).toBeGreaterThan(0);
|
||||
}
|
||||
const missing = Object.values(SETTINGS_SEARCH_INDEX)
|
||||
.flat()
|
||||
.filter((key) => translate(key) === key);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { Bell, Boxes } from "lucide-react";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-permissions", () => ({
|
||||
usePermissions: () => ({ has: () => true, loaded: true }),
|
||||
}));
|
||||
vi.mock("@/main-axios", () => ({
|
||||
getUserPreferences: vi.fn(async () => ({})),
|
||||
saveUserPreferences: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
import { MobileBar, pickPrimaryItems } from "@/shell/MobileBar";
|
||||
import {
|
||||
RAIL_ITEMS,
|
||||
registerRailItem,
|
||||
resetRegisteredRailItems,
|
||||
} from "@/sidebar/rail-items";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetRegisteredRailItems();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
function renderBar(onRailClick = vi.fn()) {
|
||||
render(
|
||||
<MobileBar
|
||||
railView="hosts"
|
||||
sidebarOpen={false}
|
||||
username="alice"
|
||||
onRailClick={onRailClick}
|
||||
onOpenTab={vi.fn()}
|
||||
onOpenSettings={vi.fn()}
|
||||
onOpenPalette={vi.fn()}
|
||||
onLogout={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
return onRailClick;
|
||||
}
|
||||
|
||||
describe("MobileBar", () => {
|
||||
it("puts flagged items first and caps the bar at four", () => {
|
||||
const ids = pickPrimaryItems(RAIL_ITEMS).map((item) => item.id);
|
||||
expect(ids.slice(0, 2)).toEqual(["hosts", "quick-connect"]);
|
||||
expect(ids).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("opens a destination from the bar", () => {
|
||||
const onRailClick = renderBar();
|
||||
fireEvent.click(screen.getByLabelText("nav.hosts"));
|
||||
expect(onRailClick).toHaveBeenCalledWith("hosts");
|
||||
});
|
||||
|
||||
it("lists footer items and settings behind More", () => {
|
||||
registerRailItem({
|
||||
id: "inbox",
|
||||
icon: Bell,
|
||||
labelKey: "test.inbox",
|
||||
placement: "footer",
|
||||
pluginId: "demo",
|
||||
});
|
||||
registerRailItem({
|
||||
id: "boxes",
|
||||
icon: Boxes,
|
||||
labelKey: "test.boxes",
|
||||
group: "objects",
|
||||
pluginId: "demo",
|
||||
});
|
||||
renderBar();
|
||||
fireEvent.click(screen.getByLabelText("common.more"));
|
||||
expect(screen.getByText("test.inbox")).toBeTruthy();
|
||||
expect(screen.getByText("nav.settings")).toBeTruthy();
|
||||
expect(screen.getByText("nav.group.objects")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("leaves out hidden items", () => {
|
||||
localStorage.setItem("hiddenRailTabs", JSON.stringify(["hosts"]));
|
||||
renderBar();
|
||||
expect(screen.queryByLabelText("nav.hosts")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { AdminUserManagePanel } from "../../sidebar/AdminUserManagePanel";
|
||||
import { SurfaceScope } from "@/components/surface/surface-scope";
|
||||
import type { AdminUser } from "../../sidebar/AdminManagementSections";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
@@ -60,14 +61,16 @@ function renderPanel(
|
||||
callbacks: Partial<Record<string, () => void>> = {},
|
||||
) {
|
||||
return render(
|
||||
<AdminUserManagePanel
|
||||
user={user}
|
||||
roles={[]}
|
||||
onBack={callbacks.onBack ?? vi.fn()}
|
||||
onOpenHostTab={callbacks.onOpenHostTab as never}
|
||||
onUserDeleted={callbacks.onUserDeleted ?? vi.fn()}
|
||||
onSecondFactorsReset={callbacks.onSecondFactorsReset ?? vi.fn()}
|
||||
/>,
|
||||
<SurfaceScope>
|
||||
<AdminUserManagePanel
|
||||
user={user}
|
||||
roles={[]}
|
||||
onBack={callbacks.onBack ?? vi.fn()}
|
||||
onOpenHostTab={callbacks.onOpenHostTab as never}
|
||||
onUserDeleted={callbacks.onUserDeleted ?? vi.fn()}
|
||||
onSecondFactorsReset={callbacks.onSecondFactorsReset ?? vi.fn()}
|
||||
/>
|
||||
</SurfaceScope>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -193,7 +196,7 @@ describe("AdminUserManagePanel", () => {
|
||||
|
||||
await userEvent.click(screen.getByText("admin.manageTabDanger"));
|
||||
await userEvent.click(screen.getByText("admin.deleteUser"));
|
||||
await userEvent.click(screen.getByText("common.confirm"));
|
||||
await userEvent.click(screen.getByText("common.delete"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.deleteUser).toHaveBeenCalledWith("bob");
|
||||
|
||||
@@ -13,6 +13,11 @@ vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/main-axios", () => ({
|
||||
getUserPreferences: vi.fn(async () => ({ storageMode: "local" })),
|
||||
saveUserPreferences: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-permissions", () => ({
|
||||
usePermissions: () => ({ has: () => true, loaded: true }),
|
||||
}));
|
||||
@@ -30,6 +35,7 @@ function renderRail(onRailClick = vi.fn()) {
|
||||
username="alice"
|
||||
isAdmin={false}
|
||||
onRailClick={onRailClick}
|
||||
onOpenSettings={vi.fn()}
|
||||
onLogout={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
@@ -55,7 +61,7 @@ describe("AppRail", () => {
|
||||
const onRailClick = renderRail();
|
||||
|
||||
const inbox = screen.getByTitle("test.inbox");
|
||||
const profile = screen.getAllByTitle("nav.userProfile")[0];
|
||||
const profile = screen.getAllByTitle("nav.openSettings")[0];
|
||||
const boxes = screen.getByTitle("test.boxes");
|
||||
// Footer items render after the main list and before the profile.
|
||||
expect(
|
||||
@@ -82,6 +88,51 @@ describe("AppRail", () => {
|
||||
renderRail();
|
||||
expect(screen.queryByTitle("test.inbox")).toBeNull();
|
||||
});
|
||||
|
||||
it("groups items into bands with a labelled divider", () => {
|
||||
registerRailItem({
|
||||
id: "fleets",
|
||||
icon: Boxes,
|
||||
labelKey: "test.fleets",
|
||||
group: "objects",
|
||||
pluginId: "demo",
|
||||
});
|
||||
renderRail();
|
||||
expect(screen.getByText("nav.group.tools")).toBeTruthy();
|
||||
const fleets = screen.getByTitle("test.fleets");
|
||||
const connections = screen.getByTitle("nav.connections");
|
||||
expect(
|
||||
fleets.compareDocumentPosition(connections) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("lists hidden items and brings one back", async () => {
|
||||
localStorage.setItem("hiddenRailTabs", JSON.stringify(["connections"]));
|
||||
renderRail();
|
||||
expect(screen.queryByTitle("nav.connections")).toBeNull();
|
||||
await userEvent.click(screen.getByTitle("nav.hiddenCount"));
|
||||
await userEvent.click(screen.getByTitle("nav.showInRail"));
|
||||
expect(JSON.parse(localStorage.getItem("hiddenRailTabs")!)).toEqual([]);
|
||||
expect(await screen.findByTitle("nav.connections")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens settings from the account button", async () => {
|
||||
const onOpenSettings = vi.fn();
|
||||
render(
|
||||
<AppRail
|
||||
railView="hosts"
|
||||
sidebarOpen={false}
|
||||
username="alice"
|
||||
isAdmin
|
||||
onRailClick={vi.fn()}
|
||||
onOpenSettings={onOpenSettings}
|
||||
onLogout={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByLabelText("nav.openSettings"));
|
||||
expect(onOpenSettings).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RailBadge", () => {
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MultiPanelHint } from "../../sidebar/MultiPanelHint";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const STORAGE_KEY = "termix_multiPanelHintDismissed";
|
||||
|
||||
function renderHint(props: Partial<Parameters<typeof MultiPanelHint>[0]> = {}) {
|
||||
return render(
|
||||
<MultiPanelHint
|
||||
canPromote
|
||||
canRightDock
|
||||
onOpenAsTab={vi.fn()}
|
||||
onOpenInRightDock={vi.fn()}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MultiPanelHint", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("shows both actions to a user who has not dismissed it", () => {
|
||||
renderHint();
|
||||
expect(screen.getByText("nav.multiPanelHintTitle")).toBeTruthy();
|
||||
expect(screen.getByText("nav.openAsTab")).toBeTruthy();
|
||||
expect(screen.getByText("nav.openInRightDock")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("spells out how to do each action without the hint", () => {
|
||||
// The whole point of the hint is that the knowledge outlives it, so the
|
||||
// shortcut and the permanent button must both be named.
|
||||
const { container } = renderHint();
|
||||
expect(container.textContent).toContain("nav.multiPanelHintTabHow");
|
||||
expect(container.textContent).toContain("nav.multiPanelHintDockHow");
|
||||
});
|
||||
|
||||
it("stays hidden once dismissed", () => {
|
||||
localStorage.setItem(STORAGE_KEY, "true");
|
||||
renderHint();
|
||||
expect(screen.queryByText("nav.multiPanelHintTitle")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides and persists the choice when dismissed", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHint();
|
||||
await user.click(screen.getByLabelText("common.dismiss"));
|
||||
|
||||
expect(screen.queryByText("nav.multiPanelHintTitle")).toBeNull();
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe("true");
|
||||
});
|
||||
|
||||
it("renders nothing when the panel supports neither action", () => {
|
||||
renderHint({ canPromote: false, canRightDock: false });
|
||||
expect(screen.queryByText("nav.multiPanelHintTitle")).toBeNull();
|
||||
});
|
||||
|
||||
it("only offers the actions the panel actually supports", () => {
|
||||
renderHint({ canPromote: false });
|
||||
expect(screen.queryByText("nav.openAsTab")).toBeNull();
|
||||
expect(screen.getByText("nav.openInRightDock")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("runs the action and dismisses so it does not come back", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenAsTab = vi.fn();
|
||||
renderHint({ onOpenAsTab });
|
||||
|
||||
await user.click(screen.getByText("nav.openAsTab"));
|
||||
|
||||
expect(onOpenAsTab).toHaveBeenCalledOnce();
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe("true");
|
||||
});
|
||||
});
|
||||
@@ -88,7 +88,7 @@ describe.each([
|
||||
setValue={setValue}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
fireEvent.click(screen.getByRole("switch"));
|
||||
expect(setValue).toHaveBeenCalledWith(manifest.id, key, !saved);
|
||||
// These are the values HostEditor writes through each plugin's host settings API.
|
||||
expect(form.pluginSettings).toEqual(pluginSettings);
|
||||
|
||||
@@ -69,7 +69,7 @@ describe("host sharing settings on reopening the editor", () => {
|
||||
);
|
||||
|
||||
// The toggle starts from the server value, including false despite its true default.
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
fireEvent.click(screen.getByRole("switch"));
|
||||
expect(setValue).toHaveBeenCalledWith(
|
||||
"session-sharing",
|
||||
"allowSessionSharing",
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("TOTPDialog", () => {
|
||||
it("submits the code", () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(
|
||||
<TOTPDialog isOpen prompt="" onSubmit={onSubmit} onCancel={vi.fn()} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "sshAuth.totpRequired" }),
|
||||
).toBeTruthy();
|
||||
fireEvent.change(screen.getByPlaceholderText("000000"), {
|
||||
target: { value: "123456" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "sshAuth.totpVerify" }));
|
||||
expect(onSubmit).toHaveBeenCalledWith("123456");
|
||||
});
|
||||
|
||||
it("sends an empty answer for a push prompt and then waits", () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(
|
||||
<TOTPDialog
|
||||
isOpen
|
||||
prompt=""
|
||||
mode="push"
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "sshAuth.mfaSendRequest" }),
|
||||
);
|
||||
expect(onSubmit).toHaveBeenCalledWith("");
|
||||
expect(screen.getByText("sshAuth.mfaWaitingApproval")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("cancels", () => {
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<TOTPDialog isOpen prompt="" onSubmit={vi.fn()} onCancel={onCancel} />,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.cancel" }));
|
||||
expect(onCancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders nothing when closed", () => {
|
||||
render(
|
||||
<TOTPDialog
|
||||
isOpen={false}
|
||||
prompt=""
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user