import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { HostConfig } from "../types.js";
import type { ComposeDiscovery } from "./compose-discovery.js";
import { HostResolver } from "./host-resolver.js";
describe("HostResolver", () => {
const originalResolutionTimeout = process.env.COMPOSE_HOST_RESOLUTION_TIMEOUT_MS;
const originalConflictGrace = process.env.COMPOSE_CONFLICT_GRACE_MS;
let mockDiscovery: ComposeDiscovery;
let resolver: HostResolver;
const hosts: HostConfig[] = [
{ name: "host1", host: "192.168.1.10", protocol: "ssh" },
{ name: "host2", host: "192.168.1.20", protocol: "ssh" },
];
beforeEach(() => {
delete process.env.COMPOSE_HOST_RESOLUTION_TIMEOUT_MS;
delete process.env.COMPOSE_CONFLICT_GRACE_MS;
mockDiscovery = {
resolveProjectPath: vi.fn(),
} as unknown as ComposeDiscovery;
resolver = new HostResolver(mockDiscovery, hosts);
});
afterEach(() => {
if (originalResolutionTimeout === undefined) {
delete process.env.COMPOSE_HOST_RESOLUTION_TIMEOUT_MS;
} else {
process.env.COMPOSE_HOST_RESOLUTION_TIMEOUT_MS = originalResolutionTimeout;
}
if (originalConflictGrace === undefined) {
delete process.env.COMPOSE_CONFLICT_GRACE_MS;
} else {
process.env.COMPOSE_CONFLICT_GRACE_MS = originalConflictGrace;
}
});
describe("resolveHost", () => {
it("should return specified host if provided", async () => {
const result = await resolver.resolveHost("myproject", "host1");
expect(result).toEqual({ name: "host1", host: "192.168.1.10", protocol: "ssh" });
});
it("should throw error if specified host not found", async () => {
await expect(resolver.resolveHost("myproject", "nonexistent")).rejects.toThrow(
'Host "nonexistent" not found in configuration'
);
});
it("should auto-resolve to single matching host", async () => {
vi.mocked(mockDiscovery.resolveProjectPath).mockImplementation(
async (host: HostConfig, projectName: string) => {
if (host.name === "host2" && projectName === "myproject") {
return "/opt/myproject/compose.yaml";
}
throw new Error("Project not found");
}
);
const result = await resolver.resolveHost("myproject");
expect(result).toEqual({ name: "host2", host: "192.168.1.20", protocol: "ssh" });
});
it("should throw error if project found on multiple hosts", async () => {
vi.mocked(mockDiscovery.resolveProjectPath).mockResolvedValue("/opt/myproject/compose.yaml");
await expect(resolver.resolveHost("myproject")).rejects.toThrow(
'Project "myproject" found on multiple hosts: host1, host2. Please specify which host to use.'
);
});
it("should throw error if project not found on any host", async () => {
vi.mocked(mockDiscovery.resolveProjectPath).mockRejectedValue(new Error("Project not found"));
await expect(resolver.resolveHost("myproject")).rejects.toThrow(
'Project "myproject" not found on any configured host'
);
});
it("should throw error if no hosts configured", async () => {
const emptyResolver = new HostResolver(mockDiscovery, []);
await expect(emptyResolver.resolveHost("myproject")).rejects.toThrow("No hosts configured");
});
it("should respect timeout during auto-resolution", async () => {
vi.mocked(mockDiscovery.resolveProjectPath).mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => resolve("/opt/myproject/compose.yaml"), 35000);
})
);
await expect(resolver.resolveHost("myproject")).rejects.toThrow(
"Host resolution timed out after 30000ms"
);
}, 35000);
it("should respect COMPOSE_HOST_RESOLUTION_TIMEOUT_MS override", async () => {
process.env.COMPOSE_HOST_RESOLUTION_TIMEOUT_MS = "20";
vi.mocked(mockDiscovery.resolveProjectPath).mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => resolve("/opt/myproject/compose.yaml"), 1000);
})
);
await expect(resolver.resolveHost("myproject")).rejects.toThrow(
"Host resolution timed out after 20ms"
);
});
});
describe("early-exit behavior", () => {
const threeHosts: HostConfig[] = [
{ name: "host1", host: "192.168.1.10", protocol: "ssh" },
{ name: "host2", host: "192.168.1.20", protocol: "ssh" },
{ name: "host3", host: "192.168.1.30", protocol: "ssh" },
];
it("should resolve without waiting for slow hosts after grace period", async () => {
// Use short grace to keep test fast
process.env.COMPOSE_CONFLICT_GRACE_MS = "50";
// host1 matches instantly, host2 fails instantly, host3 is very slow
vi.mocked(mockDiscovery.resolveProjectPath).mockImplementation(async (host: HostConfig) => {
if (host.name === "host1") return "/opt/myproject/compose.yaml";
if (host.name === "host2") throw new Error("Not found");
// host3 takes 10 seconds — simulates offline host
return new Promise((resolve) => setTimeout(() => resolve("/slow"), 10000));
});
const threeHostResolver = new HostResolver(mockDiscovery, threeHosts);
const start = Date.now();
const result = await threeHostResolver.resolveHost("myproject");
const elapsed = Date.now() - start;
expect(result.name).toBe("host1");
// Should resolve in ~50ms (grace period), not 10s (slow host)
expect(elapsed).toBeLessThan(2000);
});
it("should detect conflicts between fast-responding hosts within grace period", async () => {
process.env.COMPOSE_CONFLICT_GRACE_MS = "200";
// host1 and host2 both match within the grace period, host3 is slow
vi.mocked(mockDiscovery.resolveProjectPath).mockImplementation((host: HostConfig) => {
if (host.name === "host1") return Promise.resolve("/opt/project/compose.yaml");
if (host.name === "host2") {
return new Promise((resolve) =>
setTimeout(() => resolve("/opt/project/compose.yaml"), 50)
);
}
// host3 is very slow
return new Promise((resolve) => setTimeout(() => resolve("/slow"), 10000));
});
const threeHostResolver = new HostResolver(mockDiscovery, threeHosts);
const start = Date.now();
await expect(threeHostResolver.resolveHost("myproject")).rejects.toThrow(
/found on multiple hosts/
);
const elapsed = Date.now() - start;
// Should detect conflict within grace period, not wait for host3
expect(elapsed).toBeLessThan(2000);
});
it("should not record late-arriving results after abort", async () => {
process.env.COMPOSE_HOST_RESOLUTION_TIMEOUT_MS = "50";
vi.mocked(mockDiscovery.resolveProjectPath).mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => resolve("/late"), 200);
})
);
const threeHostResolver = new HostResolver(mockDiscovery, threeHosts);
await expect(threeHostResolver.resolveHost("myproject")).rejects.toThrow(
"Host resolution timed out after 50ms"
);
});
it("should wait for all hosts when no match is found", async () => {
// All hosts fail at different times — must wait for all
vi.mocked(mockDiscovery.resolveProjectPath).mockImplementation((host: HostConfig) => {
const delays: Record<string, number> = { host1: 10, host2: 20, host3: 30 };
const delay = delays[host.name] ?? 10;
return new Promise((_, reject) => setTimeout(() => reject(new Error("Not found")), delay));
});
const threeHostResolver = new HostResolver(mockDiscovery, threeHosts);
await expect(threeHostResolver.resolveHost("myproject")).rejects.toThrow(
/not found on any configured host/
);
});
it("should resolve immediately when all checks settle before grace expires", async () => {
process.env.COMPOSE_CONFLICT_GRACE_MS = "5000"; // Long grace
// host1 matches, host2 fails — both settle instantly
vi.mocked(mockDiscovery.resolveProjectPath).mockImplementation(async (host: HostConfig) => {
if (host.name === "host1") return "/opt/myproject/compose.yaml";
throw new Error("Not found");
});
const start = Date.now();
const result = await resolver.resolveHost("myproject");
const elapsed = Date.now() - start;
expect(result.name).toBe("host1");
// Should NOT wait for the 5s grace since all hosts settled
expect(elapsed).toBeLessThan(500);
});
});
});