feat: finish the docker plugin (B15)

This commit is contained in:
LukeGus committed 2026-09-24 11:58:20 -05:00
1 parent 0a3f05d99d
commit 993a91c899
167 files changed
+26299 -5136

No files matched your search

@@ -0,0 +1,170 @@
import { EventEmitter } from "node:events";
import express from "express";
import { afterEach, describe, expect, it } from "vitest";
import {
createMockCtx,
type MockPluginContext,
} from "@termix/plugin-sdk/testing";
import type { PluginWebSocketConnection } from "@termix/plugin-sdk/backend";
import { activate } from "../../src/backend/index.js";
import { manifest, sshHost } from "./server";
import { FakeClient, FakeStream } from "./fake-ssh";
/** A ws WebSocket as far as the console uses one. */
class FakeSocket extends EventEmitter {
readyState = 1;
sent: Array<Record<string, unknown>> = [];
closedWith: [number, string] | null = null;
send(data: string) {
this.sent.push(JSON.parse(data));
}
ping() {}
close(code: number, reason: string) {
if (this.readyState !== 1) return;
this.readyState = 3;
this.closedWith = [code, reason];
this.emit("close");
}
message(payload: unknown) {
this.emit("message", Buffer.from(JSON.stringify(payload)));
}
}
function consoleClient() {
const shells: FakeStream[] = [];
const client = new FakeClient([
[/which bash/, { stdout: "/bin/bash\n" }],
[/exec -it abc123 \/bin\/bash/, (stream) => shells.push(stream)],
]);
return { client, shells };
}
async function activated(client: FakeClient): Promise<MockPluginContext> {
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
router: () => express.Router(),
permissions: ["docker.use"],
sshHosts: [sshHost(7)],
sshClient: client,
});
await mock.ctx.settings.setHost(7, "enableDocker", true);
// Like the runtime, disposing a connection ends its client.
const connect = mock.ctx.ssh.connect;
mock.ctx.ssh.connect = (async (...args: Parameters<typeof connect>) => ({
...(await connect(...args)),
dispose: () => client.end(),
})) as typeof connect;
await activate(mock.ctx);
return mock;
}
async function openConsole(mock: MockPluginContext, userId = "user-1") {
const route = mock.wsRoutes.find((r) => r.path === "/console");
expect(route?.options).toEqual({ public: true, optionalAuth: true });
const socket = new FakeSocket();
mock.setActor(userId || undefined);
await route!.handler!({
userId,
request: {},
socket,
clientIp: "127.0.0.1",
requestOrigin: "http://localhost",
isDataUnlocked: () => true,
} as PluginWebSocketConnection);
return socket;
}
async function until(check: () => boolean) {
for (let i = 0; i < 50 && !check(); i++) {
await new Promise((resolve) => setImmediate(resolve));
}
expect(check()).toBe(true);
}
let disposals: Array<() => void | Promise<void>> = [];
afterEach(async () => {
for (const dispose of disposals.reverse()) await dispose();
disposals = [];
});
describe("docker console", () => {
it("refuses a socket without a user", async () => {
const mock = await activated(consoleClient().client);
disposals.push(...mock.disposals);
const socket = await openConsole(mock, "");
expect(socket.closedWith?.[0]).toBe(1008);
});
it("closes an open console on disable, and the next enable works", async () => {
const first = consoleClient();
const mock = await activated(first.client);
const socket = await openConsole(mock);
socket.message({
type: "connect",
data: {
hostConfig: { id: 7, ip: "10.0.0.7" },
containerId: "abc123",
shell: "bash",
cols: 100,
rows: 30,
},
});
await until(() => socket.sent.some((m) => m.type === "connected"));
socket.message({ type: "input", data: "ls\n" });
await until(() => first.shells[0]?.written.includes("ls\n") ?? false);
// Disable: the runtime empties the disposable bag.
for (const dispose of [...mock.disposals].reverse()) await dispose();
expect(first.shells[0].ended).toBe(true);
expect(first.client.ended).toBe(true);
expect(socket.closedWith?.[0]).toBe(1001);
// Enable again: a fresh activate on the same module serves a new console.
const second = consoleClient();
const again = await activated(second.client);
disposals.push(...again.disposals);
const next = await openConsole(again);
next.message({
type: "connect",
data: { hostConfig: { id: 7, ip: "10.0.0.7" }, containerId: "abc123" },
});
await until(() => next.sent.some((m) => m.type === "connected"));
second.shells[0].emit("data", Buffer.from("hello"));
await until(() =>
next.sent.some((m) => m.type === "output" && m.data === "hello"),
);
});
it("refuses a host id that resolves to another machine", async () => {
const mock = await activated(consoleClient().client);
disposals.push(...mock.disposals);
const socket = await openConsole(mock);
socket.message({
type: "connect",
data: { hostConfig: { id: 7, ip: "10.9.9.9" }, containerId: "abc123" },
});
await until(() => socket.sent.some((m) => m.type === "error"));
expect(String(socket.sent[0].message)).toMatch(/Host mismatch/);
});
it("refuses a host with Docker switched off", async () => {
const mock = await activated(consoleClient().client);
disposals.push(...mock.disposals);
await mock.ctx.settings.setHost(7, "enableDocker", false);
const socket = await openConsole(mock);
socket.message({
type: "connect",
data: { hostConfig: { id: 7, ip: "10.0.0.7" }, containerId: "abc123" },
});
await until(() => socket.sent.some((m) => m.type === "error"));
expect(String(socket.sent[0].message)).toMatch(/not enabled/);
});
});
+84
View File
@@ -0,0 +1,84 @@
import { EventEmitter } from "node:events";
/** An ssh2 exec channel: stdout on "data", stderr on `stderr`, then "close". */
export class FakeStream extends EventEmitter {
stderr = new EventEmitter();
written: string[] = [];
ended = false;
window: [number, number] | null = null;
write(data: string) {
this.written.push(data);
return true;
}
end() {
this.ended = true;
}
setWindow(rows: number, cols: number) {
this.window = [rows, cols];
}
/** Emits output and exits, on the next tick like a real channel. */
finish(stdout: string, code = 0, stderr = "") {
setImmediate(() => {
if (stdout) this.emit("data", Buffer.from(stdout));
if (stderr) this.stderr.emit("data", Buffer.from(stderr));
this.emit("close", code);
});
}
}
export type ExecReply =
| { stdout?: string; stderr?: string; code?: number }
| ((stream: FakeStream) => void);
/**
* An ssh2 Client that answers exec by the first matching pattern. A reply
* may be a function for a long-lived channel such as a console.
*/
export class FakeClient extends EventEmitter {
commands: string[] = [];
streams: FakeStream[] = [];
ended = false;
constructor(private replies: Array<[RegExp, ExecReply]> = []) {
super();
}
reply(pattern: RegExp, reply: ExecReply) {
this.replies.unshift([pattern, reply]);
}
exec(
command: string,
optionsOrCallback: unknown,
maybeCallback?: (err: Error | undefined, stream: FakeStream) => void,
) {
const callback = (
typeof optionsOrCallback === "function"
? optionsOrCallback
: maybeCallback
) as (err: Error | undefined, stream: FakeStream) => void;
this.commands.push(command);
const stream = new FakeStream();
this.streams.push(stream);
const match = this.replies.find(([pattern]) => pattern.test(command));
callback(undefined, stream);
if (!match) {
stream.finish("");
return this;
}
const reply = match[1];
if (typeof reply === "function") reply(stream);
else stream.finish(reply.stdout ?? "", reply.code ?? 0, reply.stderr ?? "");
return this;
}
end() {
if (this.ended) return;
this.ended = true;
this.emit("close");
}
}
@@ -0,0 +1,143 @@
import { afterEach, describe, expect, it } from "vitest";
import express from "express";
import { createMockCtx } from "@termix/plugin-sdk/testing";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import { activate } from "../../src/backend/index.js";
import {
normalizeImportedHost,
readDockerHostSettings,
} from "../../src/backend/host-settings.js";
import {
diffContainerStates,
parseContainerStates,
} from "../../src/backend/container-state.js";
import { manifest, startServer, type TestServer } from "./server";
let server: TestServer | null = null;
afterEach(async () => {
await server?.close();
server = null;
});
describe("docker activate", () => {
it("fails closed without network:serve", async () => {
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities.filter(
(cap) => cap !== "network:serve",
),
router: () => express.Router(),
});
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
PluginCapabilityError,
);
});
it("cannot reach a host without ssh:connect", async () => {
server = await startServer({
mock: {
capabilities: manifest.capabilities.filter(
(cap) => cap !== "ssh:connect",
),
},
});
const response = await server.request("POST", "/ssh/connect", {
body: { sessionId: "s1", hostId: 7 },
});
expect(response.status).toBe(500);
});
it("registers its import normalizer", async () => {
server = await startServer();
expect(
server.mock.ctx.registry.consume("docker.hostImportNormalizer"),
).toBe(normalizeImportedHost);
});
});
describe("docker host settings", () => {
it("reads the switch and runtime with defaults", async () => {
server = await startServer({ dockerOn: false });
expect(await readDockerHostSettings(server.mock.ctx, 7)).toEqual({
enabled: false,
runtime: "docker",
});
await server.mock.ctx.settings.setHost(7, "enableDocker", true);
await server.mock.ctx.settings.setHost(7, "containerRuntime", "podman");
expect(await readDockerHostSettings(server.mock.ctx, 7)).toEqual({
enabled: true,
runtime: "podman",
});
});
it("takes an export's nested settings over the old flat fields", () => {
expect(
normalizeImportedHost({
enableDocker: false,
pluginSettings: {
docker: { enableDocker: true, containerRuntime: "podman" },
},
}),
).toEqual({ enableDocker: true, containerRuntime: "podman" });
});
it("reads 2.8 flat fields and proxmox-created rows", () => {
expect(
normalizeImportedHost({
enableDocker: 1,
dockerConfig: '{"runtime":"podman"}',
}),
).toEqual({ enableDocker: true, containerRuntime: "podman" });
expect(normalizeImportedHost({ enableDocker: true })).toEqual({
enableDocker: true,
});
expect(normalizeImportedHost({ name: "plain" })).toBeNull();
});
});
/**
* The polling side needs SSH, so what is tested here is the pure part:
* turning `ps` output into states, and two snapshots into events.
*/
describe("container state", () => {
it("reads name, state and health out of the ps output", () => {
const states = parseContainerStates(
[
'{"name":"web","state":"running","status":"Up 2 hours"}',
"",
"not json at all",
'{"state":"running"}',
'{"name":"api","state":"running","status":"Up 1 hour (unhealthy)"}',
].join("\n"),
);
expect([...states.keys()]).toEqual(["web", "api"]);
expect(states.get("api")).toEqual({ state: "running", unhealthy: true });
});
it("reports transitions and skips containers seen for the first time", () => {
const running = { state: "running", unhealthy: false };
const exited = { state: "exited", unhealthy: false };
const restarting = { state: "restarting", unhealthy: false };
expect(
diffContainerStates(
new Map([
["web", running],
["db", exited],
["cache", running],
]),
new Map([
["web", exited],
["db", running],
["cache", restarting],
["new", running],
]),
),
).toEqual([
{ container: "web", event: "exited" },
{ container: "db", event: "started" },
{ container: "cache", event: "restarting" },
]);
});
});
+282
View File
@@ -0,0 +1,282 @@
import { afterEach, describe, expect, it } from "vitest";
import type {
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
import { startServer, type TestServer } from "./server";
import { FakeClient } from "./fake-ssh";
let server: TestServer | null = null;
afterEach(async () => {
await server?.close();
server = null;
});
async function connect(s: TestServer, sessionId = "s1", hostId = 7) {
return s.request("POST", "/ssh/connect", { body: { sessionId, hostId } });
}
/** Swaps ctx.ssh.connect for one that runs `flow` against the prompt channel. */
function promptingConnect(
s: TestServer,
flow: (
ask: NonNullable<PluginSshConnectOptions["prompt"]>["ask"],
) => Promise<void>,
) {
s.mock.ctx.ssh.connect = (async (
host: PluginSshHost,
options?: PluginSshConnectOptions,
) => {
await flow(options!.prompt!.ask);
return {
client: s.client as never,
jumpClient: null,
host,
dispose: () => s.client.end(),
};
}) as never;
}
describe("docker routes", () => {
it("refuses every route without docker.use", async () => {
server = await startServer({ permissions: [] });
const calls: Array<[string, string]> = [
["POST", "/ssh/connect"],
["POST", "/ssh/connect-totp"],
["POST", "/ssh/connect-warpgate"],
["POST", "/ssh/disconnect"],
["POST", "/ssh/keepalive"],
["GET", "/ssh/status?sessionId=s1"],
["GET", "/validate/s1"],
["GET", "/containers/s1"],
["GET", "/containers/s1/abc"],
["POST", "/containers/s1/abc/start"],
["DELETE", "/containers/s1/abc/remove"],
["GET", "/containers/s1/abc/logs"],
["GET", "/containers/s1/abc/stats"],
];
for (const [method, path] of calls) {
const response = await server.request(method, path, {
body: method === "GET" ? undefined : {},
});
expect(response.status, `${method} ${path}`).toBe(403);
}
});
it("refuses a host with Docker switched off", async () => {
server = await startServer({ dockerOn: false });
const response = await connect(server);
expect(response.status).toBe(403);
expect(response.body.code).toBe("DOCKER_DISABLED");
});
it("answers 404 for a host the user cannot reach", async () => {
server = await startServer();
expect((await connect(server, "s1", 99)).status).toBe(404);
});
it("connects, validates and manages containers on the session", async () => {
server = await startServer();
const connected = await connect(server);
expect(connected.status).toBe(200);
expect(connected.body.success).toBe(true);
expect(server.mock.statusReports).toContainEqual({ hostId: 7, ok: true });
const validation = await server.request("GET", "/validate/s1");
expect(validation.body).toEqual({
available: true,
version: "27.1.1",
runtime: "docker",
});
const list = await server.request("GET", "/containers/s1");
expect(list.body).toEqual([
expect.objectContaining({ id: "abc123", name: "web", state: "running" }),
]);
server.client.reply(/ start abc123$/, { stdout: "abc123" });
const started = await server.request("POST", "/containers/s1/abc123/start");
expect(started.status).toBe(200);
expect(
server.client.commands.some((c) => c.endsWith("docker start abc123")),
).toBe(true);
server.client.reply(/ logs abc123/, { stdout: "line one\nline two\n" });
const logs = await server.request(
"GET",
"/containers/s1/abc123/logs?tail=50&since=2026-01-01T00:00:00Z",
);
expect(logs.body).toEqual({ success: true, logs: "line one\nline two\n" });
expect(server.client.commands.at(-1)).toContain(
"logs abc123 --tail 50 --since 2026-01-01T00:00:00Z 2>&1",
);
server.client.reply(/ stats abc123/, {
stdout:
'{"cpu":"1.5%","memory":"10MiB / 1GiB","memoryPercent":"1%","netIO":"1kB / 2kB","blockIO":"0B / 0B","pids":"3"}',
});
const stats = await server.request("GET", "/containers/s1/abc123/stats");
expect(stats.body).toMatchObject({
cpu: "1.5%",
memoryUsed: "10MiB",
memoryLimit: "1GiB",
netInput: "1kB",
netOutput: "2kB",
});
server.client.reply(/ rm abc123/, {
code: 1,
stderr: "Error: No such container: abc123",
});
const removed = await server.request(
"DELETE",
"/containers/s1/abc123/remove",
);
expect(removed.status).toBe(404);
expect(
(await server.request("POST", "/containers/s1/abc123/explode")).status,
).toBe(404);
expect(
(await server.request("GET", "/containers/s1/bad;id/logs")).status,
).toBe(400);
await server.request("POST", "/ssh/disconnect", {
body: { sessionId: "s1" },
});
const status = await server.request("GET", "/ssh/status?sessionId=s1");
expect(status.body.connected).toBe(false);
});
it("uses Podman when the host says so", async () => {
server = await startServer();
await server.mock.ctx.settings.setHost(7, "containerRuntime", "podman");
await connect(server);
await server.request("GET", "/containers/s1");
expect(server.client.commands.at(-1)).toMatch(/ podman ps -a --format/);
});
it("keeps another user's session to its owner", async () => {
server = await startServer();
await connect(server);
const response = await server.request("GET", "/containers/s1", {
user: "user-2",
});
expect(response.status).toBe(400);
const status = await server.request("GET", "/ssh/status?sessionId=s1", {
user: "user-2",
});
expect(status.body.connected).toBe(false);
});
it("parks a TOTP prompt and re-asks after a wrong code", async () => {
server = await startServer();
promptingConnect(server, async (ask) => {
let code = await ask({ kind: "totp", prompt: "Code:", retry: false });
while (code !== "123456") {
if (code === null) throw new Error("Cancelled");
code = await ask({ kind: "totp", prompt: "Code:", retry: true });
}
});
const first = await connect(server);
expect(first.body).toMatchObject({ requires_totp: true, prompt: "Code:" });
const pending = await server.request("GET", "/containers/s1");
expect(pending.body.code).toBe("AUTH_PENDING");
const wrong = await server.request("POST", "/ssh/connect-totp", {
body: { sessionId: "s1", totpCode: "000000" },
});
expect(wrong.body).toMatchObject({ requires_totp: true, retry: true });
const stranger = await server.request("POST", "/ssh/connect-totp", {
user: "user-2",
body: { sessionId: "s1", totpCode: "123456" },
});
expect(stranger.status).toBe(404);
const right = await server.request("POST", "/ssh/connect-totp", {
body: { sessionId: "s1", totpCode: "123456" },
});
expect(right.body).toMatchObject({ success: true, status: "success" });
expect((await server.request("GET", "/containers/s1")).status).toBe(200);
});
it("continues a Warpgate sign-in and refuses a code for it", async () => {
server = await startServer();
promptingConnect(server, async (ask) => {
await ask({
kind: "warpgate",
url: "https://warpgate.example/login",
securityKey: "KEY",
instructions: "",
});
});
const first = await connect(server);
expect(first.body).toMatchObject({
requires_warpgate: true,
url: "https://warpgate.example/login",
securityKey: "KEY",
});
const wrongKind = await server.request("POST", "/ssh/connect-totp", {
body: { sessionId: "s1", totpCode: "1" },
});
expect(wrongKind.status).toBe(400);
const done = await server.request("POST", "/ssh/connect-warpgate", {
body: { sessionId: "s1" },
});
expect(done.body.success).toBe(true);
});
it("asks for credentials when a host with no secret gets a password prompt", async () => {
server = await startServer({
mock: {
sshHosts: [
{
id: 7,
userId: "user-1",
ip: "10.0.0.7",
port: 22,
username: "root",
authType: "none",
},
],
},
});
promptingConnect(server, async (ask) => {
const answer = await ask({
kind: "input",
prompt: "Password:",
echo: false,
isPush: false,
});
if (answer === null)
throw new Error("All configured authentication methods failed");
});
const response = await connect(server);
expect(response.body).toEqual({
status: "auth_required",
reason: "no_keyboard",
});
});
it("reports a failed login to core", async () => {
const client = new FakeClient();
server = await startServer({ client });
server.mock.ctx.ssh.connect = (async () => {
throw new Error("All configured authentication methods failed");
}) as never;
const response = await connect(server);
expect(response.status).toBe(500);
expect(server.mock.statusReports).toContainEqual({
hostId: 7,
ok: false,
hostKeyChanged: false,
});
});
});
+116
View File
@@ -0,0 +1,116 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import express, { type Router } from "express";
import {
createMockCtx,
type MockContextOptions,
type MockPluginContext,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
import { activate } from "../../src/backend/index.js";
import { FakeClient } from "./fake-ssh";
export const manifest = manifestJson as unknown as PluginManifest;
export const PS_LINE =
'{"id":"abc123","name":"web","image":"nginx","status":"Up 2 hours","state":"running","ports":"80/tcp","created":"2026-01-01"}';
/** A host owned by user-1, as ctx.ssh.resolveHost sees it. */
export function sshHost(id: number, extra: Record<string, unknown> = {}) {
return {
id,
userId: "user-1",
name: `host-${id}`,
ip: `10.0.0.${id}`,
port: 22,
username: "root",
authType: "password",
password: "pw",
...extra,
};
}
/** A client that looks like a Linux host with Docker running. */
export function dockerClient(): FakeClient {
return new FakeClient([
[/^ver$/, { stdout: "" }],
[/--version/, { stdout: "Docker version 27.1.1, build abc" }],
[/ ps -a --format/, { stdout: `${PS_LINE}\n` }],
[/ ps$/, { stdout: "" }],
]);
}
export interface TestServer {
mock: MockPluginContext;
client: FakeClient;
request: (
method: string,
path: string,
options?: { user?: string; body?: unknown },
// Route bodies vary per test; asserting on them is the point.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<{ status: number; body: any }>;
close: () => Promise<void>;
}
/** The plugin activated with its router served the way core mounts it. */
export async function startServer(
options: {
permissions?: string[];
dockerOn?: boolean;
client?: FakeClient;
mock?: Partial<MockContextOptions>;
} = {},
): Promise<TestServer> {
const client = options.client ?? dockerClient();
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
router: () => (router = express.Router()),
permissions: options.permissions ?? ["docker.use"],
sshHosts: [sshHost(7), sshHost(8)],
sshClient: client,
...options.mock,
});
if (options.dockerOn !== false) {
await mock.ctx.settings.setHost(7, "enableDocker", true);
}
await activate(mock.ctx);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") ?? undefined);
next();
});
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return {
mock,
client,
async request(method, path, { user = "user-1", body } = {}) {
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: {
"x-test-user": user,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
},
async close() {
for (const dispose of mock.disposals.reverse()) await dispose();
await new Promise<void>((resolve) => server.close(() => resolve()));
},
};
}
@@ -0,0 +1,105 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type {
DockerEvent,
DockerEventsV1,
DockerServiceV1,
} from "../../src/backend/index.js";
import { startServer, type TestServer } from "./server";
let server: TestServer | null = null;
afterEach(async () => {
vi.useRealTimers();
await server?.close();
server = null;
});
const psState = (state: string, status = "Up") =>
`{"name":"web","state":"${state}","status":"${status}"}\n`;
describe("docker.containers service", () => {
it("lists containers and runs actions as the caller", async () => {
server = await startServer();
const docker = server.mock.services.get(
"docker.containers",
) as DockerServiceV1;
const containers = await server.mock.ctx.asUser("user-1", () =>
docker.listContainers(7),
);
expect(containers).toEqual([
expect.objectContaining({ id: "abc123", name: "web" }),
]);
await server.mock.ctx.asUser("user-1", () =>
docker.action(7, "web", "restart"),
);
expect(server.client.commands.at(-1)).toMatch(/ docker restart web$/);
expect(server.mock.sshConnections.at(-1)).toMatchObject({
host: 7,
pool: "docker",
});
await expect(
server.mock.ctx.asUser("user-1", () =>
docker.action(7, "web; rm -rf /", "stop"),
),
).rejects.toThrow(/Invalid container name/);
});
it("needs a calling user", async () => {
server = await startServer();
const docker = server.mock.services.get(
"docker.containers",
) as DockerServiceV1;
await expect(docker.listContainers(7)).rejects.toThrow(/calling user/);
});
});
describe("docker.events service", () => {
it("reports state changes between polls to every subscriber", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
server = await startServer();
const events = server.mock.services.get("docker.events") as DockerEventsV1;
const seen: DockerEvent[] = [];
const listener = (event: DockerEvent) => seen.push(event);
const unsubscribe = await server.mock.ctx.asUser("user-1", () =>
events.subscribe(7, listener),
);
// Subscribing the same listener again is a no-op.
await server.mock.ctx.asUser("user-1", () => events.subscribe(7, listener));
const poll = async (output: string) => {
server!.client.reply(/ ps -a --format/, { stdout: output });
vi.setSystemTime(Date.now() + 61_000);
await server!.mock.runScheduled();
};
await poll(psState("running"));
expect(seen).toEqual([]);
await poll(psState("exited", "Exited (1)"));
expect(seen).toEqual([{ hostId: 7, container: "web", event: "exited" }]);
await poll(psState("running", "Up 1 second (unhealthy)"));
expect(seen.slice(1)).toEqual([
{ hostId: 7, container: "web", event: "started" },
{ hostId: 7, container: "web", event: "unhealthy" },
]);
unsubscribe();
const before = server.client.commands.length;
await poll(psState("exited"));
expect(server.client.commands.length).toBe(before);
expect(seen).toHaveLength(3);
});
it("stops polling once the plugin is disposed", async () => {
server = await startServer();
const events = server.mock.services.get("docker.events") as DockerEventsV1;
await server.mock.ctx.asUser("user-1", () => events.subscribe(7, () => {}));
for (const dispose of [...server.mock.disposals].reverse()) await dispose();
expect(server.mock.scheduled.every((job) => job.stopped)).toBe(true);
});
});