Files
Termix/src/backend/tests/plugins/worker-deactivate.test.ts
T
LukeGus c94a453f1e feat: ship the ssh terminal as a first-party in-process plugin
Adds a hardcoded process:transport-owner tier so the terminal can own its
WebSocket server and ssh2 clients, which cannot cross the worker boundary.
2026-09-21 08:28:50 -05:00

122 lines
3.5 KiB
TypeScript

/**
* Worker plugins gained an optional deactivate() hook. It is best-effort by
* design: a plugin that ignores it or hangs must not be able to block a
* disable, it just loses its cleanup.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import path from "node:path";
import { PluginLoader } from "../../plugins/loader.js";
import { PluginBroker } from "../../plugins/broker.js";
import { createFixturePlugin, type Fixture } from "./fixture-plugin.js";
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
function buildLoader(broker: PluginBroker): PluginLoader {
return new PluginLoader({
onWorkerReady: (plugin, worker) => broker.attach(plugin, worker),
onWorkerGone: (plugin) => broker.detach(plugin.id),
onBeforeTerminate: async (plugin) => {
const runtime = broker.get(plugin.id);
if (runtime) await broker.requestDeactivate(runtime, 2000);
},
});
}
function newBroker(): PluginBroker {
return new PluginBroker({
listHosts: async () => [],
resolveHost: async () => null,
});
}
describe("worker deactivate hook", () => {
let fixture: Fixture | null = null;
let loader: PluginLoader | null = null;
afterEach(async () => {
await loader?.shutdown();
loader = null;
fixture?.cleanup();
fixture = null;
});
it("runs the plugin's deactivate() before the worker is terminated", async () => {
fixture = createFixturePlugin();
const marker = path.join(fixture.root, "marker.txt");
fs.writeFileSync(
path.join(fixture.dir, "backend", "index.mjs"),
`
import fs from "node:fs";
export async function activate() {}
export async function deactivate() {
fs.writeFileSync(${JSON.stringify(marker)}, "cleaned");
}
`,
);
loader = buildLoader(newBroker());
const plugin = await loader.load(fixture.dir);
await loader.activate(plugin.id, "user-1");
await loader.deactivate(plugin.id);
expect(plugin.state).toBe("stopped");
expect(fs.readFileSync(marker, "utf8")).toBe("cleaned");
});
it("still stops a plugin that exports no deactivate", async () => {
fixture = createFixturePlugin();
loader = buildLoader(newBroker());
const plugin = await loader.load(fixture.dir);
await loader.activate(plugin.id, "user-1");
await loader.deactivate(plugin.id);
expect(plugin.state).toBe("stopped");
expect(plugin.worker).toBeNull();
});
it("does not let a hanging deactivate block the disable", async () => {
fixture = createFixturePlugin();
fs.writeFileSync(
path.join(fixture.dir, "backend", "index.mjs"),
`
export async function activate() {}
export function deactivate() {
return new Promise(() => {});
}
`,
);
const broker = newBroker();
loader = new PluginLoader({
onWorkerReady: (plugin, worker) => broker.attach(plugin, worker),
onWorkerGone: (plugin) => broker.detach(plugin.id),
onBeforeTerminate: async (plugin) => {
const runtime = broker.get(plugin.id);
// Short timeout so the test does not wait out the real one.
if (runtime) await broker.requestDeactivate(runtime, 150);
},
});
const plugin = await loader.load(fixture.dir);
await loader.activate(plugin.id, "user-1");
const started = Date.now();
await loader.deactivate(plugin.id);
expect(plugin.state).toBe("stopped");
expect(Date.now() - started).toBeLessThan(2000);
});
});