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.
This commit is contained in:
138
test/client.test.js
Normal file
138
test/client.test.js
Normal file
@@ -0,0 +1,138 @@
|
||||
"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/);
|
||||
});
|
||||
80
test/core.test.js
Normal file
80
test/core.test.js
Normal file
@@ -0,0 +1,80 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const core = require("../src/core");
|
||||
|
||||
function withEnv(vars, body) {
|
||||
const saved = { ...process.env };
|
||||
Object.assign(process.env, vars);
|
||||
try {
|
||||
return body();
|
||||
} finally {
|
||||
process.env = saved;
|
||||
}
|
||||
}
|
||||
|
||||
test("inputs come from INPUT_* with spaces folded to underscores", () => {
|
||||
withEnv({ INPUT_DOCKER_IMAGE: " ghcr.io/acme/api:1 " }, () => {
|
||||
assert.equal(core.getInput("docker-image"), "");
|
||||
assert.equal(core.getInput("docker image"), "ghcr.io/acme/api:1");
|
||||
});
|
||||
});
|
||||
|
||||
test("an unset input falls back to its environment variable, then its default", () => {
|
||||
withEnv({ INPUT_HOST: "", DOKPLOY_HOST: "https://dokploy.example.com" }, () => {
|
||||
assert.equal(core.getInput("host", { fallbackEnv: "DOKPLOY_HOST" }), "https://dokploy.example.com");
|
||||
});
|
||||
withEnv({ INPUT_HOST: "https://explicit.example.com", DOKPLOY_HOST: "https://env.example.com" }, () => {
|
||||
assert.equal(core.getInput("host", { fallbackEnv: "DOKPLOY_HOST" }), "https://explicit.example.com");
|
||||
});
|
||||
withEnv({}, () => {
|
||||
assert.equal(core.getInput("title", { fallback: "CI deploy" }), "CI deploy");
|
||||
assert.throws(() => core.getInput("api-key", { required: true }), /`api-key` is required/);
|
||||
});
|
||||
});
|
||||
|
||||
test("boolean inputs accept the usual spellings and reject the rest", () => {
|
||||
withEnv({ INPUT_WAIT: "TRUE" }, () => assert.equal(core.getBooleanInput("wait", false), true));
|
||||
withEnv({ INPUT_WAIT: "no" }, () => assert.equal(core.getBooleanInput("wait", true), false));
|
||||
withEnv({ INPUT_WAIT: "" }, () => assert.equal(core.getBooleanInput("wait", true), true));
|
||||
withEnv({ INPUT_WAIT: "maybe" }, () => assert.throws(() => core.getBooleanInput("wait"), /must be a boolean/));
|
||||
});
|
||||
|
||||
test("number inputs reject anything that is not a non-negative number", () => {
|
||||
withEnv({ INPUT_TIMEOUT: "900" }, () => assert.equal(core.getNumberInput("timeout", 600), 900));
|
||||
withEnv({ INPUT_TIMEOUT: "" }, () => assert.equal(core.getNumberInput("timeout", 600), 600));
|
||||
withEnv({ INPUT_TIMEOUT: "-1" }, () => assert.throws(() => core.getNumberInput("timeout", 600), /non-negative/));
|
||||
withEnv({ INPUT_TIMEOUT: "soon" }, () => assert.throws(() => core.getNumberInput("timeout", 600), /non-negative/));
|
||||
});
|
||||
|
||||
test("choice inputs name the alternatives when they are wrong", () => {
|
||||
withEnv({ INPUT_ACTION: "REDEPLOY" }, () =>
|
||||
assert.equal(core.getChoiceInput("action", ["deploy", "redeploy"], "deploy"), "redeploy"),
|
||||
);
|
||||
withEnv({ INPUT_ACTION: "restart" }, () =>
|
||||
assert.throws(() => core.getChoiceInput("action", ["deploy", "redeploy"], "deploy"), /must be one of deploy, redeploy/),
|
||||
);
|
||||
});
|
||||
|
||||
test("multi-line outputs survive the heredoc encoding", () => {
|
||||
const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "dokploy-action-")), "output");
|
||||
fs.writeFileSync(file, "");
|
||||
|
||||
withEnv({ GITHUB_OUTPUT: file }, () => {
|
||||
core.setOutput("status", "done");
|
||||
core.setOutput("logs", "line one\nline two");
|
||||
});
|
||||
|
||||
const written = fs.readFileSync(file, "utf8");
|
||||
assert.match(written, /^status<<ghadelimiter_[0-9a-f-]+\ndone\nghadelimiter_[0-9a-f-]+\n/);
|
||||
assert.match(written, /logs<<ghadelimiter_[0-9a-f-]+\nline one\nline two\nghadelimiter_/);
|
||||
});
|
||||
|
||||
test("workflow command payloads are escaped", () => {
|
||||
assert.equal(core.escapeData("100% done\nnext"), "100%25 done%0Anext");
|
||||
});
|
||||
269
test/deploy.test.js
Normal file
269
test/deploy.test.js
Normal file
@@ -0,0 +1,269 @@
|
||||
"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"/);
|
||||
});
|
||||
78
test/helpers.js
Normal file
78
test/helpers.js
Normal file
@@ -0,0 +1,78 @@
|
||||
"use strict";
|
||||
|
||||
/** A DokployClient stand-in that records calls and replays canned answers. */
|
||||
class FakeApi {
|
||||
/**
|
||||
* @param {object} handlers Procedure name -> value, or (input, call) => value.
|
||||
* A value may be a function returning a promise, or an array consumed one
|
||||
* entry per call so a poller can see a status change.
|
||||
*/
|
||||
constructor(handlers = {}) {
|
||||
this.handlers = handlers;
|
||||
this.calls = [];
|
||||
this.counts = {};
|
||||
}
|
||||
|
||||
#dispatch(kind, procedure, input) {
|
||||
this.calls.push({ kind, procedure, input });
|
||||
const index = (this.counts[procedure] = (this.counts[procedure] ?? 0) + 1) - 1;
|
||||
|
||||
if (!(procedure in this.handlers)) {
|
||||
throw new Error(`FakeApi: unexpected ${kind} ${procedure}`);
|
||||
}
|
||||
const handler = this.handlers[procedure];
|
||||
if (typeof handler === "function") return Promise.resolve(handler(input, index));
|
||||
if (Array.isArray(handler) && handler.__sequence) {
|
||||
return Promise.resolve(handler[Math.min(index, handler.length - 1)]);
|
||||
}
|
||||
return Promise.resolve(handler);
|
||||
}
|
||||
|
||||
query(procedure, input) {
|
||||
return this.#dispatch("query", procedure, input);
|
||||
}
|
||||
|
||||
mutate(procedure, input) {
|
||||
return this.#dispatch("mutate", procedure, input);
|
||||
}
|
||||
|
||||
procedures() {
|
||||
return this.calls.map((call) => call.procedure);
|
||||
}
|
||||
|
||||
find(procedure) {
|
||||
return this.calls.find((call) => call.procedure === procedure);
|
||||
}
|
||||
}
|
||||
|
||||
/** Marks an array as "one entry per call" rather than a literal return value. */
|
||||
function sequence(values) {
|
||||
const copy = [...values];
|
||||
copy.__sequence = true;
|
||||
return copy;
|
||||
}
|
||||
|
||||
/** A clock that only moves when something sleeps, so tests never really wait. */
|
||||
function fakeClock(start = 0) {
|
||||
let current = start;
|
||||
return {
|
||||
now: () => current,
|
||||
sleep: async (ms) => {
|
||||
current += Math.max(ms, 1);
|
||||
},
|
||||
advance: (ms) => {
|
||||
current += ms;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function collectingLog() {
|
||||
const lines = [];
|
||||
return {
|
||||
lines,
|
||||
info: (message) => lines.push(`info: ${message}`),
|
||||
warning: (message) => lines.push(`warning: ${message}`),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { FakeApi, sequence, fakeClock, collectingLog };
|
||||
166
test/resolve.test.js
Normal file
166
test/resolve.test.js
Normal file
@@ -0,0 +1,166 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { FakeApi } = require("./helpers");
|
||||
const { resolveTarget } = require("../src/resolve");
|
||||
|
||||
const PROJECTS = [
|
||||
{
|
||||
projectId: "p1",
|
||||
name: "shop",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "e1",
|
||||
name: "production",
|
||||
isDefault: true,
|
||||
applications: [{ applicationId: "a1", name: "api", appName: "shop-api-prod" }],
|
||||
compose: [],
|
||||
},
|
||||
{
|
||||
environmentId: "e2",
|
||||
name: "staging",
|
||||
isDefault: false,
|
||||
applications: [{ applicationId: "a2", name: "api", appName: "shop-api-stage" }],
|
||||
compose: [{ composeId: "c1", name: "worker", appName: "shop-worker" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ projectId: "p2", name: "blog", environments: [] },
|
||||
];
|
||||
|
||||
function apiWithProjects(projects = PROJECTS) {
|
||||
return new FakeApi({ "project.all": projects });
|
||||
}
|
||||
|
||||
test("an application id is confirmed through application.one", async () => {
|
||||
const api = new FakeApi({
|
||||
"application.one": { name: "api", appName: "shop-api-prod", environmentId: "e1" },
|
||||
});
|
||||
|
||||
const target = await resolveTarget(api, { applicationId: "a1" });
|
||||
|
||||
assert.deepEqual(target, { kind: "application", id: "a1", name: "api", appName: "shop-api-prod", environmentId: "e1" });
|
||||
assert.deepEqual(api.find("application.one").input, { applicationId: "a1" });
|
||||
});
|
||||
|
||||
test("a compose id is confirmed through compose.one", async () => {
|
||||
const api = new FakeApi({ "compose.one": { name: "worker", appName: "shop-worker", environmentId: "e2" } });
|
||||
|
||||
const target = await resolveTarget(api, { composeId: "c1" });
|
||||
|
||||
assert.equal(target.kind, "compose");
|
||||
assert.equal(target.id, "c1");
|
||||
});
|
||||
|
||||
test("a name resolves against the project's default environment", async () => {
|
||||
const api = apiWithProjects();
|
||||
|
||||
const target = await resolveTarget(api, { project: "shop", service: "api" });
|
||||
|
||||
assert.equal(target.id, "a1");
|
||||
assert.equal(target.environmentId, "e1");
|
||||
assert.equal(target.environmentName, "production");
|
||||
assert.equal(target.appName, "shop-api-prod");
|
||||
});
|
||||
|
||||
test("an explicit environment picks the other copy of the same name", async () => {
|
||||
const api = apiWithProjects();
|
||||
|
||||
const target = await resolveTarget(api, { project: "shop", service: "api", environment: "staging" });
|
||||
|
||||
assert.equal(target.id, "a2");
|
||||
assert.equal(target.environmentName, "staging");
|
||||
});
|
||||
|
||||
test("a Compose stack resolves by name", async () => {
|
||||
const api = apiWithProjects();
|
||||
|
||||
const target = await resolveTarget(api, { project: "shop", service: "worker", environment: "staging" });
|
||||
|
||||
assert.equal(target.kind, "compose");
|
||||
assert.equal(target.id, "c1");
|
||||
});
|
||||
|
||||
test("the Docker service name works as well as the display name", async () => {
|
||||
const api = apiWithProjects();
|
||||
|
||||
const target = await resolveTarget(api, { project: "shop", service: "shop-api-prod" });
|
||||
|
||||
assert.equal(target.id, "a1");
|
||||
});
|
||||
|
||||
test("names match case-insensitively", async () => {
|
||||
const api = apiWithProjects();
|
||||
|
||||
const target = await resolveTarget(api, { project: "SHOP", service: "API" });
|
||||
|
||||
assert.equal(target.id, "a1");
|
||||
});
|
||||
|
||||
test("an unknown project lists the ones that exist", async () => {
|
||||
const api = apiWithProjects();
|
||||
|
||||
await assert.rejects(
|
||||
() => resolveTarget(api, { project: "store", service: "api" }),
|
||||
/No project named "store"\. Available: blog, shop\./,
|
||||
);
|
||||
});
|
||||
|
||||
test("an unknown environment lists the ones that exist", async () => {
|
||||
const api = apiWithProjects();
|
||||
|
||||
await assert.rejects(
|
||||
() => resolveTarget(api, { project: "shop", service: "api", environment: "qa" }),
|
||||
/no environment named "qa"\. Available: production, staging\./,
|
||||
);
|
||||
});
|
||||
|
||||
test("an unknown service lists the ones in that environment", async () => {
|
||||
const api = apiWithProjects();
|
||||
|
||||
await assert.rejects(
|
||||
() => resolveTarget(api, { project: "shop", service: "cron", environment: "staging" }),
|
||||
/No application or Compose stack named "cron" in shop\/staging\. Available: api, worker\./,
|
||||
);
|
||||
});
|
||||
|
||||
test("a name shared by an application and a Compose stack asks for an id", async () => {
|
||||
const api = apiWithProjects([
|
||||
{
|
||||
projectId: "p1",
|
||||
name: "shop",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "e1",
|
||||
name: "production",
|
||||
isDefault: true,
|
||||
applications: [{ applicationId: "a1", name: "api", appName: "shop-api" }],
|
||||
compose: [{ composeId: "c1", name: "api", appName: "shop-api-compose" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await assert.rejects(
|
||||
() => resolveTarget(api, { project: "shop", service: "api" }),
|
||||
/ambiguous in shop\/production \(application and compose\)\. Use `application-id` or `compose-id`/,
|
||||
);
|
||||
});
|
||||
|
||||
test("a project with no environments is reported as such", async () => {
|
||||
const api = apiWithProjects();
|
||||
|
||||
await assert.rejects(() => resolveTarget(api, { project: "blog", service: "api" }), /has no environments/);
|
||||
});
|
||||
|
||||
test("conflicting or missing target inputs are rejected before any request", async () => {
|
||||
const api = new FakeApi({});
|
||||
|
||||
await assert.rejects(() => resolveTarget(api, { applicationId: "a1", composeId: "c1" }), /not both/);
|
||||
await assert.rejects(() => resolveTarget(api, { applicationId: "a1", project: "shop", service: "api" }), /not both/);
|
||||
await assert.rejects(() => resolveTarget(api, {}), /No target/);
|
||||
await assert.rejects(() => resolveTarget(api, { project: "shop" }), /No target/);
|
||||
assert.equal(api.calls.length, 0);
|
||||
});
|
||||
Reference in New Issue
Block a user