mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
374 lines
11 KiB
TypeScript
374 lines
11 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { Puzzle } from "lucide-react";
|
|
import type {
|
|
FrontendModule,
|
|
TermixApp,
|
|
} from "@termix-ssh/plugin-sdk/frontend";
|
|
import type { PluginSummary } from "@/api/plugins-api";
|
|
import {
|
|
configurePluginLoader,
|
|
isPluginFrontendActive,
|
|
orderForActivation,
|
|
resetPluginLoader,
|
|
startPluginRuntime,
|
|
startPreLoginPlugins,
|
|
syncPlugins,
|
|
} from "@/plugin-host/loader";
|
|
import {
|
|
getPluginRecord,
|
|
getPluginStoreState,
|
|
resetPluginStore,
|
|
} from "@/plugin-host/plugin-store";
|
|
import {
|
|
resetRegisteredRailItems,
|
|
visibleRailItems,
|
|
} from "@/sidebar/rail-items";
|
|
import { getTabType, resetTabTypes } from "@/shell/tab-registry";
|
|
import i18n from "@/i18n/i18n";
|
|
|
|
function summary(
|
|
id: string,
|
|
overrides: Partial<PluginSummary> = {},
|
|
): PluginSummary {
|
|
return {
|
|
id,
|
|
name: id,
|
|
version: "1.0.0",
|
|
enabled: true,
|
|
state: "active",
|
|
frontend: true,
|
|
assetVersion: "v1",
|
|
contributes: { panels: [{ id: `${id}-panel`, titleKey: "nav.hosts" }] },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/** A plugin that registers a rail item named after itself. */
|
|
function railPlugin(id: string, calls: string[] = []): FrontendModule {
|
|
return {
|
|
activate(app: TermixApp) {
|
|
calls.push(`activate:${id}`);
|
|
app.registerRailItem({
|
|
id: `${id}-panel`,
|
|
icon: Puzzle,
|
|
titleKey: "nav.hosts",
|
|
});
|
|
},
|
|
deactivate() {
|
|
calls.push(`deactivate:${id}`);
|
|
},
|
|
};
|
|
}
|
|
|
|
function railIds(): string[] {
|
|
return visibleRailItems().map((item) => item.id);
|
|
}
|
|
|
|
let plugins: PluginSummary[] = [];
|
|
let modules: Record<string, FrontendModule> = {};
|
|
|
|
beforeEach(() => {
|
|
plugins = [];
|
|
modules = {};
|
|
configurePluginLoader({
|
|
fetchPlugins: async () => plugins,
|
|
importFrontend: async (entry) => {
|
|
const module = modules[entry.id];
|
|
if (!module) throw new Error(`no module for ${entry.id}`);
|
|
return module;
|
|
},
|
|
loadLocale: async () => null,
|
|
injectCss: () => null,
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await resetPluginLoader();
|
|
resetPluginStore();
|
|
resetRegisteredRailItems();
|
|
resetTabTypes();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("plugin loader", () => {
|
|
it("activates an enabled plugin and deactivates it when disabled, without a reload", async () => {
|
|
const calls: string[] = [];
|
|
modules.alpha = railPlugin("alpha", calls);
|
|
plugins = [summary("alpha")];
|
|
|
|
await syncPlugins();
|
|
expect(railIds()).toContain("alpha-panel");
|
|
expect(isPluginFrontendActive("alpha")).toBe(true);
|
|
|
|
plugins = [summary("alpha", { enabled: false })];
|
|
await syncPlugins();
|
|
expect(railIds()).not.toContain("alpha-panel");
|
|
expect(calls).toEqual(["activate:alpha", "deactivate:alpha"]);
|
|
expect(getPluginRecord("alpha")?.frontend).toBe("inactive");
|
|
});
|
|
|
|
it("re-activates the same module after disable then enable", async () => {
|
|
const calls: string[] = [];
|
|
modules.alpha = railPlugin("alpha", calls);
|
|
|
|
plugins = [summary("alpha")];
|
|
await syncPlugins();
|
|
plugins = [summary("alpha", { enabled: false })];
|
|
await syncPlugins();
|
|
plugins = [summary("alpha")];
|
|
await syncPlugins();
|
|
|
|
expect(railIds()).toContain("alpha-panel");
|
|
expect(calls).toEqual([
|
|
"activate:alpha",
|
|
"deactivate:alpha",
|
|
"activate:alpha",
|
|
]);
|
|
});
|
|
|
|
it("disposes registrations even when deactivate throws", async () => {
|
|
modules.alpha = {
|
|
activate: railPlugin("alpha").activate,
|
|
deactivate: () => {
|
|
throw new Error("boom");
|
|
},
|
|
};
|
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
plugins = [summary("alpha")];
|
|
await syncPlugins();
|
|
plugins = [];
|
|
await syncPlugins();
|
|
expect(railIds()).not.toContain("alpha-panel");
|
|
});
|
|
|
|
it("isolates a plugin whose activate throws", async () => {
|
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
modules.broken = {
|
|
activate(app) {
|
|
app.registerRailItem({
|
|
id: "broken-panel",
|
|
icon: Puzzle,
|
|
titleKey: "nav.hosts",
|
|
});
|
|
throw new Error("activate failed");
|
|
},
|
|
};
|
|
modules.healthy = railPlugin("healthy");
|
|
plugins = [summary("broken"), summary("healthy")];
|
|
|
|
await syncPlugins();
|
|
|
|
expect(getPluginRecord("broken")?.frontend).toBe("failed");
|
|
expect(getPluginRecord("broken")?.error).toBe("activate failed");
|
|
// What it registered before throwing is cleaned up.
|
|
expect(railIds()).not.toContain("broken-panel");
|
|
expect(railIds()).toContain("healthy-panel");
|
|
});
|
|
|
|
it("marks a plugin failed when its bundle cannot be imported", async () => {
|
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
plugins = [summary("missing")];
|
|
await syncPlugins();
|
|
expect(getPluginRecord("missing")?.frontend).toBe("failed");
|
|
});
|
|
|
|
it("does not retry the same broken bundle, but does a new one", async () => {
|
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
const activate = vi.fn(() => {
|
|
throw new Error("nope");
|
|
});
|
|
modules.flaky = { activate };
|
|
plugins = [summary("flaky")];
|
|
await syncPlugins();
|
|
await syncPlugins();
|
|
expect(activate).toHaveBeenCalledTimes(1);
|
|
|
|
modules.flaky = railPlugin("flaky");
|
|
plugins = [summary("flaky", { assetVersion: "v2" })];
|
|
await syncPlugins();
|
|
expect(getPluginRecord("flaky")?.frontend).toBe("active");
|
|
});
|
|
|
|
it("refuses to register a view the manifest does not declare", async () => {
|
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
modules.sneaky = {
|
|
activate(app) {
|
|
app.registerTab("undeclared", () => null);
|
|
},
|
|
};
|
|
plugins = [summary("sneaky")];
|
|
await syncPlugins();
|
|
expect(getTabType("undeclared")).toBeUndefined();
|
|
expect(getPluginRecord("sneaky")?.error).toMatch(/not declared/);
|
|
});
|
|
|
|
it("skips server-failed and frontend-less plugins", async () => {
|
|
modules.down = railPlugin("down");
|
|
modules.headless = railPlugin("headless");
|
|
plugins = [
|
|
summary("down", { state: "failed" }),
|
|
summary("headless", { frontend: false }),
|
|
];
|
|
await syncPlugins();
|
|
expect(railIds()).not.toContain("down-panel");
|
|
expect(railIds()).not.toContain("headless-panel");
|
|
});
|
|
|
|
it("keeps every plugin when the pre-login list lands after sign-in", async () => {
|
|
modules.login = railPlugin("login");
|
|
modules.alpha = railPlugin("alpha");
|
|
plugins = [summary("login"), summary("alpha")];
|
|
|
|
let releasePreLogin: (value: Response) => void = () => {};
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(
|
|
() =>
|
|
new Promise<Response>((resolve) => {
|
|
releasePreLogin = resolve;
|
|
}),
|
|
);
|
|
|
|
const preLogin = startPreLoginPlugins();
|
|
await startPluginRuntime();
|
|
releasePreLogin(new Response(JSON.stringify([summary("login")])));
|
|
await preLogin;
|
|
|
|
expect(isPluginFrontendActive("login")).toBe(true);
|
|
expect(isPluginFrontendActive("alpha")).toBe(true);
|
|
});
|
|
|
|
it("retries failed login bundles only when explicitly requested", async () => {
|
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(
|
|
async () => new Response(JSON.stringify([summary("login")])),
|
|
);
|
|
await startPreLoginPlugins();
|
|
expect(isPluginFrontendActive("login")).toBe(false);
|
|
modules.login = railPlugin("login");
|
|
await startPreLoginPlugins();
|
|
expect(isPluginFrontendActive("login")).toBe(false);
|
|
await startPreLoginPlugins({ retryFailed: true });
|
|
expect(isPluginFrontendActive("login")).toBe(true);
|
|
});
|
|
|
|
it("ignores a pre-login load started after sign-in", async () => {
|
|
modules.login = railPlugin("login");
|
|
modules.alpha = railPlugin("alpha");
|
|
plugins = [summary("login"), summary("alpha")];
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(
|
|
async () => new Response(JSON.stringify([summary("login")])),
|
|
);
|
|
|
|
await startPluginRuntime();
|
|
await startPreLoginPlugins();
|
|
|
|
expect(isPluginFrontendActive("alpha")).toBe(true);
|
|
});
|
|
|
|
it("loads plugin strings on guest pages", async () => {
|
|
modules.guest = railPlugin("guest");
|
|
plugins = [summary("guest", { locales: ["en"] })];
|
|
configurePluginLoader({
|
|
fetchPlugins: async () => plugins,
|
|
importFrontend: async (entry) => modules[entry.id],
|
|
loadLocale: async () => ({ hello: "Hello guest" }),
|
|
injectCss: () => null,
|
|
});
|
|
|
|
await startPluginRuntime({ guest: true });
|
|
await i18n.loadNamespaces("guest");
|
|
expect(i18n.t("hello", { ns: "guest" })).toBe("Hello guest");
|
|
});
|
|
|
|
it("settles even when the plugin list cannot be fetched", async () => {
|
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
configurePluginLoader({
|
|
fetchPlugins: async () => {
|
|
throw new Error("offline");
|
|
},
|
|
});
|
|
await syncPlugins();
|
|
expect(getPluginStoreState().settled).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("activation order", () => {
|
|
it("activates a dependency before its dependent", async () => {
|
|
const calls: string[] = [];
|
|
modules.child = railPlugin("child", calls);
|
|
modules.parent = railPlugin("parent", calls);
|
|
plugins = [
|
|
summary("child", { dependencies: { parent: "^1.0.0" } }),
|
|
summary("parent"),
|
|
];
|
|
await syncPlugins();
|
|
expect(calls).toEqual(["activate:parent", "activate:child"]);
|
|
});
|
|
|
|
it("blocks a plugin whose hard dependency is off", async () => {
|
|
modules.child = railPlugin("child");
|
|
plugins = [
|
|
summary("child", { dependencies: { parent: "^1.0.0" } }),
|
|
summary("parent", { enabled: false }),
|
|
];
|
|
await syncPlugins();
|
|
expect(getPluginRecord("child")?.frontend).toBe("blocked");
|
|
expect(railIds()).not.toContain("child-panel");
|
|
});
|
|
|
|
it("runs without an optional dependency", () => {
|
|
const { order, blocked } = orderForActivation([
|
|
summary("child", { optionalDependencies: { gone: "^1.0.0" } }),
|
|
]);
|
|
expect(order.map((entry) => entry.id)).toEqual(["child"]);
|
|
expect(blocked.size).toBe(0);
|
|
});
|
|
|
|
it("blocks both sides of a dependency cycle", () => {
|
|
const { order, blocked } = orderForActivation([
|
|
summary("a", { dependencies: { b: "*" } }),
|
|
summary("b", { dependencies: { a: "*" } }),
|
|
]);
|
|
expect(order).toEqual([]);
|
|
expect([...blocked.keys()].sort()).toEqual(["a", "b"]);
|
|
});
|
|
|
|
it("activates independent plugins concurrently rather than one at a time", async () => {
|
|
modules.alpha = railPlugin("alpha");
|
|
modules.beta = railPlugin("beta");
|
|
plugins = [summary("alpha"), summary("beta")];
|
|
|
|
// Each import blocks until the other has started, which only resolves
|
|
// if both imports were kicked off before either finished.
|
|
let releaseAlpha: () => void;
|
|
let releaseBeta: () => void;
|
|
const alphaStarted = new Promise<void>((resolve) => {
|
|
releaseAlpha = resolve;
|
|
});
|
|
const betaStarted = new Promise<void>((resolve) => {
|
|
releaseBeta = resolve;
|
|
});
|
|
configurePluginLoader({
|
|
fetchPlugins: async () => plugins,
|
|
importFrontend: async (entry) => {
|
|
if (entry.id === "alpha") {
|
|
releaseAlpha!();
|
|
await betaStarted;
|
|
} else if (entry.id === "beta") {
|
|
releaseBeta!();
|
|
await alphaStarted;
|
|
}
|
|
const module = modules[entry.id];
|
|
if (!module) throw new Error(`no module for ${entry.id}`);
|
|
return module;
|
|
},
|
|
loadLocale: async () => null,
|
|
injectCss: () => null,
|
|
});
|
|
|
|
await syncPlugins();
|
|
|
|
expect(railIds()).toContain("alpha-panel");
|
|
expect(railIds()).toContain("beta-panel");
|
|
});
|
|
});
|