feat: port the demo redesign with Manage and Settings tabs

This commit is contained in:
LukeGus committed 2026-10-05 16:23:21 -05:00
1 parent f8151cb885
commit 35c954b79a
128 files changed
+13262 -8143

No files matched your search

+4 -6
View File
@@ -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();
});
});
@@ -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");
});
});
+21
View File
@@ -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");
});
});
+72
View File
@@ -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();
});
});
+20
View File
@@ -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();
});
});
+80
View File
@@ -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(() =>
@@ -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([]);
});
});
+85
View File
@@ -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");
+52 -1
View File
@@ -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();
});
});