mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
feat: integration the github action for deployment
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user