feat: give plugins their own settings and one settings screen

This commit is contained in:
LukeGus committed 2026-09-22 18:31:19 -05:00
1 parent cc15559ea1
commit 49f0286a14
66 files changed
+36497 -451

No files matched your search

@@ -0,0 +1,297 @@
/**
* The schema-driven settings form.
*
* A plugin declares fields and core draws them, so the thing worth proving is
* that every declared type renders a usable control, reports what the user
* typed, and that a secret's value never appears in the DOM.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { PluginSettingsField } from "@/api/plugins-api";
import { SettingsFieldRow } from "@/settings/SettingsFields";
import { isFieldActive } from "@/settings/settings-fields-util";
import {
registerSettingsComponent,
resetSettingsComponents,
} from "@/settings/settings-components";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
function renderField(
field: PluginSettingsField,
values: Record<string, unknown> = {},
options: { running?: boolean } = {},
) {
const setValue = vi.fn();
render(
<SettingsFieldRow
pluginId="sample"
field={field}
values={values}
setValue={setValue}
running={options.running ?? true}
/>,
);
return { setValue };
}
beforeEach(() => {
resetSettingsComponents();
});
afterEach(() => {
cleanup();
});
describe("field types", () => {
it("renders a boolean as a switch and reports the toggle", () => {
const { setValue } = renderField(
{ key: "enabled", type: "boolean", labelKey: "enabled.label" },
{ enabled: false },
);
const toggle = screen.getByRole("button");
fireEvent.click(toggle);
expect(setValue).toHaveBeenCalledWith("enabled", true);
});
it("renders a string as a text box and reports what was typed", () => {
const { setValue } = renderField(
{ key: "baseUrl", type: "string", labelKey: "baseUrl.label" },
{ baseUrl: "https://a" },
);
const input = screen.getByDisplayValue("https://a");
fireEvent.change(input, { target: { value: "https://b" } });
expect(setValue).toHaveBeenCalledWith("baseUrl", "https://b");
});
it("renders a number with its declared bounds", () => {
const { setValue } = renderField(
{ key: "retries", type: "number", labelKey: "k", min: 0, max: 5 },
{ retries: 2 },
);
const input = screen.getByDisplayValue("2") as HTMLInputElement;
expect(input.getAttribute("min")).toBe("0");
expect(input.getAttribute("max")).toBe("5");
fireEvent.change(input, { target: { value: "4" } });
expect(setValue).toHaveBeenCalledWith("retries", 4);
});
it("clears a number to null rather than to zero", () => {
const { setValue } = renderField(
{ key: "retries", type: "number", labelKey: "k" },
{ retries: 2 },
);
fireEvent.change(screen.getByDisplayValue("2"), { target: { value: "" } });
expect(setValue).toHaveBeenCalledWith("retries", null);
});
it("renders a textarea", () => {
const { setValue } = renderField(
{ key: "notes", type: "textarea", labelKey: "k" },
{ notes: "hello" },
);
fireEvent.change(screen.getByDisplayValue("hello"), {
target: { value: "goodbye" },
});
expect(setValue).toHaveBeenCalledWith("notes", "goodbye");
});
it("renders json as editable text", () => {
renderField({ key: "raw", type: "json", labelKey: "k" }, { raw: { a: 1 } });
expect(screen.getByDisplayValue(/"a": 1/)).toBeTruthy();
});
it("renders a multiselect option per choice and reports a selection", () => {
const { setValue } = renderField(
{
key: "tags",
type: "multiselect",
labelKey: "k",
options: [
{ value: "a", labelKey: "opt.a" },
{ value: "b", labelKey: "opt.b" },
],
},
{ tags: ["a"] },
);
const boxes = screen.getAllByRole("checkbox");
expect(boxes).toHaveLength(2);
fireEvent.click(boxes[1]);
expect(setValue).toHaveBeenCalledWith("tags", ["a", "b"]);
});
it("removes a multiselect value when unticked", () => {
const { setValue } = renderField(
{
key: "tags",
type: "multiselect",
labelKey: "k",
options: [
{ value: "a", labelKey: "opt.a" },
{ value: "b", labelKey: "opt.b" },
],
},
{ tags: ["a", "b"] },
);
fireEvent.click(screen.getAllByRole("checkbox")[0]);
expect(setValue).toHaveBeenCalledWith("tags", ["b"]);
});
it("disables every control while the plugin is not running", () => {
renderField(
{ key: "baseUrl", type: "string", labelKey: "k" },
{ baseUrl: "https://a" },
{ running: false },
);
expect(
(screen.getByDisplayValue("https://a") as HTMLInputElement).disabled,
).toBe(true);
});
});
describe("secrets", () => {
const field: PluginSettingsField = {
key: "apiKey",
type: "secret",
labelKey: "apiKey.label",
};
it("shows a stored secret as set, without its value", () => {
renderField(field, { apiKey: { set: true } });
expect(screen.getByText("settings.secretSet")).toBeTruthy();
expect(document.body.textContent).not.toContain("tskey");
});
it("shows an unset secret as not set", () => {
renderField(field, { apiKey: { set: false } });
expect(screen.getByText("settings.secretNotSet")).toBeTruthy();
});
it("switches to an input when replacing", () => {
const { setValue } = renderField(field, { apiKey: { set: true } });
fireEvent.click(screen.getByText("settings.secretReplace"));
expect(setValue).toHaveBeenCalledWith("apiKey", "");
});
it("takes a new value once editing", () => {
const { setValue } = renderField(field, { apiKey: "" });
// A password input has no textbox role, so it is found by its type.
const input = document.querySelector(
'input[type="password"]',
) as HTMLInputElement;
fireEvent.change(input, { target: { value: "tskey-new" } });
expect(setValue).toHaveBeenCalledWith("apiKey", "tskey-new");
});
it("restores the redacted marker on cancel, so saving changes nothing", () => {
const { setValue } = renderField(field, { apiKey: "half-typed" });
fireEvent.click(screen.getByText("common.cancel"));
expect(setValue).toHaveBeenCalledWith("apiKey", { set: false });
});
});
describe("custom fields", () => {
it("renders a registered component", () => {
registerSettingsComponent("sample", "devices", () => (
<div>custom-component-here</div>
));
renderField({ key: "devices", type: "custom", component: "devices" });
expect(screen.getByText("custom-component-here")).toBeTruthy();
});
it("renders nothing when no component is registered", () => {
const { container } = render(
<SettingsFieldRow
pluginId="sample"
field={{ key: "devices", type: "custom", component: "missing" }}
values={{}}
setValue={vi.fn()}
running
/>,
);
expect(container.innerHTML).toBe("");
});
it("keeps two plugins' components apart", () => {
registerSettingsComponent("sample", "shared", () => <div>mine</div>);
registerSettingsComponent("other", "shared", () => <div>theirs</div>);
renderField({ key: "shared", type: "custom", component: "shared" });
expect(screen.getByText("mine")).toBeTruthy();
expect(screen.queryByText("theirs")).toBeNull();
});
});
describe("errors", () => {
it("shows a per-field message from a rejected save", () => {
render(
<SettingsFieldRow
pluginId="sample"
field={{ key: "retries", type: "number", labelKey: "k" }}
values={{ retries: 99 }}
setValue={vi.fn()}
running
error='"retries" must be at most 5'
/>,
);
expect(screen.getByText('"retries" must be at most 5')).toBeTruthy();
});
});
describe("isFieldActive", () => {
const gated: PluginSettingsField = {
key: "socketPath",
type: "string",
labelKey: "k",
requires: "enableDocker",
};
it("is inactive while the named boolean is off", () => {
expect(isFieldActive(gated, { enableDocker: false })).toBe(false);
expect(isFieldActive(gated, {})).toBe(false);
});
it("is active once the named boolean is on", () => {
expect(isFieldActive(gated, { enableDocker: true })).toBe(true);
});
it("is always active without a requires", () => {
expect(isFieldActive({ key: "a", type: "string", labelKey: "k" }, {})).toBe(
true,
);
});
});
@@ -0,0 +1,185 @@
/**
* The one Settings screen.
*
* What matters here is the navigation: which bands appear for whom, that a
* plugin's page shows up only when it has settings that user could change, and
* that the nav collapses rather than disappearing on a phone.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import type { PluginSummary } from "@/api/plugins-api";
const { getPluginsMock } = vi.hoisted(() => ({ getPluginsMock: vi.fn() }));
vi.mock("@/api/plugins-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/api/plugins-api")>()),
getPlugins: getPluginsMock,
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
// The two core panels are large and pull in most of the app; this screen only
// has to decide which one to mount.
vi.mock("@/sidebar/UserProfilePanel", () => ({
UserProfilePanel: () => <div>profile-panel</div>,
}));
vi.mock("@/sidebar/AdminSettingsPanel", () => ({
AdminSettingsPanel: () => <div>admin-panel</div>,
}));
vi.mock("@/settings/legacy-settings-components", () => ({
registerLegacySettingsComponents: vi.fn(),
}));
const { SettingsScreen } = await import("@/settings/SettingsScreen");
function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
return {
id: "tailscale",
name: "Tailscale",
version: "1.0.0",
enabled: true,
state: "active",
contributes: {
settings: { admin: [{ key: "apiKey", type: "secret", labelKey: "k" }] },
},
...overrides,
} as PluginSummary;
}
beforeEach(() => {
getPluginsMock.mockReset();
getPluginsMock.mockResolvedValue([]);
});
afterEach(() => {
cleanup();
});
describe("navigation bands", () => {
it("always shows the You band", async () => {
render(<SettingsScreen />);
expect(await screen.findByText("settings.bandYou")).toBeTruthy();
});
it("shows the Instance band only to an admin", async () => {
const { unmount } = render(<SettingsScreen isAdmin={false} />);
await waitFor(() => expect(getPluginsMock).toHaveBeenCalled());
expect(screen.queryByText("settings.bandInstance")).toBeNull();
unmount();
render(<SettingsScreen isAdmin />);
expect(await screen.findByText("settings.bandInstance")).toBeTruthy();
});
it("shows no Plugins band when nothing declares settings", async () => {
render(<SettingsScreen isAdmin />);
await waitFor(() => expect(getPluginsMock).toHaveBeenCalled());
expect(screen.queryByText("settings.bandPlugins")).toBeNull();
});
it("lists a plugin with admin settings for an admin", async () => {
getPluginsMock.mockResolvedValue([plugin()]);
render(<SettingsScreen isAdmin />);
expect(await screen.findByText("settings.bandPlugins")).toBeTruthy();
expect(screen.getByText("Tailscale")).toBeTruthy();
});
it("hides an admin-only plugin page from a non-admin", async () => {
getPluginsMock.mockResolvedValue([plugin()]);
render(<SettingsScreen isAdmin={false} />);
await waitFor(() => expect(getPluginsMock).toHaveBeenCalled());
expect(screen.queryByText("Tailscale")).toBeNull();
});
it("shows a plugin with user settings to everyone", async () => {
getPluginsMock.mockResolvedValue([
plugin({
contributes: {
settings: { user: [{ key: "theme", type: "string", labelKey: "k" }] },
},
}),
]);
render(<SettingsScreen isAdmin={false} />);
expect(await screen.findByText("Tailscale")).toBeTruthy();
});
it("hides a disabled plugin's page", async () => {
getPluginsMock.mockResolvedValue([plugin({ enabled: false })]);
render(<SettingsScreen isAdmin />);
await waitFor(() => expect(getPluginsMock).toHaveBeenCalled());
expect(screen.queryByText("Tailscale")).toBeNull();
});
it("keeps working when the plugin list cannot be loaded", async () => {
getPluginsMock.mockRejectedValue(new Error("offline"));
render(<SettingsScreen isAdmin />);
expect(await screen.findByText("settings.bandYou")).toBeTruthy();
});
});
describe("initial section", () => {
it("lands on the profile panel by default", async () => {
render(<SettingsScreen />);
expect(await screen.findByText("profile-panel")).toBeTruthy();
});
it("lands on the admin panel when opened from the admin rail entry", async () => {
render(<SettingsScreen isAdmin initialSection="admin" />);
expect(await screen.findByText("admin-panel")).toBeTruthy();
});
it("does not mount the admin panel for a non-admin", async () => {
render(<SettingsScreen isAdmin={false} initialSection="admin" />);
await waitFor(() => expect(getPluginsMock).toHaveBeenCalled());
expect(screen.queryByText("admin-panel")).toBeNull();
});
it("switches section when the rail entry changes", async () => {
const { rerender } = render(
<SettingsScreen isAdmin initialSection="profile" />,
);
expect(await screen.findByText("profile-panel")).toBeTruthy();
rerender(<SettingsScreen isAdmin initialSection="admin" />);
expect(await screen.findByText("admin-panel")).toBeTruthy();
});
});
describe("phone layout", () => {
it("offers a disclosure button for the collapsed nav", async () => {
render(<SettingsScreen />);
await screen.findByText("settings.bandYou");
const toggle = document.querySelector("button[aria-expanded]");
expect(toggle).toBeTruthy();
expect(toggle?.getAttribute("aria-expanded")).toBe("false");
});
it("hides the nav below md and shows it from md up", async () => {
render(<SettingsScreen />);
await screen.findByText("settings.bandYou");
const nav = document.querySelector("nav")!;
expect(nav.className).toContain("hidden");
expect(nav.className).toContain("md:flex");
});
});
@@ -0,0 +1,193 @@
/**
* The host editor's Plugins group.
*
* No bundled plugin declares host settings yet, so a fixture stands in for one.
* The rule worth protecting is the empty case: with nothing to show, the tab
* must not be registered at all rather than appearing blank.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { PluginSummary } from "@/api/plugins-api";
import {
HOST_PLUGINS_TAB_ID,
syncHostPluginsTab,
} from "@/settings/host-plugins-tab";
import {
getRegisteredHostEditorTab,
unregisterHostEditorTab,
} from "@/sidebar/HostManagerTabs";
import { HostPluginSections } from "@/settings/HostPluginSections";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
return {
id: "docker",
name: "Docker",
version: "1.0.0",
enabled: true,
state: "active",
icon: "Box",
contributes: {
settings: {
host: {
enableKey: "enableDocker",
enableLabelKey: "enableDocker.label",
fields: [
{ key: "socketPath", type: "string", labelKey: "socket.label" },
],
},
},
},
...overrides,
} as PluginSummary;
}
beforeEach(() => {
unregisterHostEditorTab(HOST_PLUGINS_TAB_ID);
});
afterEach(() => {
cleanup();
unregisterHostEditorTab(HOST_PLUGINS_TAB_ID);
});
describe("syncHostPluginsTab", () => {
it("registers the tab when a plugin contributes host settings", () => {
expect(syncHostPluginsTab([plugin()])).toBe(true);
expect(getRegisteredHostEditorTab(HOST_PLUGINS_TAB_ID)).toBeDefined();
});
it("registers nothing when no plugin contributes", () => {
expect(syncHostPluginsTab([])).toBe(false);
expect(getRegisteredHostEditorTab(HOST_PLUGINS_TAB_ID)).toBeUndefined();
});
it("ignores a plugin with no host settings", () => {
const other = plugin({
id: "ai",
contributes: { settings: { admin: [] } },
});
expect(syncHostPluginsTab([other])).toBe(false);
});
it("ignores a disabled plugin", () => {
expect(syncHostPluginsTab([plugin({ enabled: false })])).toBe(false);
});
it("removes the tab once the last contributor goes", () => {
syncHostPluginsTab([plugin()]);
expect(getRegisteredHostEditorTab(HOST_PLUGINS_TAB_ID)).toBeDefined();
syncHostPluginsTab([plugin({ enabled: false })]);
expect(getRegisteredHostEditorTab(HOST_PLUGINS_TAB_ID)).toBeUndefined();
});
it("registers for a plugin declaring only an enable switch", () => {
const bare = plugin({
contributes: {
settings: {
host: { enableKey: "enableThing", enableLabelKey: "k", fields: [] },
},
},
});
expect(syncHostPluginsTab([bare])).toBe(true);
});
});
describe("HostPluginSections", () => {
it("shows the enable switch and hides the fields until it is on", () => {
const setValue = vi.fn();
render(
<HostPluginSections
plugins={[plugin()]}
values={{ docker: { enableDocker: false } }}
setValue={setValue}
/>,
);
expect(screen.getByText("plugins.docker.enableDocker.label")).toBeTruthy();
expect(screen.queryByText("plugins.docker.socket.label")).toBeNull();
});
it("shows the fields once the enable switch is on", () => {
render(
<HostPluginSections
plugins={[plugin()]}
values={{ docker: { enableDocker: true } }}
setValue={vi.fn()}
/>,
);
expect(screen.getByText("plugins.docker.socket.label")).toBeTruthy();
});
it("reports a change against the right plugin", () => {
const setValue = vi.fn();
render(
<HostPluginSections
plugins={[plugin()]}
values={{ docker: { enableDocker: false } }}
setValue={setValue}
/>,
);
fireEvent.click(screen.getAllByRole("button")[0]);
expect(setValue).toHaveBeenCalledWith("docker", "enableDocker", true);
});
it("warns when the plugin is installed but not running", () => {
render(
<HostPluginSections
plugins={[plugin({ state: "failed" })]}
values={{ docker: {} }}
setValue={vi.fn()}
/>,
);
expect(screen.getByText("settings.hostPluginNotRunning")).toBeTruthy();
});
it("renders nothing at all with no contributing plugins", () => {
const { container } = render(
<HostPluginSections plugins={[]} values={{}} setValue={vi.fn()} />,
);
expect(container.innerHTML).toBe("");
});
it("keeps two plugins' values apart", () => {
const vault = plugin({
id: "vault",
name: "Vault",
contributes: {
settings: {
host: {
fields: [{ key: "role", type: "string", labelKey: "role.label" }],
},
},
},
});
render(
<HostPluginSections
plugins={[plugin(), vault]}
values={{
docker: { enableDocker: true, socketPath: "/var/run/docker.sock" },
vault: { role: "web" },
}}
setValue={vi.fn()}
/>,
);
expect(screen.getByDisplayValue("/var/run/docker.sock")).toBeTruthy();
expect(screen.getByDisplayValue("web")).toBeTruthy();
});
});