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.
This commit is contained in:
LukeGus committed 2026-09-21 08:28:50 -05:00
1 parent e040b12045
commit c94a453f1e
43 files changed
+2426 -38

No files matched your search

@@ -0,0 +1,84 @@
/**
* The real bundled ssh-terminal plugin, as it ships.
*
* This reads plugins/ in the repo rather than a fixture: the manifest is a
* checked-in artifact, and a typo in it would otherwise only show up at boot.
*/
import { describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import path from "node:path";
import { getBundledPluginsDir } from "../../plugins/paths.js";
import { parseManifest } from "../../plugins/manifest.js";
import {
isFirstParty,
runsInProcess,
TRANSPORT_OWNER_CAPABILITY,
} from "../../plugins/first-party.js";
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
function readBundledManifest(pluginId: string): Record<string, unknown> {
const file = path.join(getBundledPluginsDir(), pluginId, "manifest.json");
return JSON.parse(fs.readFileSync(file, "utf8"));
}
describe("bundled ssh-terminal plugin", () => {
it("ships a directory with a manifest and both entry points", () => {
const dir = path.join(getBundledPluginsDir(), "ssh-terminal");
expect(fs.existsSync(path.join(dir, "manifest.json"))).toBe(true);
expect(fs.existsSync(path.join(dir, "backend", "index.mjs"))).toBe(true);
expect(fs.existsSync(path.join(dir, "frontend", "index.mjs"))).toBe(true);
});
it("has a manifest that passes the real validator", () => {
const { manifest, errors } = parseManifest(
readBundledManifest("ssh-terminal"),
);
expect(errors).toEqual([]);
expect(manifest?.id).toBe("ssh-terminal");
expect(manifest?.category).toBe("Terminal");
});
it("declares the transport-owner capability and qualifies for the tier", () => {
const { manifest } = parseManifest(readBundledManifest("ssh-terminal"));
expect(manifest?.permissions).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(isFirstParty("ssh-terminal")).toBe(true);
expect(runsInProcess("ssh-terminal", manifest!.permissions)).toBe(true);
});
it("contributes the terminal tab the shell registers", () => {
const { manifest } = parseManifest(readBundledManifest("ssh-terminal"));
const tab = manifest?.contributes?.tabs?.[0];
// The shell keys tab content off this id, so it has to stay "terminal".
expect(tab?.id).toBe("terminal");
expect(tab?.openFrom).toContain("rail");
});
it("exports activate and deactivate from its backend entry", () => {
const entry = path.join(
getBundledPluginsDir(),
"ssh-terminal",
"backend",
"index.mjs",
);
const source = fs.readFileSync(entry, "utf8");
// A first-party plugin owns a listening port, so deactivate is not
// optional here the way it is for an ordinary plugin.
expect(source).toMatch(/export async function activate/);
expect(source).toMatch(/export async function deactivate/);
});
});
@@ -0,0 +1,180 @@
/**
* The central promise of shipping the terminal as a plugin: disabling it
* releases what it owns, and nothing else notices.
*
* The SSH connection pool deliberately stayed in core, so a worker plugin's
* ctx.ssh keeps working with the terminal disabled. If the pool had moved into
* the terminal plugin, these tests would fail, which is the point of them.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import net from "node:net";
import { PluginLoader } from "../../plugins/loader.js";
import { PluginBroker } from "../../plugins/broker.js";
import { TRANSPORT_OWNER_CAPABILITY } from "../../plugins/first-party.js";
import { clearRegistry } from "../../plugins/registry.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(),
},
}));
vi.mock("../../plugins/permissions.js", () => ({
assertCapability: vi.fn().mockResolvedValue(undefined),
hasCapability: vi.fn().mockResolvedValue(true),
PluginPermissionError: class extends Error {},
invalidatePluginPermissionCache: vi.fn(),
}));
const TEST_PORT = 39102;
function isListening(port: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = net.connect({ port, host: "127.0.0.1" });
socket.once("connect", () => {
socket.destroy();
resolve(true);
});
socket.once("error", () => resolve(false));
});
}
/** Stands in for the terminal: a first-party plugin that owns a real port. */
function terminalLikeSource(port: number): string {
return `
import net from "node:net";
let server = null;
export async function activate(ctx) {
server = net.createServer();
await new Promise((resolve) => server.listen(${port}, "127.0.0.1", resolve));
ctx.registry.provide("terminal.sessions", { live: true });
}
export async function deactivate() {
if (!server) return;
await new Promise((resolve) => server.close(resolve));
server = null;
}
`;
}
describe("disabling the terminal plugin", () => {
const fixtures: Fixture[] = [];
let loader: PluginLoader | null = null;
afterEach(async () => {
await loader?.shutdown();
loader = null;
for (const fixture of fixtures.splice(0)) fixture.cleanup();
clearRegistry();
});
it("frees its port and leaves another plugin's ctx.ssh working", async () => {
const terminal = createFixturePlugin({
id: "ssh-terminal",
permissions: [TRANSPORT_OWNER_CAPABILITY],
manifestOverrides: { category: "Terminal" },
backendSource: terminalLikeSource(TEST_PORT),
});
fixtures.push(terminal);
const other = createFixturePlugin({
id: "other-plugin",
permissions: ["ssh.exec"],
// Exposes an HTTP route the test invokes after the terminal is
// disabled, so the ctx.ssh round trip genuinely happens afterwards and
// travels the real worker boundary.
backendSource: `
export async function activate(ctx) {
await ctx.http.route("GET", "/run", async () => {
const handle = await ctx.ssh.connect(1);
const result = await ctx.ssh.exec(handle, "uptime");
await ctx.ssh.close(handle);
return result;
});
}
`,
});
fixtures.push(other);
// Stands in for the core pool. The terminal plugin does not provide it,
// so disabling the terminal must not affect it.
const open = vi.fn().mockResolvedValue({
release: vi.fn(),
exec: vi
.fn()
.mockResolvedValue({ stdout: "up 3 days", stderr: "", code: 0 }),
});
const broker = new PluginBroker({
listHosts: async () => [],
resolveHost: async () => null,
ssh: { open },
});
loader = new PluginLoader({
onWorkerReady: (plugin, worker) => broker.attach(plugin, worker),
onWorkerGone: (plugin) => broker.detach(plugin.id),
});
await loader.load(terminal.dir);
await loader.load(other.dir);
await loader.activate("ssh-terminal");
await loader.activate("other-plugin", "user-1");
expect(await isListening(TEST_PORT)).toBe(true);
await loader.deactivate("ssh-terminal");
// What the user sees: the terminal's port is gone.
expect(await isListening(TEST_PORT)).toBe(false);
expect(loader.get("ssh-terminal")?.state).toBe("stopped");
// And the other plugin is untouched, still running off the core pool.
const otherPlugin = loader.get("other-plugin");
expect(otherPlugin?.state).toBe("active");
// The real test: a ctx.ssh round trip completed AFTER the terminal was
// disabled, off the core pool, across the worker boundary.
const runtime = broker.get("other-plugin")!;
const result = (await broker.invokeRoute(runtime, "GET /run", {
method: "GET",
path: "/run",
params: {},
query: {},
body: null,
headers: {},
userId: "user-1",
})) as { stdout: string };
expect(open).toHaveBeenCalled();
expect(result.stdout).toBe("up 3 days");
});
it("can be re-enabled after being disabled", async () => {
const terminal = createFixturePlugin({
id: "ssh-terminal",
permissions: [TRANSPORT_OWNER_CAPABILITY],
manifestOverrides: { category: "Terminal" },
backendSource: terminalLikeSource(TEST_PORT + 1),
});
fixtures.push(terminal);
loader = new PluginLoader();
await loader.load(terminal.dir);
await loader.activate("ssh-terminal");
expect(await isListening(TEST_PORT + 1)).toBe(true);
await loader.deactivate("ssh-terminal");
expect(await isListening(TEST_PORT + 1)).toBe(false);
await loader.activate("ssh-terminal");
expect(await isListening(TEST_PORT + 1)).toBe(true);
});
});
@@ -0,0 +1,113 @@
/**
* The first-party tier is the one place in the plugin system where code runs
* without the worker boundary, so the thing most worth testing is that it
* cannot be entered by a plugin that says it should be.
*/
import { describe, expect, it } from "vitest";
import {
FIRST_PARTY_PLUGIN_IDS,
isFirstParty,
runsInProcess,
TRANSPORT_OWNER_CAPABILITY,
} from "../../plugins/first-party.js";
import {
parseManifest,
SUPPORTED_PLUGIN_API_VERSION,
} from "../../plugins/manifest.js";
function manifest(overrides: Record<string, unknown> = {}) {
return {
id: "sample-plugin",
name: "Sample Plugin",
version: "1.0.0",
description: "A plugin.",
author: { name: "Someone" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: SUPPORTED_PLUGIN_API_VERSION },
capabilities: {
backend: true,
frontend: false,
electron: false,
platforms: ["linux"],
},
permissions: [] as string[],
sidecars: [],
...overrides,
};
}
describe("first-party allowlist", () => {
it("contains only ssh-terminal", () => {
expect([...FIRST_PARTY_PLUGIN_IDS]).toEqual(["ssh-terminal"]);
});
it("recognises ssh-terminal and nothing else", () => {
expect(isFirstParty("ssh-terminal")).toBe(true);
expect(isFirstParty("ssh-terminal-pro")).toBe(false);
expect(isFirstParty("community-plugin")).toBe(false);
expect(isFirstParty("")).toBe(false);
});
});
describe("runsInProcess", () => {
it("requires both the allowlist and the declared capability", () => {
expect(runsInProcess("ssh-terminal", [TRANSPORT_OWNER_CAPABILITY])).toBe(
true,
);
// On the list but not asking for it: stays in a worker, so the manifest
// remains an honest description of what the plugin does.
expect(runsInProcess("ssh-terminal", ["hosts.read"])).toBe(false);
// Asking for it but not on the list.
expect(
runsInProcess("community-plugin", [TRANSPORT_OWNER_CAPABILITY]),
).toBe(false);
expect(runsInProcess("community-plugin", ["hosts.read"])).toBe(false);
});
});
describe("manifest gate on the reserved capability", () => {
it("refuses the capability for a plugin that is not first-party", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({ permissions: [TRANSPORT_OWNER_CAPABILITY] }),
);
expect(parsed).toBeUndefined();
expect(errors).toHaveLength(1);
expect(errors[0]).toContain(TRANSPORT_OWNER_CAPABILITY);
expect(errors[0]).toContain("reserved for first-party plugins");
});
it("refuses it even when mixed in with ordinary permissions", () => {
const { manifest: parsed } = parseManifest(
manifest({
permissions: ["hosts.read", TRANSPORT_OWNER_CAPABILITY, "storage.own"],
}),
);
expect(parsed).toBeUndefined();
});
it("allows it for ssh-terminal", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({
id: "ssh-terminal",
permissions: [TRANSPORT_OWNER_CAPABILITY],
}),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("ssh-terminal");
});
it("still accepts an ordinary manifest that never mentions it", () => {
const { manifest: parsed, errors } = parseManifest(
manifest({ permissions: ["hosts.read"] }),
);
expect(errors).toEqual([]);
expect(parsed?.id).toBe("sample-plugin");
});
});
@@ -0,0 +1,200 @@
/**
* The in-process branch of the loader: a first-party plugin runs on the main
* thread, with no worker, and its deactivate() actually gets awaited.
*
* These use the real ssh-terminal id because the allowlist is hardcoded and
* that is the point -- there is deliberately no way to fake membership.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import path from "node:path";
import { PluginLoader } from "../../plugins/loader.js";
import { TRANSPORT_OWNER_CAPABILITY } from "../../plugins/first-party.js";
import { clearRegistry, consume } from "../../plugins/registry.js";
import { pluginEvents } from "../../plugins/events.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(),
},
}));
const FIRST_PARTY_OPTIONS = {
id: "ssh-terminal",
permissions: [TRANSPORT_OWNER_CAPABILITY],
manifestOverrides: { category: "Terminal" },
};
/**
* Writes a marker file rather than using a module-level variable: the plugin
* is imported by URL into the same process, but ESM caching means a second
* fixture at a different path is a different module instance.
*/
function backendWritingMarker(markerPath: string, extra = ""): string {
const literal = JSON.stringify(markerPath);
return `
import fs from "node:fs";
export async function activate(ctx) {
fs.writeFileSync(${literal}, "activated");
${extra}
}
export async function deactivate() {
fs.appendFileSync(${literal}, ":deactivated");
}
`;
}
describe("in-process first-party plugins", () => {
let fixture: Fixture | null = null;
let loader: PluginLoader | null = null;
beforeEach(() => {
clearRegistry();
pluginEvents.clear();
});
afterEach(async () => {
await loader?.shutdown();
loader = null;
fixture?.cleanup();
fixture = null;
clearRegistry();
pluginEvents.clear();
});
it("activates on the main thread with no worker and no owner", async () => {
fixture = createFixturePlugin(FIRST_PARTY_OPTIONS);
const marker = path.join(fixture.root, "marker.txt");
fs.writeFileSync(
path.join(fixture.dir, "backend", "index.mjs"),
backendWritingMarker(marker),
);
const onWorkerReady = vi.fn();
loader = new PluginLoader({ onWorkerReady });
const plugin = await loader.load(fixture.dir);
// No ownerUserId: an in-process plugin never acts as a user.
await loader.activate(plugin.id);
expect(plugin.state).toBe("active");
expect(plugin.worker).toBeNull();
expect(plugin.inProcess).not.toBeNull();
expect(plugin.ownerUserId).toBeNull();
// The broker attaches via this hook; an in-process plugin bypasses it.
expect(onWorkerReady).not.toHaveBeenCalled();
expect(fs.readFileSync(marker, "utf8")).toBe("activated");
});
it("awaits deactivate() on disable and clears the handle", async () => {
fixture = createFixturePlugin(FIRST_PARTY_OPTIONS);
const marker = path.join(fixture.root, "marker.txt");
fs.writeFileSync(
path.join(fixture.dir, "backend", "index.mjs"),
backendWritingMarker(marker),
);
loader = new PluginLoader();
const plugin = await loader.load(fixture.dir);
await loader.activate(plugin.id);
await loader.deactivate(plugin.id);
expect(plugin.state).toBe("stopped");
expect(plugin.inProcess).toBeNull();
expect(fs.readFileSync(marker, "utf8")).toBe("activated:deactivated");
});
it("revokes registry entries the plugin provided", async () => {
fixture = createFixturePlugin(FIRST_PARTY_OPTIONS);
const marker = path.join(fixture.root, "marker.txt");
fs.writeFileSync(
path.join(fixture.dir, "backend", "index.mjs"),
backendWritingMarker(
marker,
`ctx.registry.provide("terminal.sessions", { live: true });`,
),
);
loader = new PluginLoader();
const plugin = await loader.load(fixture.dir);
await loader.activate(plugin.id);
expect(consume("terminal.sessions")).toEqual({ live: true });
await loader.deactivate(plugin.id);
// Disabling the plugin must take its services with it, or a consumer
// would keep resolving a dead provider.
expect(consume("terminal.sessions")).toBeUndefined();
});
it("detaches event listeners the plugin registered", async () => {
fixture = createFixturePlugin(FIRST_PARTY_OPTIONS);
const marker = path.join(fixture.root, "marker.txt");
fs.writeFileSync(
path.join(fixture.dir, "backend", "index.mjs"),
backendWritingMarker(marker, `ctx.events.on("host.status", () => {});`),
);
loader = new PluginLoader();
const plugin = await loader.load(fixture.dir);
await loader.activate(plugin.id);
expect(pluginEvents.listenerCount("host.status")).toBe(1);
await loader.deactivate(plugin.id);
expect(pluginEvents.listenerCount("host.status")).toBe(0);
});
it("reports an activation failure instead of leaving a half-live plugin", async () => {
fixture = createFixturePlugin(FIRST_PARTY_OPTIONS);
fs.writeFileSync(
path.join(fixture.dir, "backend", "index.mjs"),
`export async function activate() { throw new Error("boom"); }`,
);
loader = new PluginLoader();
const plugin = await loader.load(fixture.dir);
await expect(loader.activate(plugin.id)).rejects.toThrow("boom");
expect(plugin.state).toBe("crashed");
expect(plugin.inProcess).toBeNull();
expect(plugin.lastError).toContain("boom");
});
it("rejects an entry with no activate export", async () => {
fixture = createFixturePlugin(FIRST_PARTY_OPTIONS);
fs.writeFileSync(
path.join(fixture.dir, "backend", "index.mjs"),
`export const nothing = true;`,
);
loader = new PluginLoader();
const plugin = await loader.load(fixture.dir);
await expect(loader.activate(plugin.id)).rejects.toThrow(
/does not export an activate/,
);
});
it("runs a first-party plugin in a worker when it does not ask for the tier", async () => {
fixture = createFixturePlugin({
id: "ssh-terminal",
permissions: ["hosts.read"],
manifestOverrides: { category: "Terminal" },
});
loader = new PluginLoader();
const plugin = await loader.load(fixture.dir);
await loader.activate(plugin.id, "user-1");
expect(plugin.state).toBe("active");
expect(plugin.worker).not.toBeNull();
expect(plugin.inProcess).toBeNull();
});
});
+19 -1
View File
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PluginLoader, MAX_RESTART_ATTEMPTS } from "../../plugins/loader.js";
import { createFixturePlugin, type Fixture } from "./fixture-plugin.js";
@@ -280,6 +280,24 @@ describe("PluginLoader", () => {
});
describe("loadAll", () => {
// loadAll scans bundled plugins as well as user-installed ones, and the
// repo really does ship one now. These tests are about the user directory,
// so they point the bundled dir somewhere empty.
let previousBundled: string | undefined;
beforeEach(() => {
previousBundled = process.env.TERMIX_BUNDLED_PLUGINS_DIR;
process.env.TERMIX_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled-plugins";
});
afterEach(() => {
if (previousBundled === undefined) {
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
} else {
process.env.TERMIX_BUNDLED_PLUGINS_DIR = previousBundled;
}
});
it("returns an empty list when the plugins dir does not exist", async () => {
const previous = process.env.DATA_DIR;
process.env.DATA_DIR = "/nonexistent/data-dir";
@@ -0,0 +1,69 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
clearRegistry,
consume,
has,
provide,
revoke,
} from "../../plugins/registry.js";
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
describe("service registry", () => {
afterEach(() => clearRegistry());
it("round-trips a provider", () => {
const pool = { name: "ssh-pool" };
provide("ssh.transport", pool);
expect(has("ssh.transport")).toBe(true);
expect(consume<typeof pool>("ssh.transport")).toBe(pool);
});
it("returns undefined rather than throwing for a missing provider", () => {
// Callers have to cope with a provider disappearing when its plugin is
// disabled, so this is the contract rather than an error.
expect(consume("nothing.here")).toBeUndefined();
expect(has("nothing.here")).toBe(false);
});
it("replaces an existing provider", () => {
provide("ssh.transport", { id: 1 });
provide("ssh.transport", { id: 2 });
expect(consume<{ id: number }>("ssh.transport")?.id).toBe(2);
});
it("only revokes the value that is actually registered", () => {
const first = { id: 1 };
const second = { id: 2 };
provide("terminal.sessions", first);
provide("terminal.sessions", second);
// A crashed-and-restarted plugin must not revoke the replacement its own
// restart installed.
expect(revoke("terminal.sessions", first)).toBe(false);
expect(consume("terminal.sessions")).toBe(second);
expect(revoke("terminal.sessions", second)).toBe(true);
expect(consume("terminal.sessions")).toBeUndefined();
});
it("revokes unconditionally when no value is given", () => {
provide("terminal.sessions", { id: 1 });
expect(revoke("terminal.sessions")).toBe(true);
expect(has("terminal.sessions")).toBe(false);
});
it("reports a revoke of something never registered", () => {
expect(revoke("nothing.here")).toBe(false);
});
});
@@ -0,0 +1,121 @@
/**
* 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);
});
});