summaryrefslogtreecommitdiffhomepage
path: root/src/core/plugin-abi/load-diff.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/core/plugin-abi/load-diff.test.ts')
-rw-r--r--src/core/plugin-abi/load-diff.test.ts183
1 files changed, 183 insertions, 0 deletions
diff --git a/src/core/plugin-abi/load-diff.test.ts b/src/core/plugin-abi/load-diff.test.ts
new file mode 100644
index 0000000..f8ebf6c
--- /dev/null
+++ b/src/core/plugin-abi/load-diff.test.ts
@@ -0,0 +1,183 @@
+import type { PluginListResponse } from "@dispatch/transport-contract";
+import { describe, expect, it } from "vitest";
+import {
+ decideFrontendLoads,
+ frontendCandidates,
+ type ImportedFrontendModule,
+ usablePluginIds,
+} from "./load-diff.js";
+import { FRONTEND_ABI_VERSION } from "./version.js";
+
+const httpBase = "http://localhost:24203";
+
+function listWith(
+ ...rows: readonly {
+ readonly id: string;
+ readonly apiVersion?: string;
+ readonly bust?: string;
+ }[]
+): PluginListResponse {
+ return {
+ loaded: rows.map((r) => ({
+ id: r.id,
+ name: r.id,
+ version: "1.0.0",
+ tier: "standard" as const,
+ trust: "local" as const,
+ locked: false,
+ dependsOn: [],
+ ...(r.apiVersion === undefined
+ ? {}
+ : { frontend: { apiVersion: r.apiVersion, entry: "frontend.js", bust: r.bust ?? "1" } }),
+ })),
+ disabledWithReason: [],
+ discovered: [],
+ missing: [],
+ };
+}
+
+describe("frontendCandidates", () => {
+ it("derives candidates from loaded ∧ frontend-declaring ∧ workspace-enabled rows", () => {
+ const candidates = frontendCandidates(
+ listWith(
+ { id: "a", apiVersion: "^1", bust: "111" },
+ { id: "no-fe" },
+ { id: "b", apiVersion: "^1", bust: "222" },
+ ),
+ { workspaceId: "team-a", usableIds: new Set(["a", "b", "no-fe"]), httpBase },
+ );
+ expect(candidates).toEqual([
+ {
+ id: "a",
+ apiVersion: "^1",
+ url: `${httpBase}/plugins/a/frontend?workspace=team-a&v=111`,
+ },
+ {
+ id: "b",
+ apiVersion: "^1",
+ url: `${httpBase}/plugins/b/frontend?workspace=team-a&v=222`,
+ },
+ ]);
+ });
+
+ it("excludes plugins disabled in the active workspace", () => {
+ const candidates = frontendCandidates(listWith({ id: "a", apiVersion: "^1" }), {
+ workspaceId: "team-a",
+ usableIds: new Set<string>(),
+ httpBase,
+ });
+ expect(candidates).toEqual([]);
+ });
+
+ it("URL-encodes the workspace and bust", () => {
+ const candidates = frontendCandidates(listWith({ id: "a", apiVersion: "^1", bust: "1 2" }), {
+ workspaceId: "team a",
+ usableIds: new Set(["a"]),
+ httpBase,
+ });
+ expect(candidates[0]?.url).toBe(`${httpBase}/plugins/a/frontend?workspace=team%20a&v=1%202`);
+ });
+});
+
+describe("usablePluginIds", () => {
+ it("mirrors isPluginUsable: core usable unless disabled, others enabled-only", () => {
+ const usable = usablePluginIds([
+ { pluginId: "core-one", tier: "core", state: "undefined", loaded: true, locked: true },
+ { pluginId: "core-off", tier: "core", state: "disabled", loaded: true, locked: true },
+ { pluginId: "std-on", tier: "standard", state: "enabled", loaded: true, locked: false },
+ { pluginId: "std-blank", tier: "standard", state: "undefined", loaded: true, locked: false },
+ { pluginId: "prov-on", tier: "providers", state: "enabled", loaded: true, locked: false },
+ { pluginId: "prov-off", tier: "providers", state: "disabled", loaded: true, locked: false },
+ ]);
+ expect([...usable].sort()).toEqual(["core-one", "prov-on", "std-on"]);
+ });
+});
+
+describe("decideFrontendLoads — the load diff (boot / extensions-changed / workspace.set)", () => {
+ const c = (id: string, bust = "1"): { id: string; url: string; apiVersion: string } => ({
+ id,
+ url: `${httpBase}/plugins/${id}/frontend?workspace=w&v=${bust}`,
+ apiVersion: "^1",
+ });
+
+ it("boot: every desired candidate imports (nothing imported yet)", () => {
+ const d = decideFrontendLoads([c("a"), c("b")], new Map());
+ expect(d.toLoad.map((x) => x.id)).toEqual(["a", "b"]);
+ expect(d.toUnload).toEqual([]);
+ expect(d.incompatible).toEqual([]);
+ });
+
+ it("extensions-changed: a removed plugin unloads, a newcomer imports", () => {
+ const imported = new Map<string, ImportedFrontendModule>([
+ ["a", { url: c("a").url }],
+ ["gone", { url: `${httpBase}/plugins/gone/frontend?workspace=w&v=1` }],
+ ]);
+ const d = decideFrontendLoads([c("a"), c("new")], imported);
+ expect(d.toLoad.map((x) => x.id)).toEqual(["new"]);
+ expect(d.toUnload).toEqual(["gone"]);
+ });
+
+ it("workspace.set: the enablement set change drives load AND unload", () => {
+ // The candidate list already reflects the NEW workspace (composition
+ // root re-derives per sync) — previously-imported ids absent from it unload.
+ const imported = new Map<string, ImportedFrontendModule>([
+ ["a", { url: c("a").url }],
+ ["b", { url: c("b").url }],
+ ]);
+ const d = decideFrontendLoads([c("a")], imported);
+ expect(d.toLoad).toEqual([]);
+ expect(d.toUnload).toEqual(["b"]);
+ });
+
+ it("a stable desired set is a no-op (idempotent sync)", () => {
+ const imported = new Map<string, ImportedFrontendModule>([
+ ["a", { url: c("a").url }],
+ ["b", { url: c("b").url }],
+ ]);
+ const d = decideFrontendLoads([c("a"), c("b")], imported);
+ expect(d.toLoad).toEqual([]);
+ expect(d.toUnload).toEqual([]);
+ });
+
+ it("a changed URL (backend reload → new bust) is unload + re-import", () => {
+ const imported = new Map<string, ImportedFrontendModule>([["a", { url: c("a", "old").url }]]);
+ const d = decideFrontendLoads([c("a", "new")], imported);
+ expect(d.toLoad.map((x) => x.id)).toEqual(["a"]);
+ expect(d.toUnload).toEqual(["a"]);
+ });
+
+ it("handshake failure → incompatible, never imported, still unloaded if it was", () => {
+ const bad = { ...c("bad"), apiVersion: "^2" };
+ const imported = new Map<string, ImportedFrontendModule>([["bad", { url: bad.url }]]);
+ const d = decideFrontendLoads([bad], imported);
+ expect(d.toLoad).toEqual([]);
+ expect(d.incompatible).toEqual([{ id: "bad", apiVersion: "^2" }]);
+ // The previously-imported instance must be swept (the manifest changed
+ // under us — the UI must not keep running incompatible code).
+ expect(d.toUnload).toEqual(["bad"]);
+ });
+
+ it("the handshake checks against FRONTEND_ABI_VERSION", () => {
+ // A range that includes the host version passes — proving the version
+ // constant feeds the check.
+ expect(FRONTEND_ABI_VERSION).toBe("1.1");
+ const d = decideFrontendLoads([c("ok")], new Map());
+ expect(d.incompatible).toEqual([]);
+ });
+
+ it("the additive bump keeps v1 plugins loading (plan 04: ^1 spans 1.1)", () => {
+ // c() declares apiVersion "^1" — a bare-major range is a
+ // major-compatibility claim, so the 1.1 host (layout member added) still
+ // loads every v1 plugin.
+ const d = decideFrontendLoads([c("v1-plugin")], new Map());
+ expect(d.toLoad.map((x) => x.id)).toEqual(["v1-plugin"]);
+ expect(d.incompatible).toEqual([]);
+ });
+
+ it("plugins requiring the 1.1 ABI (^1.1) load on the 1.1 host", () => {
+ const layout = { ...c("layout-plugin"), apiVersion: "^1.1" };
+ const d = decideFrontendLoads([layout], new Map());
+ expect(d.toLoad.map((x) => x.id)).toEqual(["layout-plugin"]);
+ expect(d.incompatible).toEqual([]);
+ });
+});