File size: 14,110 Bytes
5448d8b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

// ── Temp dirs ──

const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-plugins-lifecycle-"));
process.env.DATA_DIR = TEST_DATA_DIR;

// ── Dynamic imports (after DATA_DIR set) ──

const core = await import("../../src/lib/db/core.ts");
const dbPlugins = await import("../../src/lib/db/plugins.ts");
const hooks = await import("../../src/lib/plugins/hooks.ts");
const { pluginManager } = await import("../../src/lib/plugins/manager.ts");

// ── Fixture: create a valid plugin in a temp directory ──
// Scanner expects: sourceDir/<plugin-name>/plugin.json + index.js
// Returns the sourceDir (parent) to pass to pluginManager.install()

function writeTestPlugin(opts?: { name?: string; onRequest?: boolean; enabledByDefault?: boolean }) {
  const name = opts?.name ?? "test-lifecycle-plugin";
  const onRequest = opts?.onRequest ?? true;
  const enabledByDefault = opts?.enabledByDefault ?? false;

  // Create a fresh source dir for this plugin (scanner scans for subdirs)
  const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-plugin-src-"));
  const pluginDir = path.join(sourceDir, name);
  fs.mkdirSync(pluginDir, { recursive: true });

  const manifest = {
    name,
    version: "1.0.0",
    description: "Integration test plugin",
    author: "test",
    main: "index.js",
    hooks: { onRequest, onResponse: false, onError: false },
    enabledByDefault,
    requires: { permissions: [] },
  };

  fs.writeFileSync(path.join(pluginDir, "plugin.json"), JSON.stringify(manifest, null, 2));

  // Plugin exports an onRequest handler that returns metadata (child-process isolation means
  // the handler cannot mutate the parent's ctx object directly β€” it must return the result).
  const indexJs = onRequest
    ? `module.exports.onRequest = function(ctx) { return { metadata: { hookCalled: true } }; };`
    : `module.exports = {};`;

  fs.writeFileSync(path.join(pluginDir, "index.js"), indexJs);

  return { sourceDir, pluginDir, name };
}

// ── Helpers ──

function cleanupDir(dir: string) {
  fs.rmSync(dir, { recursive: true, force: true });
}

// Track temp source dirs for cleanup
const activeSourceDirs: string[] = [];

function cleanupSourceDirs() {
  for (const dir of activeSourceDirs) {
    try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
  }
  activeSourceDirs.length = 0;
}

// ── Lifecycle ──

test.beforeEach(() => {
  core.resetDbInstance();
  hooks.resetHooks();
  cleanupDir(TEST_DATA_DIR);
  fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
  cleanupSourceDirs();
});

test.after(() => {
  core.resetDbInstance();
  cleanupSourceDirs();
  try { cleanupDir(TEST_DATA_DIR); } catch {}
});

// ── Tests: Install ──

test("install: copies plugin and creates DB row", async () => {
  const { sourceDir, name } = writeTestPlugin({ name: "install-test" });
  activeSourceDirs.push(sourceDir);

  const row = await pluginManager.install(sourceDir);

  assert.equal(row.name, name);
  assert.equal(row.version, "1.0.0");
  assert.equal(row.description, "Integration test plugin");
  assert.equal(row.status, "installed");

  // Verify DB lookup works
  const fromDb = dbPlugins.getPluginByName(name);
  assert.ok(fromDb, "plugin should be retrievable from DB");
  assert.equal(fromDb!.name, name);
  assert.equal(fromDb!.version, "1.0.0");

  await pluginManager.uninstall(name);
});

test("install: throws on duplicate install", async () => {
  const { sourceDir, name } = writeTestPlugin({ name: "dup-test" });
  activeSourceDirs.push(sourceDir);

  await pluginManager.install(sourceDir);
  await assert.rejects(() => pluginManager.install(sourceDir), /already installed/);

  await pluginManager.uninstall(name);
});

test("install: throws on invalid source directory", async () => {
  const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-empty-"));
  activeSourceDirs.push(emptyDir);
  await assert.rejects(() => pluginManager.install(emptyDir), /No valid plugin found/);
});

// ── Tests: Activate ──

test("activate: transitions DB status to active", async () => {
  const { sourceDir, name } = writeTestPlugin({ name: "activate-status" });
  activeSourceDirs.push(sourceDir);

  await pluginManager.install(sourceDir);
  await pluginManager.activate(name);

  const row = dbPlugins.getPluginByName(name);
  assert.ok(row, "plugin should exist in DB");
  assert.equal(row!.status, "active");
  assert.equal(row!.enabled, 1);

  await pluginManager.uninstall(name);
});

test("activate: registers manifest-declared hooks", async () => {
  const { sourceDir, name } = writeTestPlugin({ name: "activate-hooks" });
  activeSourceDirs.push(sourceDir);

  await pluginManager.install(sourceDir);
  await pluginManager.activate(name);

  // onRequest should be registered (manifest declares it)
  const registered = hooks.getHooks("onRequest");
  const found = registered.find((r) => r.pluginName === name);
  assert.ok(found, "onRequest hook should be registered for the plugin");

  // onResponse should NOT be registered (manifest says false)
  const responseHooks = hooks.getHooks("onResponse");
  const notFound = responseHooks.find((r) => r.pluginName === name);
  assert.equal(notFound, undefined, "onResponse hook should not be registered");

  await pluginManager.uninstall(name);
});

test("activate: plugin handler fires on hook emit", async () => {
  const { sourceDir, name } = writeTestPlugin({ name: "activate-emit" });
  activeSourceDirs.push(sourceDir);

  await pluginManager.install(sourceDir);
  await pluginManager.activate(name);

  // Fire the onRequest hook with a PluginContext-like payload.
  // Plugins run in isolated child processes and cannot mutate the parent's object
  // in-place β€” use emitHookBlocking and inspect the returned merged metadata.
  const payload = { requestId: "test-req", body: {}, model: "test", metadata: {} };
  const result = await hooks.emitHookBlocking("onRequest", payload);

  // The handler sets metadata.hookCalled = true; it is returned in the merged result.
  assert.deepEqual((result as Record<string, unknown>).metadata, { hookCalled: true });

  await pluginManager.uninstall(name);
});

test("activate: is idempotent for already-active plugin", async () => {
  const { sourceDir, name } = writeTestPlugin({ name: "activate-idempotent" });
  activeSourceDirs.push(sourceDir);

  await pluginManager.install(sourceDir);
  await pluginManager.activate(name);
  // Second activate should not throw
  await pluginManager.activate(name);

  const row = dbPlugins.getPluginByName(name);
  assert.equal(row!.status, "active");

  await pluginManager.uninstall(name);
});

test("activate: throws for nonexistent plugin", async () => {
  await assert.rejects(() => pluginManager.activate("no-such-plugin"), /not found/);
});

// ── Tests: Deactivate ──

test("deactivate: transitions DB status to inactive", async () => {
  const { sourceDir, name } = writeTestPlugin({ name: "deactivate-status" });
  activeSourceDirs.push(sourceDir);

  await pluginManager.install(sourceDir);
  await pluginManager.activate(name);
  await pluginManager.deactivate(name);

  const row = dbPlugins.getPluginByName(name);
  assert.ok(row, "plugin should still exist in DB after deactivation");
  assert.equal(row!.status, "inactive");

  await pluginManager.uninstall(name);
});

test("deactivate: unregisters all hooks for the plugin", async () => {
  const { sourceDir, name } = writeTestPlugin({ name: "deactivate-hooks" });
  activeSourceDirs.push(sourceDir);

  await pluginManager.install(sourceDir);
  await pluginManager.activate(name);

  // Verify hook is registered before deactivation
  assert.ok(hooks.getHooks("onRequest").find((r) => r.pluginName === name));

  await pluginManager.deactivate(name);

  // Hook should be gone
  const after = hooks.getHooks("onRequest");
  assert.equal(after.find((r) => r.pluginName === name), undefined, "hook should be unregistered");

  await pluginManager.uninstall(name);
});

test("deactivate: hook no longer fires after deactivation", async () => {
  const { sourceDir, name } = writeTestPlugin({ name: "deactivate-nofire" });
  activeSourceDirs.push(sourceDir);

  await pluginManager.install(sourceDir);
  await pluginManager.activate(name);

  // Verify hook fires while active (use emitHookBlocking β€” child-process isolation
  // means plugins cannot mutate the parent's in-memory payload object in-place).
  const payload = { requestId: "req-1", body: {}, model: "test", metadata: {} };
  const result1 = await hooks.emitHookBlocking("onRequest", payload);
  assert.deepEqual((result1 as Record<string, unknown>).metadata, { hookCalled: true });

  await pluginManager.deactivate(name);

  // After deactivation, hook is unregistered β€” emitHookBlocking returns empty metadata.
  const payload2 = { requestId: "req-2", body: {}, model: "test", metadata: {} };
  const result2 = await hooks.emitHookBlocking("onRequest", payload2);
  assert.deepEqual((result2 as Record<string, unknown>).metadata, {});

  await pluginManager.uninstall(name);
});

// ── Tests: Uninstall ──

test("uninstall: removes DB row", async () => {
  const { sourceDir, name } = writeTestPlugin({ name: "uninstall-db" });
  activeSourceDirs.push(sourceDir);

  await pluginManager.install(sourceDir);
  assert.ok(dbPlugins.getPluginByName(name), "should exist before uninstall");

  await pluginManager.uninstall(name);

  assert.equal(dbPlugins.getPluginByName(name), null, "should be removed from DB");
});

test("uninstall: removes plugin directory from disk", async () => {
  const { sourceDir, name } = writeTestPlugin({ name: "uninstall-dir" });
  activeSourceDirs.push(sourceDir);

  const row = await pluginManager.install(sourceDir);
  const installedDir = row.pluginDir;
  assert.ok(fs.existsSync(installedDir), "plugin dir should exist after install");

  await pluginManager.uninstall(name);

  assert.ok(!fs.existsSync(installedDir), "plugin dir should be removed after uninstall");
});

test("uninstall: deactivates before removing if active", async () => {
  const { sourceDir, name } = writeTestPlugin({ name: "uninstall-active" });
  activeSourceDirs.push(sourceDir);

  await pluginManager.install(sourceDir);
  await pluginManager.activate(name);

  // Verify active + hook registered
  assert.equal(dbPlugins.getPluginByName(name)!.status, "active");
  assert.ok(hooks.getHooks("onRequest").find((r) => r.pluginName === name));

  await pluginManager.uninstall(name);

  // Plugin should be fully gone
  assert.equal(dbPlugins.getPluginByName(name), null);
  assert.equal(hooks.getHooks("onRequest").find((r) => r.pluginName === name), undefined);
});

test("uninstall: throws for nonexistent plugin", async () => {
  await assert.rejects(() => pluginManager.uninstall("ghost-plugin"), /not found/);
});

// ── Tests: Full lifecycle ──

test("full lifecycle: install -> activate -> hook fires -> deactivate -> uninstall", async () => {
  const { sourceDir, name } = writeTestPlugin({ name: "full-lifecycle" });
  activeSourceDirs.push(sourceDir);

  // 1. Install
  const row = await pluginManager.install(sourceDir);
  assert.equal(row.status, "installed");
  assert.ok(dbPlugins.getPluginByName(name), "exists in DB after install");

  // 2. Activate
  await pluginManager.activate(name);
  const afterActivate = dbPlugins.getPluginByName(name);
  assert.equal(afterActivate!.status, "active");
  assert.ok(hooks.getHooks("onRequest").find((r) => r.pluginName === name), "hook registered");

  // 3. Fire hook (use emitHookBlocking β€” child-process isolation means plugins cannot
  //    mutate the parent's in-memory payload object; check the returned merged result).
  const payload = { requestId: "lifecycle-req", body: {}, model: "test", metadata: {} };
  const hookResult = await hooks.emitHookBlocking("onRequest", payload);
  assert.deepEqual(
    (hookResult as Record<string, unknown>).metadata,
    { hookCalled: true },
    "hook handler executed"
  );

  // 4. Deactivate
  await pluginManager.deactivate(name);
  const afterDeactivate = dbPlugins.getPluginByName(name);
  assert.equal(afterDeactivate!.status, "inactive");
  assert.equal(
    hooks.getHooks("onRequest").find((r) => r.pluginName === name),
    undefined,
    "hook unregistered after deactivation"
  );

  // 5. Uninstall
  await pluginManager.uninstall(name);
  assert.equal(dbPlugins.getPluginByName(name), null, "removed from DB");
});

// ── Tests: Multi-plugin isolation ──

test("multiple plugins: hooks are isolated per plugin", async () => {
  const p1 = writeTestPlugin({ name: "multi-p1" });
  const p2 = writeTestPlugin({ name: "multi-p2" });
  activeSourceDirs.push(p1.sourceDir, p2.sourceDir);

  await pluginManager.install(p1.sourceDir);
  await pluginManager.install(p2.sourceDir);
  await pluginManager.activate("multi-p1");
  await pluginManager.activate("multi-p2");

  // Both should have onRequest hooks
  const onRequest = hooks.getHooks("onRequest");
  assert.ok(onRequest.find((r) => r.pluginName === "multi-p1"));
  assert.ok(onRequest.find((r) => r.pluginName === "multi-p2"));

  // Deactivate only p1
  await pluginManager.deactivate("multi-p1");

  const afterDeactivate = hooks.getHooks("onRequest");
  assert.equal(afterDeactivate.find((r) => r.pluginName === "multi-p1"), undefined);
  assert.ok(afterDeactivate.find((r) => r.pluginName === "multi-p2"), "p2 hook still registered");

  // Cleanup
  await pluginManager.uninstall("multi-p1");
  await pluginManager.uninstall("multi-p2");
});