mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: give plugins their own settings and one settings screen
This commit is contained in:
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user