A dependency-free node20 action that triggers application.deploy or compose.deploy, then follows the deployment to a terminal status so a failed build fails the CI run. Snapshots the deployment list before triggering, so a concurrent deployment is never mistaken for this one. Optionally points the application at a freshly built image first, which is what lets a workflow lint, test, push and deploy the same artifact.
139 lines
4.9 KiB
JavaScript
139 lines
4.9 KiB
JavaScript
"use strict";
|
|
|
|
const test = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
|
|
const { DokployClient, DokployError, normaliseHost, parseError } = require("../src/client");
|
|
|
|
function response(status, body) {
|
|
return {
|
|
ok: status < 400,
|
|
status,
|
|
text: async () => (typeof body === "string" ? body : JSON.stringify(body)),
|
|
};
|
|
}
|
|
|
|
function clientWith(fetchImpl, options = {}) {
|
|
return new DokployClient({
|
|
host: "https://dokploy.example.com",
|
|
token: "secret",
|
|
fetch: fetchImpl,
|
|
sleep: async () => {},
|
|
...options,
|
|
});
|
|
}
|
|
|
|
test("normaliseHost tolerates trailing slashes and a trailing /api", () => {
|
|
const expected = "https://dokploy.example.com/api";
|
|
assert.equal(normaliseHost("https://dokploy.example.com"), expected);
|
|
assert.equal(normaliseHost("https://dokploy.example.com/"), expected);
|
|
assert.equal(normaliseHost("https://dokploy.example.com/api"), expected);
|
|
assert.equal(normaliseHost(" https://dokploy.example.com/api/ "), expected);
|
|
});
|
|
|
|
test("normaliseHost rejects empty and non-HTTP hosts", () => {
|
|
assert.throws(() => normaliseHost(""), /must not be empty/);
|
|
assert.throws(() => normaliseHost("dokploy.example.com"), /http:\/\/ or https:\/\//);
|
|
assert.throws(() => normaliseHost("ftp://dokploy.example.com"), /http:\/\/ or https:\/\//);
|
|
});
|
|
|
|
test("query flattens input into query parameters and drops nulls", async () => {
|
|
let seen;
|
|
const api = clientWith(async (url, init) => {
|
|
seen = { url, init };
|
|
return response(200, { ok: true });
|
|
});
|
|
|
|
await api.query("deployment.all", { applicationId: "app-1", tail: 100, since: null });
|
|
|
|
assert.equal(seen.url, "https://dokploy.example.com/api/deployment.all?applicationId=app-1&tail=100");
|
|
assert.equal(seen.init.method, "GET");
|
|
assert.equal(seen.init.headers["x-api-key"], "secret");
|
|
assert.equal(seen.init.body, undefined);
|
|
});
|
|
|
|
test("mutate posts a JSON body", async () => {
|
|
let seen;
|
|
const api = clientWith(async (url, init) => {
|
|
seen = { url, init };
|
|
return response(200, true);
|
|
});
|
|
|
|
const result = await api.mutate("application.deploy", { applicationId: "app-1", title: "CI abc1234" });
|
|
|
|
assert.equal(seen.url, "https://dokploy.example.com/api/application.deploy");
|
|
assert.equal(seen.init.method, "POST");
|
|
assert.equal(seen.init.headers["content-type"], "application/json");
|
|
assert.deepEqual(JSON.parse(seen.init.body), { applicationId: "app-1", title: "CI abc1234" });
|
|
assert.equal(result, true);
|
|
});
|
|
|
|
test("an error response becomes a DokployError carrying the Zod field errors", async () => {
|
|
const body = {
|
|
message: "Invalid input",
|
|
code: "BAD_REQUEST",
|
|
data: { code: "BAD_REQUEST", path: "application.deploy", zodError: { fieldErrors: { applicationId: ["Required"] } } },
|
|
};
|
|
const api = clientWith(async () => response(400, body));
|
|
|
|
await assert.rejects(
|
|
() => api.mutate("application.deploy", {}),
|
|
(err) => {
|
|
assert.ok(err instanceof DokployError);
|
|
assert.equal(err.status, 400);
|
|
assert.match(err.message, /HTTP 400/);
|
|
assert.match(err.message, /BAD_REQUEST/);
|
|
assert.match(err.message, /applicationId: Required/);
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
test("a 500 whose message says 'not found' still counts as not found", () => {
|
|
const err = parseError(500, "application.one", JSON.stringify({ message: "Application not found" }));
|
|
assert.equal(err.isNotFound, true);
|
|
});
|
|
|
|
test("queries retry a 5xx, mutations do not", async () => {
|
|
let queryAttempts = 0;
|
|
const flaky = clientWith(async () => {
|
|
queryAttempts += 1;
|
|
return queryAttempts < 3 ? response(502, "bad gateway") : response(200, { fine: true });
|
|
});
|
|
assert.deepEqual(await flaky.query("project.all"), { fine: true });
|
|
assert.equal(queryAttempts, 3);
|
|
|
|
let mutateAttempts = 0;
|
|
const failing = clientWith(async () => {
|
|
mutateAttempts += 1;
|
|
return response(502, "bad gateway");
|
|
});
|
|
await assert.rejects(() => failing.mutate("application.deploy", {}));
|
|
assert.equal(mutateAttempts, 1, "a retried deploy would queue a second build");
|
|
});
|
|
|
|
test("queries give up after the configured number of retries", async () => {
|
|
let attempts = 0;
|
|
const api = clientWith(
|
|
async () => {
|
|
attempts += 1;
|
|
return response(500, "boom");
|
|
},
|
|
{ retries: 1 },
|
|
);
|
|
await assert.rejects(() => api.query("project.all"));
|
|
assert.equal(attempts, 2);
|
|
});
|
|
|
|
test("an HTML response explains that the host is probably wrong", async () => {
|
|
const api = clientWith(async () => response(200, "<!doctype html><html><body>nginx</body></html>"));
|
|
await assert.rejects(() => api.query("project.all"), /is `host` pointing at a Dokploy instance\?/);
|
|
});
|
|
|
|
test("a network failure is reported against the procedure that caused it", async () => {
|
|
const api = clientWith(async () => {
|
|
throw new Error("ECONNREFUSED");
|
|
});
|
|
await assert.rejects(() => api.mutate("application.deploy", {}), /Calling application\.deploy: ECONNREFUSED/);
|
|
});
|