mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 07:35:24 +00:00
feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning
This commit is contained in:
1 parent
c21bca8cf0
commit
fbe736bc20
70 files changed
+1117
-1139
No files matched your search
@@ -0,0 +1,185 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
renderHook,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import type { PluginSummary } from "@/api/plugins-api";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getPlugins: vi.fn(),
|
||||
getPluginAdminSettings: vi.fn(),
|
||||
getPluginUserSettings: vi.fn(),
|
||||
updatePluginAdminSettings: vi.fn(),
|
||||
updatePluginUserSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/plugins-api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/api/plugins-api")>()),
|
||||
...api,
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => {
|
||||
const t = (key: string, options?: { name?: string }) =>
|
||||
options?.name ? `${key}:${options.name}` : key;
|
||||
return { useTranslation: () => ({ t }) };
|
||||
});
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
import {
|
||||
FeatureSettingsSection,
|
||||
useFeatureSettings,
|
||||
} from "@/settings/FeatureSettingsSections";
|
||||
|
||||
function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
|
||||
return {
|
||||
id: "ai",
|
||||
name: "AI Assistant",
|
||||
version: "1.0.0",
|
||||
enabled: true,
|
||||
state: "active",
|
||||
contributes: {
|
||||
settings: {
|
||||
admin: [{ key: "on", type: "boolean", labelKey: "admin.on" }],
|
||||
user: [{ key: "enabled", type: "boolean", labelKey: "user.enabled" }],
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
} as PluginSummary;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("useFeatureSettings", () => {
|
||||
it("keeps enabled features with visible fields in the scope, sorted by name", async () => {
|
||||
api.getPlugins.mockResolvedValue([
|
||||
plugin({ id: "zeta", name: "Zeta" }),
|
||||
plugin({ id: "off", name: "Off", enabled: false }),
|
||||
plugin({
|
||||
id: "hidden",
|
||||
name: "Hidden",
|
||||
contributes: {
|
||||
settings: {
|
||||
user: [{ key: "x", type: "boolean", labelKey: "x", hidden: true }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
plugin({ id: "alpha", name: "Alpha" }),
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useFeatureSettings("user"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.map((p) => p.id)).toEqual(["alpha", "zeta"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves out features with nothing in the scope", async () => {
|
||||
api.getPlugins.mockResolvedValue([
|
||||
plugin({
|
||||
id: "admin-only",
|
||||
contributes: {
|
||||
settings: {
|
||||
admin: [{ key: "a", type: "boolean", labelKey: "a" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useFeatureSettings("user"));
|
||||
|
||||
await waitFor(() => expect(api.getPlugins).toHaveBeenCalled());
|
||||
expect(result.current).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FeatureSettingsSection", () => {
|
||||
it("is titled with the feature name and loads nothing while closed", () => {
|
||||
render(
|
||||
<FeatureSettingsSection
|
||||
plugin={plugin()}
|
||||
scope="user"
|
||||
open={false}
|
||||
onToggle={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("AI Assistant")).toBeTruthy();
|
||||
expect(api.getPluginUserSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads and saves the scope's values once open", async () => {
|
||||
api.getPluginUserSettings.mockResolvedValue({ enabled: false });
|
||||
api.updatePluginUserSettings.mockResolvedValue({ enabled: true });
|
||||
|
||||
render(
|
||||
<FeatureSettingsSection
|
||||
plugin={plugin()}
|
||||
scope="user"
|
||||
open
|
||||
onToggle={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByText("ai:user.enabled");
|
||||
expect(api.getPluginUserSettings).toHaveBeenCalledWith("ai");
|
||||
expect(api.getPluginAdminSettings).not.toHaveBeenCalled();
|
||||
|
||||
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(save);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.updatePluginUserSettings).toHaveBeenCalledWith("ai", {
|
||||
enabled: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the admin endpoints for the admin scope", async () => {
|
||||
api.getPluginAdminSettings.mockResolvedValue({ on: true });
|
||||
|
||||
render(
|
||||
<FeatureSettingsSection
|
||||
plugin={plugin()}
|
||||
scope="admin"
|
||||
open
|
||||
onToggle={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByText("ai:admin.on");
|
||||
expect(api.getPluginAdminSettings).toHaveBeenCalledWith("ai");
|
||||
});
|
||||
|
||||
it("says when the feature is unavailable", async () => {
|
||||
api.getPluginUserSettings.mockResolvedValue({});
|
||||
|
||||
render(
|
||||
<FeatureSettingsSection
|
||||
plugin={plugin({ state: "failed" })}
|
||||
scope="user"
|
||||
open
|
||||
onToggle={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText("settings.featureUnavailable:AI Assistant"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,182 +0,0 @@
|
||||
/**
|
||||
* 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>,
|
||||
}));
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
+67
-33
@@ -1,21 +1,18 @@
|
||||
/**
|
||||
* 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.
|
||||
* Generated host editor tabs, one per feature with manifest host settings.
|
||||
*/
|
||||
|
||||
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";
|
||||
hostFeatureTabId,
|
||||
syncHostFeatureTabs,
|
||||
} from "@/settings/host-feature-tabs";
|
||||
import {
|
||||
getHostEditorSection,
|
||||
unregisterHostEditorSection,
|
||||
registerHostEditorSection,
|
||||
resetHostEditorSections,
|
||||
} from "@/sidebar/HostManagerTabs";
|
||||
import { HostPluginSections } from "@/settings/HostPluginSections";
|
||||
|
||||
@@ -47,18 +44,62 @@ function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
unregisterHostEditorSection(HOST_PLUGINS_TAB_ID);
|
||||
resetHostEditorSections();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
unregisterHostEditorSection(HOST_PLUGINS_TAB_ID);
|
||||
resetHostEditorSections();
|
||||
});
|
||||
|
||||
describe("syncHostPluginsTab", () => {
|
||||
it("registers the tab when a plugin contributes host settings", () => {
|
||||
expect(syncHostPluginsTab([plugin()])).toBe(true);
|
||||
expect(getHostEditorSection(HOST_PLUGINS_TAB_ID)).toBeDefined();
|
||||
describe("syncHostFeatureTabs", () => {
|
||||
it("registers one tab per feature, labelled with its name", () => {
|
||||
const other = plugin({ id: "warpgate", name: "Warpgate" });
|
||||
expect(syncHostFeatureTabs([plugin(), other])).toEqual([
|
||||
"feature:docker",
|
||||
"feature:warpgate",
|
||||
]);
|
||||
expect(getHostEditorSection(hostFeatureTabId("docker"))?.label).toBe(
|
||||
"Docker",
|
||||
);
|
||||
expect(getHostEditorSection(hostFeatureTabId("warpgate"))).toBeDefined();
|
||||
});
|
||||
|
||||
it("uses the manifest's editor group and order", () => {
|
||||
const placed = plugin({
|
||||
contributes: {
|
||||
settings: {
|
||||
host: {
|
||||
editorGroup: "ssh",
|
||||
editorOrder: 30,
|
||||
enableKey: "enableDocker",
|
||||
enableLabelKey: "k",
|
||||
fields: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
syncHostFeatureTabs([placed]);
|
||||
const section = getHostEditorSection(hostFeatureTabId("docker"));
|
||||
expect(section?.group).toBe("ssh");
|
||||
expect(section?.order).toBe(30);
|
||||
});
|
||||
|
||||
it("defaults to the main strip", () => {
|
||||
syncHostFeatureTabs([plugin()]);
|
||||
expect(getHostEditorSection(hostFeatureTabId("docker"))?.group).toBe("top");
|
||||
});
|
||||
|
||||
it("skips a plugin that registered its own host editor section", () => {
|
||||
registerHostEditorSection({
|
||||
id: "docker-own",
|
||||
pluginId: "docker",
|
||||
group: "top",
|
||||
labelKey: "k",
|
||||
component: () => null,
|
||||
});
|
||||
expect(syncHostFeatureTabs([plugin()])).toEqual([]);
|
||||
expect(getHostEditorSection(hostFeatureTabId("docker"))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("registers nothing for a plugin whose host fields are all hidden", () => {
|
||||
@@ -73,12 +114,7 @@ describe("syncHostPluginsTab", () => {
|
||||
},
|
||||
},
|
||||
} as Partial<PluginSummary>);
|
||||
expect(syncHostPluginsTab([own])).toBe(false);
|
||||
});
|
||||
|
||||
it("registers nothing when no plugin contributes", () => {
|
||||
expect(syncHostPluginsTab([])).toBe(false);
|
||||
expect(getHostEditorSection(HOST_PLUGINS_TAB_ID)).toBeUndefined();
|
||||
expect(syncHostFeatureTabs([own])).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores a plugin with no host settings", () => {
|
||||
@@ -86,21 +122,20 @@ describe("syncHostPluginsTab", () => {
|
||||
id: "ai",
|
||||
contributes: { settings: { admin: [] } },
|
||||
});
|
||||
|
||||
expect(syncHostPluginsTab([other])).toBe(false);
|
||||
expect(syncHostFeatureTabs([other])).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores a disabled plugin", () => {
|
||||
expect(syncHostPluginsTab([plugin({ enabled: false })])).toBe(false);
|
||||
expect(syncHostFeatureTabs([plugin({ enabled: false })])).toEqual([]);
|
||||
});
|
||||
|
||||
it("removes the tab once the last contributor goes", () => {
|
||||
syncHostPluginsTab([plugin()]);
|
||||
expect(getHostEditorSection(HOST_PLUGINS_TAB_ID)).toBeDefined();
|
||||
it("removes a tab once its feature goes", () => {
|
||||
syncHostFeatureTabs([plugin()]);
|
||||
expect(getHostEditorSection(hostFeatureTabId("docker"))).toBeDefined();
|
||||
|
||||
syncHostPluginsTab([plugin({ enabled: false })]);
|
||||
syncHostFeatureTabs([plugin({ enabled: false })]);
|
||||
|
||||
expect(getHostEditorSection(HOST_PLUGINS_TAB_ID)).toBeUndefined();
|
||||
expect(getHostEditorSection(hostFeatureTabId("docker"))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("registers for a plugin declaring only an enable switch", () => {
|
||||
@@ -111,8 +146,7 @@ describe("syncHostPluginsTab", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(syncHostPluginsTab([bare])).toBe(true);
|
||||
expect(syncHostFeatureTabs([bare])).toEqual(["feature:docker"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -184,7 +218,7 @@ describe("HostPluginSections", () => {
|
||||
expect(setValue).toHaveBeenCalledWith("docker", "enableDocker", true);
|
||||
});
|
||||
|
||||
it("warns when the plugin is installed but not running", () => {
|
||||
it("warns when the feature is unavailable", () => {
|
||||
render(
|
||||
<HostPluginSections
|
||||
plugins={[plugin({ state: "failed" })]}
|
||||
@@ -193,7 +227,7 @@ describe("HostPluginSections", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("settings.hostPluginNotRunning")).toBeTruthy();
|
||||
expect(screen.getByText("settings.featureUnavailable")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders nothing at all with no contributing plugins", () => {
|
||||
@@ -44,7 +44,7 @@ describe("AdminSecondFactorsSection", () => {
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
await screen.findByText("admin.secondFactorUnavailable:yubi"),
|
||||
await screen.findByText("admin.secondFactorUnavailable"),
|
||||
).toBeTruthy();
|
||||
// A plugin's label resolves in its own namespace.
|
||||
expect(screen.getByText("totp:factor")).toBeTruthy();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { registerHostProtocol } from "../../sidebar/host-protocols";
|
||||
import {
|
||||
applyHostDraft,
|
||||
createHostEditorForm,
|
||||
buildHostEditorPayload,
|
||||
omitOwnerSshAuthFromSharedEdit,
|
||||
@@ -559,3 +560,27 @@ describe("plugin protocols", () => {
|
||||
expect(payload.enableSsh).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyHostDraft", () => {
|
||||
it("fills a new host's form from a plugin draft", () => {
|
||||
const form = applyHostDraft(createHostEditorForm(null), {
|
||||
name: "box",
|
||||
ip: "100.64.0.1",
|
||||
port: 2222,
|
||||
username: "luke",
|
||||
authType: "tailscale",
|
||||
});
|
||||
expect(form).toMatchObject({
|
||||
name: "box",
|
||||
ip: "100.64.0.1",
|
||||
sshPort: 2222,
|
||||
username: "luke",
|
||||
authType: "tailscale",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the form alone without a draft", () => {
|
||||
const form = createHostEditorForm(null);
|
||||
expect(applyHostDraft(form, undefined)).toBe(form);
|
||||
});
|
||||
});
|
||||
@@ -112,4 +112,23 @@ describe("createQuickConnectHost for remote desktop protocols", () => {
|
||||
pluginSettings: { demo: { profileId: 3 } },
|
||||
});
|
||||
});
|
||||
|
||||
it("marks only hosts core can save", () => {
|
||||
const base = { ip: "10.0.0.2", port: 22, username: "root" };
|
||||
expect(
|
||||
createQuickConnectHost({ ...base, authType: "password" })
|
||||
.quickConnectSavable,
|
||||
).toBe(true);
|
||||
expect(
|
||||
createQuickConnectHost({ ...base, authType: "tailscale" })
|
||||
.quickConnectSavable,
|
||||
).toBe(false);
|
||||
expect(
|
||||
createQuickConnectHost({
|
||||
...base,
|
||||
authType: "password",
|
||||
authFields: { extra: true },
|
||||
}).quickConnectSavable,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user