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:
2026-08-09 03:37:35 +03:00
commit c23b44983b
20 changed files with 2778 additions and 0 deletions

138
test/client.test.js Normal file
View 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
View 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
View 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
View 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
View 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);
});