Files
dokploy-deploy-action/test/deploy.test.js
Max Vojtkov c23b44983b Deploy Dokploy applications from GitHub and Gitea Actions
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.
2026-08-09 03:37:35 +03:00

270 lines
9.4 KiB
JavaScript

"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { FakeApi, sequence, fakeClock, collectingLog } = require("./helpers");
const { run } = require("../src/deploy");
const APP = { kind: "application", id: "a1", name: "api", appName: "shop-api", environmentId: "e1" };
const STACK = { kind: "compose", id: "c1", name: "worker", appName: "shop-worker", environmentId: "e1" };
const HISTORY = [{ deploymentId: "d1", status: "done", createdAt: "2026-08-01T00:00:00Z" }];
function options(overrides = {}) {
const clock = fakeClock();
return {
clock,
options: {
timeoutMs: 60_000,
pollMs: 5_000,
now: clock.now,
sleep: clock.sleep,
log: collectingLog(),
...overrides,
},
};
}
test("a deployment is watched from trigger to done", async () => {
const running = { deploymentId: "d2", status: "running", createdAt: "2026-08-02T00:00:00Z" };
const api = new FakeApi({
"deployment.all": sequence([
HISTORY, // snapshot taken before the trigger
HISTORY, // the queue has not picked it up yet
[...HISTORY, running], // the build started
[...HISTORY, { ...running, status: "done" }],
]),
"application.deploy": true,
});
const { options: opts } = options();
const result = await run(api, APP, { ...opts, action: "deploy", title: "CI abc1234", description: "shop/main" });
assert.equal(result.status, "done");
assert.equal(result.succeeded, true);
assert.equal(result.deploymentId, "d2");
assert.equal(result.timedOut, false);
assert.equal(result.logs, "", "logs are only printed on failure by default");
assert.deepEqual(api.find("application.deploy").input, {
applicationId: "a1",
title: "CI abc1234",
description: "shop/main",
});
});
test("a deployment that already existed is never mistaken for this run's", async () => {
const api = new FakeApi({
"deployment.all": sequence([
// A deployment newer than the one we trigger is already in flight, and
// must not be adopted as ours.
[{ deploymentId: "d9", status: "running", createdAt: "2026-08-03T00:00:00Z" }],
[
{ deploymentId: "d9", status: "running", createdAt: "2026-08-03T00:00:00Z" },
{ deploymentId: "d10", status: "done", createdAt: "2026-08-03T00:01:00Z" },
],
]),
"application.deploy": true,
});
const { options: opts } = options();
const result = await run(api, APP, { ...opts });
assert.equal(result.deploymentId, "d10");
});
test("a failed deployment fails the step and pulls the logs", async () => {
const failed = {
deploymentId: "d2",
status: "error",
createdAt: "2026-08-02T00:00:00Z",
errorMessage: "exit code 1",
};
const api = new FakeApi({
"deployment.all": sequence([HISTORY, [...HISTORY, failed]]),
"application.deploy": true,
"deployment.readLogs": "npm ERR! build failed",
});
const { options: opts } = options();
const result = await run(api, APP, { ...opts, logTail: 50 });
assert.equal(result.status, "error");
assert.equal(result.succeeded, false);
assert.equal(result.errorMessage, "exit code 1");
assert.equal(result.logs, "npm ERR! build failed");
assert.deepEqual(api.find("deployment.readLogs").input, { deploymentId: "d2", tail: 50 });
});
test("a cancelled deployment is a failure", async () => {
const api = new FakeApi({
"deployment.all": sequence([HISTORY, [...HISTORY, { deploymentId: "d2", status: "cancelled", createdAt: "x" }]]),
"application.deploy": true,
"deployment.readLogs": "",
});
const { options: opts } = options();
const result = await run(api, APP, { ...opts });
assert.equal(result.status, "cancelled");
assert.equal(result.succeeded, false);
assert.match(result.errorMessage, /status cancelled/);
});
test("unreadable logs never mask the deployment result", async () => {
const api = new FakeApi({
"deployment.all": sequence([HISTORY, [...HISTORY, { deploymentId: "d2", status: "error", createdAt: "x" }]]),
"application.deploy": true,
"deployment.readLogs": () => {
throw new Error("ENOENT");
},
});
const { options: opts } = options();
const result = await run(api, APP, { ...opts });
assert.equal(result.status, "error");
assert.match(result.logs, /could not read deployment logs: ENOENT/);
});
test("a deployment that never finishes times out", async () => {
const api = new FakeApi({
"deployment.all": (_input, index) =>
index === 0 ? HISTORY : [...HISTORY, { deploymentId: "d2", status: "running", createdAt: "x" }],
"application.deploy": true,
"deployment.readLogs": "still building",
});
const { options: opts } = options({ timeoutMs: 20_000 });
const result = await run(api, APP, { ...opts });
assert.equal(result.status, "timed-out");
assert.equal(result.succeeded, false);
assert.equal(result.timedOut, true);
assert.equal(result.deploymentId, "d2");
assert.match(result.errorMessage, /did not finish within 20s/);
assert.equal(api.counts["compose.cancelDeployment"], undefined);
assert.equal(api.counts["application.cancelDeployment"], undefined);
});
test("cancel-on-timeout asks Dokploy to stop the build", async () => {
const api = new FakeApi({
"deployment.all": (_input, index) =>
index === 0 ? HISTORY : [...HISTORY, { deploymentId: "d2", status: "running", createdAt: "x" }],
"application.deploy": true,
"application.cancelDeployment": true,
"deployment.readLogs": "",
});
const { options: opts } = options({ timeoutMs: 20_000 });
await run(api, APP, { ...opts, cancelOnTimeout: true });
assert.deepEqual(api.find("application.cancelDeployment").input, { applicationId: "a1" });
});
test("a timeout with no deployment row at all says so", async () => {
const api = new FakeApi({ "deployment.all": HISTORY, "application.deploy": true });
const { options: opts } = options({ timeoutMs: 10_000 });
const result = await run(api, APP, { ...opts });
assert.equal(result.status, "timed-out");
assert.equal(result.deploymentId, "");
assert.match(result.errorMessage, /No deployment appeared within 10s/);
});
test("docker-image points the application at the new tag before deploying", async () => {
const api = new FakeApi({
"application.saveDockerProvider": true,
"deployment.all": sequence([HISTORY, [...HISTORY, { deploymentId: "d2", status: "done", createdAt: "x" }]]),
"application.deploy": true,
});
const { options: opts } = options();
await run(api, APP, {
...opts,
dockerImage: "ghcr.io/acme/api:1.4.0",
registryUsername: "acme",
registryPassword: "token",
});
// Every field of apiSaveDockerProvider is required, so the unset ones have
// to travel as explicit nulls.
assert.deepEqual(api.find("application.saveDockerProvider").input, {
applicationId: "a1",
dockerImage: "ghcr.io/acme/api:1.4.0",
username: "acme",
password: "token",
registryUrl: null,
});
assert.deepEqual(api.procedures().slice(0, 2), ["application.saveDockerProvider", "deployment.all"]);
});
test("docker-image is refused for a Compose stack", async () => {
const api = new FakeApi({});
const { options: opts } = options();
await assert.rejects(
() => run(api, STACK, { ...opts, dockerImage: "ghcr.io/acme/api:1.4.0" }),
/only applies to applications/,
);
});
test("wait: false triggers and returns without polling", async () => {
const api = new FakeApi({ "application.deploy": true });
const { options: opts } = options();
const result = await run(api, APP, { ...opts, wait: false });
assert.equal(result.status, "triggered");
assert.equal(result.succeeded, true);
assert.deepEqual(api.procedures(), ["application.deploy"]);
});
test("start and stop do not create a deployment to watch", async () => {
const api = new FakeApi({ "application.stop": true });
const { options: opts } = options();
const result = await run(api, APP, { ...opts, action: "stop" });
assert.equal(result.status, "done");
assert.deepEqual(api.procedures(), ["application.stop"]);
assert.deepEqual(api.find("application.stop").input, { applicationId: "a1" });
});
test("reload sends the Docker service name alongside the id", async () => {
const api = new FakeApi({ "application.reload": true });
const { options: opts } = options();
await run(api, APP, { ...opts, action: "reload" });
assert.deepEqual(api.find("application.reload").input, { applicationId: "a1", appName: "shop-api" });
});
test("reload is refused for a Compose stack", async () => {
const api = new FakeApi({});
const { options: opts } = options();
await assert.rejects(() => run(api, STACK, { ...opts, action: "reload" }), /use `redeploy` for a Compose stack/);
});
test("a Compose stack uses the compose procedures throughout", async () => {
const api = new FakeApi({
"deployment.allByCompose": sequence([[], [{ deploymentId: "d1", status: "done", createdAt: "x" }]]),
"compose.redeploy": true,
});
const { options: opts } = options();
const result = await run(api, STACK, { ...opts, action: "redeploy" });
assert.equal(result.succeeded, true);
assert.deepEqual(api.find("compose.redeploy").input, { composeId: "c1" });
});
test("an unknown action is rejected", async () => {
const api = new FakeApi({});
const { options: opts } = options();
await assert.rejects(() => run(api, APP, { ...opts, action: "restart" }), /Unknown action "restart"/);
});