mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
222 lines
7.3 KiB
JavaScript
222 lines
7.3 KiB
JavaScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
DokployApiError,
|
|
buildDeploymentsUrl,
|
|
createDeploymentSnapshot,
|
|
escapeGitHubCommandValue,
|
|
fetchComposeDeployments,
|
|
findNewDeployments,
|
|
normalizeDokployUrl,
|
|
waitForDeployment,
|
|
} from "../../.github/scripts/dokploy-deployment-tracker.mjs";
|
|
|
|
const DOKPLOY_URL = "https://dokploy.example.com";
|
|
const COMPOSE_ID = "compose/id with spaces";
|
|
const API_TOKEN = "super-secret-token";
|
|
|
|
function response(payload, { ok = true, status = 200 } = {}) {
|
|
return {
|
|
ok,
|
|
status,
|
|
json: vi.fn().mockResolvedValue(payload),
|
|
};
|
|
}
|
|
|
|
function deployment(deploymentId, status = "running", overrides = {}) {
|
|
return {
|
|
deploymentId,
|
|
status,
|
|
title: `Deployment ${deploymentId}`,
|
|
createdAt: "2026-07-21T10:00:00.000Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createClock() {
|
|
let time = 0;
|
|
return {
|
|
now: () => time,
|
|
sleep: vi.fn(async (ms) => {
|
|
time += ms;
|
|
}),
|
|
};
|
|
}
|
|
|
|
describe("Dokploy deployment tracker", () => {
|
|
it("normalizes the base URL and safely encodes the compose ID", () => {
|
|
expect(normalizeDokployUrl(" https://dokploy.example.com/// ")).toBe(DOKPLOY_URL);
|
|
|
|
const url = new URL(buildDeploymentsUrl(`${DOKPLOY_URL}/`, COMPOSE_ID));
|
|
expect(url.pathname).toBe("/api/deployment.allByCompose");
|
|
expect(url.searchParams.get("composeId")).toBe(COMPOSE_ID);
|
|
});
|
|
|
|
it("escapes untrusted text before writing a GitHub workflow command", () => {
|
|
expect(escapeGitHubCommandValue("build 50%\r\n::warning::unsafe"))
|
|
.toBe("build 50%25%0D%0A::warning::unsafe");
|
|
});
|
|
|
|
it("creates a snapshot and finds only unseen deployments", () => {
|
|
const existing = deployment("existing", "done");
|
|
const snapshot = createDeploymentSnapshot([existing, {}, null], "captured-at");
|
|
|
|
expect(snapshot).toEqual({
|
|
capturedAt: "captured-at",
|
|
deploymentIds: ["existing"],
|
|
});
|
|
expect(findNewDeployments([existing, deployment("new")], snapshot))
|
|
.toEqual([deployment("new")]);
|
|
});
|
|
|
|
it("sends the API key in a header without exposing it in errors", async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValue(response([], { ok: false, status: 403 }));
|
|
|
|
await expect(fetchComposeDeployments({
|
|
dokployUrl: DOKPLOY_URL,
|
|
composeId: COMPOSE_ID,
|
|
apiToken: API_TOKEN,
|
|
fetchImpl,
|
|
maxRetries: 0,
|
|
})).rejects.toMatchObject({
|
|
message: "Dokploy deployment API returned HTTP 403",
|
|
status: 403,
|
|
});
|
|
|
|
const [, request] = fetchImpl.mock.calls[0];
|
|
expect(request.headers["x-api-key"]).toBe(API_TOKEN);
|
|
expect(fetchImpl.mock.calls[0][0]).not.toContain(API_TOKEN);
|
|
});
|
|
|
|
it("retries transient HTTP errors and network failures", async () => {
|
|
const sleep = vi.fn().mockResolvedValue(undefined);
|
|
const fetchImpl = vi.fn()
|
|
.mockResolvedValueOnce(response([], { ok: false, status: 503 }))
|
|
.mockRejectedValueOnce(new TypeError("network failed"))
|
|
.mockResolvedValueOnce(response([deployment("new")]));
|
|
|
|
await expect(fetchComposeDeployments({
|
|
dokployUrl: DOKPLOY_URL,
|
|
composeId: COMPOSE_ID,
|
|
apiToken: API_TOKEN,
|
|
fetchImpl,
|
|
sleep,
|
|
retryDelayMs: 1,
|
|
maxRetries: 2,
|
|
})).resolves.toEqual([deployment("new")]);
|
|
|
|
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
|
expect(sleep).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("does not retry authentication and configuration failures", async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValue(response([], { ok: false, status: 401 }));
|
|
const sleep = vi.fn();
|
|
|
|
await expect(fetchComposeDeployments({
|
|
dokployUrl: DOKPLOY_URL,
|
|
composeId: COMPOSE_ID,
|
|
apiToken: API_TOKEN,
|
|
fetchImpl,
|
|
sleep,
|
|
})).rejects.toBeInstanceOf(DokployApiError);
|
|
|
|
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
expect(sleep).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("tracks a new deployment from running to done", async () => {
|
|
const existing = deployment("existing", "done");
|
|
const running = deployment("new", "running");
|
|
const done = deployment("new", "done", { finishedAt: "finished-at" });
|
|
const fetchDeployments = vi.fn()
|
|
.mockResolvedValueOnce([existing])
|
|
.mockResolvedValueOnce([running, existing])
|
|
.mockResolvedValueOnce([done, existing]);
|
|
const clock = createClock();
|
|
const logger = { info: vi.fn() };
|
|
|
|
await expect(waitForDeployment({
|
|
snapshot: createDeploymentSnapshot([existing]),
|
|
fetchDeployments,
|
|
sleep: clock.sleep,
|
|
now: clock.now,
|
|
pollIntervalMs: 10,
|
|
discoveryTimeoutMs: 100,
|
|
deploymentTimeoutMs: 200,
|
|
logger,
|
|
})).resolves.toEqual(done);
|
|
|
|
expect(logger.info).toHaveBeenCalledWith("Tracking Dokploy deployment new");
|
|
expect(logger.info).toHaveBeenCalledWith("Dokploy deployment new status: running");
|
|
expect(logger.info).toHaveBeenCalledWith("Dokploy deployment new status: done");
|
|
});
|
|
|
|
it("accepts a new deployment that is already done", async () => {
|
|
const done = deployment("new", "done");
|
|
|
|
await expect(waitForDeployment({
|
|
snapshot: createDeploymentSnapshot([]),
|
|
fetchDeployments: vi.fn().mockResolvedValue([done]),
|
|
pollIntervalMs: 1,
|
|
discoveryTimeoutMs: 10,
|
|
deploymentTimeoutMs: 20,
|
|
logger: { info: vi.fn() },
|
|
})).resolves.toEqual(done);
|
|
});
|
|
|
|
it.each(["error", "cancelled"])("returns terminal %s deployment details", async (status) => {
|
|
const failed = deployment("new", status, { errorMessage: "Build failed" });
|
|
|
|
await expect(waitForDeployment({
|
|
snapshot: createDeploymentSnapshot([]),
|
|
fetchDeployments: vi.fn().mockResolvedValue([failed]),
|
|
pollIntervalMs: 1,
|
|
discoveryTimeoutMs: 10,
|
|
deploymentTimeoutMs: 20,
|
|
logger: { info: vi.fn() },
|
|
})).resolves.toEqual(failed);
|
|
});
|
|
|
|
it("fails rather than guessing when multiple new deployments appear", async () => {
|
|
await expect(waitForDeployment({
|
|
snapshot: createDeploymentSnapshot([]),
|
|
fetchDeployments: vi.fn().mockResolvedValue([
|
|
deployment("new-a"),
|
|
deployment("new-b"),
|
|
]),
|
|
logger: { info: vi.fn() },
|
|
})).rejects.toThrow("correlation is ambiguous: new-a, new-b");
|
|
});
|
|
|
|
it("times out while waiting for a new deployment record", async () => {
|
|
const clock = createClock();
|
|
|
|
await expect(waitForDeployment({
|
|
snapshot: createDeploymentSnapshot([]),
|
|
fetchDeployments: vi.fn().mockResolvedValue([]),
|
|
sleep: clock.sleep,
|
|
now: clock.now,
|
|
pollIntervalMs: 10,
|
|
discoveryTimeoutMs: 20,
|
|
deploymentTimeoutMs: 100,
|
|
logger: { info: vi.fn() },
|
|
})).rejects.toThrow("Timed out waiting for Dokploy to create a deployment record");
|
|
});
|
|
|
|
it("times out when a deployment stays running", async () => {
|
|
const clock = createClock();
|
|
|
|
await expect(waitForDeployment({
|
|
snapshot: createDeploymentSnapshot([]),
|
|
fetchDeployments: vi.fn().mockResolvedValue([deployment("new")]),
|
|
sleep: clock.sleep,
|
|
now: clock.now,
|
|
pollIntervalMs: 10,
|
|
discoveryTimeoutMs: 20,
|
|
deploymentTimeoutMs: 30,
|
|
logger: { info: vi.fn() },
|
|
})).rejects.toThrow("Timed out waiting for Dokploy deployment new to finish");
|
|
});
|
|
});
|