summaryrefslogtreecommitdiffhomepage
path: root/src/core/plugin-abi/driver.test.ts
blob: 43d0af5782cf3df1f7312d45851762d9fcef5387 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
import { describe, expect, it, vi } from "vitest";
import { createFrontendDriver, type FrontendDriverDeps } from "./driver.js";
import type { FrontendCandidate } from "./load-diff.js";

/**
 * Asymmetric testing: the ONLY fake is the OUTERMOST edge (the dynamic
 * import + the host-API builder — injected effects). The driver's internals,
 * the load diff, and the registry-facing sweep contract are real.
 */

const c = (id: string, bust = "1"): FrontendCandidate => ({
  id,
  url: `http://x/plugins/${id}/frontend?workspace=w&v=${bust}`,
  apiVersion: "^1",
});

interface Harness {
  readonly deps: FrontendDriverDeps & {
    // The race test swaps the import edge before the driver exists — the
    // driver reads the dep object per call, so the mutation is the swap.
    importModule: (url: string) => Promise<unknown>;
  };
  readonly imports: string[];
  readonly sweeps: string[];
  /** The modules the fake import() returns, keyed by the next id. */
  readonly modules: Map<string, unknown>;
  readonly logs: string[];
}

function harness(): Harness {
  const imports: string[] = [];
  const sweeps: string[] = [];
  const logs: string[] = [];
  const modules = new Map<string, unknown>();
  const deps: FrontendDriverDeps = {
    importModule: async (url) => {
      imports.push(url);
      const id = new URL(url).pathname.split("/")[2] ?? "";
      const mod = modules.get(id);
      if (mod === undefined) throw new Error(`404: ${url}`);
      return mod;
    },
    buildHostApi: (pluginId) => ({
      abiVersion: "1.1",
      registerView: () => () => {},
      surfaces: {
        subscribe: () => {},
        unsubscribe: () => {},
        invoke: () => {},
        getSpec: () => null,
      },
      logger: { info: () => {}, warn: () => {} },
      layout: {
        applyOps: async () => ({ ok: false, error: "no layout provider active" }),
        getTree: () => null,
      },
      ...(pluginId === undefined ? {} : {}),
    }),
    unregisterViewsByPlugin: (pluginId) => sweeps.push(pluginId),
    logger: {
      info: (msg) => logs.push(msg),
      warn: (msg) => logs.push(msg),
    },
  };
  return { deps, imports, sweeps, modules, logs };
}

describe("createFrontendDriver — the load lifecycle", () => {
  it("boot: imports newcomers and calls activate with the plugin's own host API", async () => {
    const h = harness();
    const activate = vi.fn();
    h.modules.set("a", { default: { activate } });
    const driver = createFrontendDriver(h.deps);
    await driver.sync([c("a")]);
    expect(h.imports).toEqual([c("a").url]);
    expect(activate).toHaveBeenCalledOnce();
    expect(driver.states().get("a")).toEqual({ kind: "loaded" });
  });

  it("extensions-changed: a vanished plugin runs its deactivate + the registry sweep", async () => {
    const h = harness();
    const deactivate = vi.fn();
    h.modules.set("a", { default: { activate: () => deactivate } });
    const driver = createFrontendDriver(h.deps);
    await driver.sync([c("a")]);
    await driver.sync([]); // the plugin is gone from the desired set
    expect(deactivate).toHaveBeenCalledOnce();
    expect(h.sweeps).toEqual(["a"]);
    expect(driver.states().has("a")).toBe(false);
  });

  it("unload disposes EXACTLY that plugin (the sweep is per-pluginId)", async () => {
    const h = harness();
    h.modules.set("a", { default: { activate: () => () => {} } });
    h.modules.set("b", { default: { activate: () => () => {} } });
    const driver = createFrontendDriver(h.deps);
    await driver.sync([c("a"), c("b")]);
    await driver.sync([c("b")]); // only `a` vanishes
    expect(h.sweeps).toEqual(["a"]);
  });

  it("workspace.set / re-enable: the SAME url re-activates the cached module", async () => {
    const h = harness();
    const activate = vi.fn();
    h.modules.set("a", { default: { activate } });
    const driver = createFrontendDriver(h.deps);
    await driver.sync([c("a")]);
    await driver.sync([]); // disable → unload (disposer + sweep)
    await driver.sync([c("a")]); // re-enable → activate re-runs
    expect(h.imports).toEqual([c("a").url, c("a").url]); // re-fetched URL
    expect(activate).toHaveBeenCalledTimes(2);
    expect(h.sweeps).toEqual(["a"]);
  });

  it("a changed URL (backend reload) unloads then re-imports fresh code", async () => {
    const h = harness();
    const activate = vi.fn(() => () => {});
    h.modules.set("a", { default: { activate } });
    const driver = createFrontendDriver(h.deps);
    await driver.sync([c("a", "old")]);
    await driver.sync([c("a", "new")]);
    expect(h.sweeps).toEqual(["a"]);
    expect(h.imports).toEqual([c("a", "old").url, c("a", "new").url]);
    expect(activate).toHaveBeenCalledTimes(2);
  });

  it("a throwing activate is LOUD: failed state, no crash, others still load", async () => {
    const h = harness();
    h.modules.set("bad", {
      default: {
        activate: () => {
          throw new Error("boom");
        },
      },
    });
    h.modules.set("good", { default: { activate: () => () => {} } });
    const driver = createFrontendDriver(h.deps);
    await driver.sync([c("bad"), c("good")]);
    expect(driver.states().get("bad")).toEqual({ kind: "failed", error: "boom" });
    expect(driver.states().get("good")).toEqual({ kind: "loaded" });
    // The failed plugin owns nothing — the sweep never ran for it.
    expect(h.sweeps).toEqual([]);
  });

  it("a broken module (404 / missing activate) is a failed state, not a crash", async () => {
    const h = harness();
    h.modules.set("no-activate", { default: {} });
    const driver = createFrontendDriver(h.deps);
    await driver.sync([c("ghost"), c("no-activate")]);
    expect(driver.states().get("ghost")?.kind).toBe("failed");
    expect(driver.states().get("no-activate")?.kind).toBe("failed");
    expect(h.logs.some((l) => l.includes("activation failed"))).toBe(true);
  });

  it("a throwing deactivate still gets the registry sweep (views never linger)", async () => {
    const h = harness();
    h.modules.set("a", {
      default: {
        activate: () => () => {
          throw new Error("disposer boom");
        },
      },
    });
    const driver = createFrontendDriver(h.deps);
    await driver.sync([c("a")]);
    await driver.sync([]);
    expect(h.sweeps).toEqual(["a"]);
  });

  it("handshake-incompatible candidates are never imported (reported for the UI)", async () => {
    const h = harness();
    const driver = createFrontendDriver(h.deps);
    await driver.sync([{ ...c("bad"), apiVersion: "^2" }]);
    expect(h.imports).toEqual([]);
    expect(driver.states().get("bad")).toEqual({ kind: "incompatible", apiVersion: "^2" });
  });

  it("overlapping syncs serialize (boot + extensions-changed race)", async () => {
    const h = harness();
    let releaseImport: (() => void) | undefined;
    h.modules.set("a", {
      default: {
        activate: () => {
          return () => {};
        },
      },
    });
    const blockingImport = async (url: string) => {
      h.imports.push(url);
      await new Promise<void>((resolve) => {
        releaseImport = resolve;
      });
      return h.modules.get("a");
    };
    // Swap the import edge BEFORE the driver exists (the driver reads the
    // dep object per call — the swap is the injection point for the race).
    h.deps.importModule = blockingImport;
    const driver = createFrontendDriver(h.deps);
    const first = driver.sync([c("a")]);
    const second = driver.sync([]); // queued behind the first
    // While the first sync is blocked on import, the second must not have
    // started (no unload of `a` mid-import).
    await new Promise((r) => setTimeout(r, 10));
    releaseImport?.();
    await Promise.all([first, second]);
    // The second pass sees `a` already imported (the queued sync ran after)
    // and unloads it — exactly one sweep, no interleaving.
    expect(h.sweeps).toEqual(["a"]);
  });
});