import { beforeEach, describe, expect, it, vi } from "vitest";
import type { HostConfig } from "../types.js";
import { ComposeScanner } from "./compose-scanner.js";
import type { ILocalExecutorService, ISSHService } from "./interfaces.js";
describe("ComposeScanner", () => {
let mockSSHService: ISSHService;
let mockLocalExecutor: ILocalExecutorService;
let scanner: ComposeScanner;
const remoteHost: HostConfig = {
name: "remote-host",
host: "192.168.1.10",
protocol: "ssh",
sshUser: "admin",
composeSearchPaths: ["/opt/docker", "/home/admin/compose"],
};
const localHost: HostConfig = {
name: "localhost",
host: "localhost",
protocol: "ssh",
};
beforeEach(() => {
mockSSHService = {
executeSSHCommand: vi.fn(),
} as ISSHService;
mockLocalExecutor = {
executeLocalCommand: vi.fn(),
} as ILocalExecutorService;
scanner = new ComposeScanner(mockSSHService, mockLocalExecutor);
});
describe("findComposeFiles", () => {
it("should find compose files via SSH on remote host", async () => {
const findOutput =
"/opt/docker/jellyfin/docker-compose.yml\n/opt/docker/plex/compose.yaml\n/home/admin/compose/nginx/docker-compose.yaml";
vi.mocked(mockSSHService.executeSSHCommand).mockResolvedValue(findOutput);
const result = await scanner.findComposeFiles(remoteHost);
expect(result).toEqual([
"/opt/docker/jellyfin/docker-compose.yml",
"/opt/docker/plex/compose.yaml",
"/home/admin/compose/nginx/docker-compose.yaml",
]);
expect(mockSSHService.executeSSHCommand).toHaveBeenCalledWith(
remoteHost,
"find",
expect.arrayContaining([
"/opt/docker",
"/home/admin/compose",
"-type",
"f",
"(",
"-name",
"docker-compose.yml",
"-o",
"-name",
"docker-compose.yaml",
"-o",
"-name",
"compose.yml",
"-o",
"-name",
"compose.yaml",
")",
"-print",
]),
expect.any(Object)
);
});
it("should use 60s timeout for find operations", async () => {
vi.mocked(mockSSHService.executeSSHCommand).mockResolvedValue("");
await scanner.findComposeFiles(remoteHost);
expect(mockSSHService.executeSSHCommand).toHaveBeenCalledWith(
remoteHost,
"find",
expect.any(Array),
{ timeoutMs: 60_000 }
);
});
it("should find compose files locally on localhost", async () => {
const findOutput =
"/var/lib/docker/app1/docker-compose.yml\n/var/lib/docker/app2/compose.yaml";
vi.mocked(mockLocalExecutor.executeLocalCommand).mockResolvedValue(findOutput);
const result = await scanner.findComposeFiles(localHost);
expect(result).toEqual([
"/var/lib/docker/app1/docker-compose.yml",
"/var/lib/docker/app2/compose.yaml",
]);
expect(mockLocalExecutor.executeLocalCommand).toHaveBeenCalledWith(
"find",
expect.arrayContaining([
"/compose",
"/mnt/cache/compose",
"/mnt/cache/code",
"-maxdepth",
"3",
"-type",
"f",
]),
expect.any(Object)
);
});
it("should use default search paths if none configured", async () => {
const hostWithoutPaths: HostConfig = {
name: "default-host",
host: "192.168.1.20",
protocol: "ssh",
};
vi.mocked(mockSSHService.executeSSHCommand).mockResolvedValue("");
await scanner.findComposeFiles(hostWithoutPaths);
expect(mockSSHService.executeSSHCommand).toHaveBeenCalledWith(
hostWithoutPaths,
"find",
expect.arrayContaining([
"/compose",
"/mnt/cache/compose",
"/mnt/cache/code",
"-maxdepth",
"3",
]),
expect.any(Object)
);
});
it("should handle empty results", async () => {
vi.mocked(mockSSHService.executeSSHCommand).mockResolvedValue("");
const result = await scanner.findComposeFiles(remoteHost);
expect(result).toEqual([]);
});
});
describe("extractProjectName", () => {
it("should extract project name from file path", () => {
const result = scanner.extractProjectName("/opt/docker/jellyfin/docker-compose.yml");
expect(result).toBe("jellyfin");
});
it("should handle compose.yaml filename", () => {
const result = scanner.extractProjectName("/home/user/myapp/compose.yaml");
expect(result).toBe("myapp");
});
it("should handle root-level compose file", () => {
const result = scanner.extractProjectName("/docker-compose.yml");
expect(result).toBe("");
});
});
describe("parseComposeName", () => {
it("should parse explicit name field from compose file via SSH", async () => {
const composeContent = `
name: my-custom-project
services:
web:
image: nginx
`;
vi.mocked(mockSSHService.executeSSHCommand).mockResolvedValue(composeContent);
const result = await scanner.parseComposeName(
remoteHost,
"/opt/docker/app/docker-compose.yml"
);
expect(result).toBe("my-custom-project");
expect(mockSSHService.executeSSHCommand).toHaveBeenCalledWith(
remoteHost,
"cat",
["/opt/docker/app/docker-compose.yml"],
expect.any(Object)
);
});
it("should parse explicit name field locally", async () => {
const composeContent = `
name: local-project
services:
db:
image: postgres
`;
vi.mocked(mockLocalExecutor.executeLocalCommand).mockResolvedValue(composeContent);
const result = await scanner.parseComposeName(
localHost,
"/var/lib/docker/myapp/compose.yaml"
);
expect(result).toBe("local-project");
});
it("should return null if no name field exists", async () => {
const composeContent = `
services:
web:
image: nginx
`;
vi.mocked(mockSSHService.executeSSHCommand).mockResolvedValue(composeContent);
const result = await scanner.parseComposeName(
remoteHost,
"/opt/docker/app/docker-compose.yml"
);
expect(result).toBeNull();
});
it("should return null on parse errors", async () => {
const invalidYaml = "invalid: yaml: content: [";
vi.mocked(mockSSHService.executeSSHCommand).mockResolvedValue(invalidYaml);
const result = await scanner.parseComposeName(
remoteHost,
"/opt/docker/app/docker-compose.yml"
);
expect(result).toBeNull();
});
});
describe("batchParseComposeNames", () => {
it("should use single cat for one file without batching overhead", async () => {
const files = ["/path/1.yml"];
vi.mocked(mockSSHService.executeSSHCommand).mockResolvedValue("name: project1\nservices:");
const result = await scanner.batchParseComposeNames(remoteHost, files, 10);
expect(result.size).toBe(1);
expect(result.get("/path/1.yml")).toBe("project1");
// Single file should use cat directly, not sh -c
expect(mockSSHService.executeSSHCommand).toHaveBeenCalledWith(
remoteHost,
"cat",
["/path/1.yml"],
expect.any(Object)
);
});
it("should read multiple files using parallel cat commands", async () => {
const files = ["/path/1.yml", "/path/2.yml", "/path/3.yml"];
vi.mocked(mockSSHService.executeSSHCommand).mockImplementation(
async (_host, command, args): Promise<string> => {
if (command !== "cat") return "";
const path = args?.[0];
if (path === "/path/1.yml") return "name: project1\nservices:";
if (path === "/path/2.yml") return "name: project2\nservices:";
if (path === "/path/3.yml") return "name: project3\nservices:";
return "";
}
);
const result = await scanner.batchParseComposeNames(remoteHost, files, 10);
expect(result.size).toBe(3);
expect(result.get("/path/1.yml")).toBe("project1");
expect(result.get("/path/2.yml")).toBe("project2");
expect(result.get("/path/3.yml")).toBe("project3");
expect(mockSSHService.executeSSHCommand).toHaveBeenCalledTimes(3);
expect(mockSSHService.executeSSHCommand).toHaveBeenCalledWith(
remoteHost,
"cat",
["/path/1.yml"],
expect.any(Object)
);
expect(mockSSHService.executeSSHCommand).toHaveBeenCalledWith(
remoteHost,
"cat",
["/path/2.yml"],
expect.any(Object)
);
expect(mockSSHService.executeSSHCommand).toHaveBeenCalledWith(
remoteHost,
"cat",
["/path/3.yml"],
expect.any(Object)
);
});
it("should handle files without name field in batch", async () => {
const files = ["/path/named.yml", "/path/unnamed.yml"];
vi.mocked(mockSSHService.executeSSHCommand).mockImplementation(
async (_host, command, args): Promise<string> => {
if (command !== "cat") return "";
const path = args?.[0];
if (path === "/path/named.yml")
return "name: my-project\nservices:\n web:\n image: nginx";
return "services:\n web:\n image: nginx";
}
);
const result = await scanner.batchParseComposeNames(remoteHost, files, 10);
expect(result.get("/path/named.yml")).toBe("my-project");
expect(result.get("/path/unnamed.yml")).toBeNull();
});
it("should fall back to individual reads on batch failure", async () => {
const files = ["/path/1.yml", "/path/2.yml"];
vi.mocked(mockSSHService.executeSSHCommand)
// First call (one of the parallel cat calls) fails
.mockRejectedValueOnce(new Error("Connection reset"))
// Remaining parallel/fallback calls return valid content
.mockResolvedValueOnce("name: project1\nservices:")
.mockResolvedValueOnce("name: project1\nservices:")
.mockResolvedValueOnce("name: project2\nservices:");
const result = await scanner.batchParseComposeNames(remoteHost, files, 10);
expect(result.size).toBe(2);
expect(result.get("/path/1.yml")).toBe("project1");
expect(result.get("/path/2.yml")).toBe("project2");
});
it("should respect concurrency limit across chunks", async () => {
// Create enough files to span multiple chunks (>20 files = multiple chunks)
const files = Array.from({ length: 50 }, (_, i) => `/path/${i}.yml`);
let concurrent = 0;
let maxConcurrent = 0;
vi.mocked(mockSSHService.executeSSHCommand).mockImplementation(async () => {
concurrent++;
maxConcurrent = Math.max(maxConcurrent, concurrent);
await new Promise((resolve) => setTimeout(resolve, 10));
concurrent--;
// Return valid batched output
return "name: test\nservices:";
});
await scanner.batchParseComposeNames(remoteHost, files, 2);
// Chunk concurrency (2) should be respected, with up to 20 cat calls per chunk.
expect(maxConcurrent).toBeLessThanOrEqual(40);
});
it("should return empty map for empty file list", async () => {
const result = await scanner.batchParseComposeNames(remoteHost, [], 10);
expect(result.size).toBe(0);
expect(mockSSHService.executeSSHCommand).not.toHaveBeenCalled();
});
it("should handle YAML parse errors for individual files in batch", async () => {
const files = ["/path/good.yml", "/path/bad.yml", "/path/also-good.yml"];
vi.mocked(mockSSHService.executeSSHCommand).mockImplementation(
async (_host, command, args): Promise<string> => {
if (command !== "cat") return "";
const path = args?.[0];
if (path === "/path/good.yml") return "name: project1\nservices:";
if (path === "/path/bad.yml") return "invalid: yaml: content: [";
return "name: project3\nservices:";
}
);
const result = await scanner.batchParseComposeNames(remoteHost, files, 10);
expect(result.size).toBe(3);
expect(result.get("/path/good.yml")).toBe("project1");
expect(result.get("/path/bad.yml")).toBeNull();
expect(result.get("/path/also-good.yml")).toBe("project3");
});
it("should work with local host", async () => {
const files = ["/path/1.yml", "/path/2.yml"];
vi.mocked(mockLocalExecutor.executeLocalCommand).mockImplementation(
async (command, args): Promise<string> => {
if (command !== "cat") return "";
const path = args?.[0];
if (path === "/path/1.yml") return "name: local1\nservices:";
return "name: local2\nservices:";
}
);
const result = await scanner.batchParseComposeNames(localHost, files, 10);
expect(result.size).toBe(2);
expect(result.get("/path/1.yml")).toBe("local1");
expect(result.get("/path/2.yml")).toBe("local2");
expect(mockLocalExecutor.executeLocalCommand).toHaveBeenCalledWith(
"cat",
["/path/1.yml"],
expect.any(Object)
);
expect(mockLocalExecutor.executeLocalCommand).toHaveBeenCalledWith(
"cat",
["/path/2.yml"],
expect.any(Object)
);
});
});
});