feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning

This commit is contained in:
LukeGus committed 2026-09-25 13:06:33 -05:00
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");
});
});
@@ -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);
});
});