"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"/); });