Files
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

382 lines
13 KiB
TypeScript

/**
* Tests for the pure deploy-gate logic — classifyDeployStatus and runDeployGate with injected status/probe functions. No SDK or runtime.
*/
import { describe, expect, it } from "bun:test";
import type { AppDeployStatusResponse, AppResponse } from "@elizaos/cloud-sdk";
import {
classifyDeployStatus,
type DeployGateConfig,
type DeployGateDeps,
runDeployGate,
} from "../src/deploy-gate.ts";
import type { ReachabilityResult } from "../src/reachability.ts";
import { makeApp } from "./helpers";
const FAST_CONFIG: DeployGateConfig = {
maxAttempts: 5,
initialDelayMs: 1,
maxDelayMs: 2,
probeTimeoutMs: 10,
requestTimeoutMs: 50,
healthPath: "/health",
};
function statusRes(
overrides: Partial<AppDeployStatusResponse>,
): AppDeployStatusResponse {
return {
success: true,
deploymentId: "dep_1",
status: "BUILDING",
vercelUrl: null,
error: null,
startedAt: null,
...overrides,
};
}
function appRes(productionUrl: string | null): AppResponse {
return {
success: true,
app: makeApp({
production_url: productionUrl,
deployment_status: "deployed",
}),
};
}
/** Build deps that walk a fixed status sequence, then probe a fixed result. */
function makeDeps(args: {
statuses: AppDeployStatusResponse[];
productionUrl?: string | null;
probe?: ReachabilityResult;
onProbe?: (url: string) => void;
}): DeployGateDeps & { statusCalls: number; appCalls: number } {
let statusCalls = 0;
let appCalls = 0;
const deps: DeployGateDeps & { statusCalls: number; appCalls: number } = {
statusCalls: 0,
appCalls: 0,
getStatus: () => {
const idx = Math.min(statusCalls, args.statuses.length - 1);
statusCalls += 1;
deps.statusCalls = statusCalls;
return Promise.resolve(args.statuses[idx]);
},
getApp: () => {
appCalls += 1;
deps.appCalls = appCalls;
return Promise.resolve(appRes(args.productionUrl ?? null));
},
probe: (url) => {
args.onProbe?.(url);
return Promise.resolve(args.probe ?? { ok: true, status: 200 });
},
sleep: () => Promise.resolve(),
};
return deps;
}
describe("classifyDeployStatus", () => {
it("maps the public lifecycle to success/error/pending", () => {
expect(classifyDeployStatus("READY")).toBe("success");
expect(classifyDeployStatus("deployed")).toBe("success");
expect(classifyDeployStatus("ERROR")).toBe("error");
expect(classifyDeployStatus("failed")).toBe("error");
expect(classifyDeployStatus("BUILDING")).toBe("pending");
expect(classifyDeployStatus("DEPLOYING")).toBe("pending");
expect(classifyDeployStatus("DRAFT")).toBe("pending");
expect(classifyDeployStatus(null)).toBe("pending");
});
});
describe("runDeployGate", () => {
it("succeeds: BUILDING → BUILDING → READY + reachable /health → live url", async () => {
let probedUrl = "";
const deps = makeDeps({
statuses: [
statusRes({ status: "BUILDING" }),
statusRes({ status: "BUILDING" }),
statusRes({ status: "READY" }),
],
productionUrl: "https://acme.elizacloud.ai",
probe: { ok: true, status: 200 },
onProbe: (u) => {
probedUrl = u;
},
});
const result = await runDeployGate(deps, FAST_CONFIG);
expect(result.phase).toBe("ready");
expect(result.url).toBe("https://acme.elizacloud.ai");
expect(result.attempts).toBe(3);
expect(probedUrl).toBe("https://acme.elizacloud.ai/health");
expect(result.reachability?.ok).toBe(true);
});
it("reads the authoritative production_url, not the status vercelUrl", async () => {
const deps = makeDeps({
// status reports a stale/placeholder url; the app row is authoritative
statuses: [
statusRes({ status: "READY", vercelUrl: "https://stale.example" }),
],
productionUrl: "https://fresh.elizacloud.ai",
probe: { ok: true, status: 200 },
});
const result = await runDeployGate(deps, FAST_CONFIG);
expect(result.url).toBe("https://fresh.elizacloud.ai");
});
it("times out when status never reaches READY", async () => {
const deps = makeDeps({
statuses: [statusRes({ status: "BUILDING" })],
probe: { ok: true, status: 200 },
});
const result = await runDeployGate(deps, {
...FAST_CONFIG,
maxAttempts: 3,
});
expect(result.phase).toBe("timeout");
expect(result.attempts).toBe(3);
expect(deps.statusCalls).toBe(3);
});
it("returns ERROR immediately on a failed deploy", async () => {
const deps = makeDeps({
statuses: [statusRes({ status: "ERROR", error: "build failed: exit 1" })],
});
const result = await runDeployGate(deps, FAST_CONFIG);
expect(result.phase).toBe("error");
expect(result.error).toBe("build failed: exit 1");
expect(deps.statusCalls).toBe(1);
});
it("reports unreachable when READY but /health returns a Caddy gateway error (503)", async () => {
const deps = makeDeps({
statuses: [statusRes({ status: "READY" })],
productionUrl: "https://acme.elizacloud.ai",
probe: { ok: false, status: 503 },
});
const result = await runDeployGate(deps, FAST_CONFIG);
expect(result.phase).toBe("unreachable");
expect(result.url).toBe("https://acme.elizacloud.ai");
expect(result.reachability?.status).toBe(503);
});
it("REGRESSION: an auth-gated app (READY + /health 401) is LIVE, not 'not live'", async () => {
// The server marks such an app READY (401 is not a gateway error), so the
// gate must agree instead of contradicting GET_APP_DEPLOY_STATUS.
const deps = makeDeps({
statuses: [statusRes({ status: "READY" })],
productionUrl: "https://acme.elizacloud.ai",
probe: { ok: false, status: 401 },
});
const result = await runDeployGate(deps, FAST_CONFIG);
expect(result.phase).toBe("ready");
expect(result.url).toBe("https://acme.elizacloud.ai");
});
it("REGRESSION: an app with no /health route (READY + 404) is LIVE", async () => {
const deps = makeDeps({
statuses: [statusRes({ status: "READY" })],
productionUrl: "https://acme.elizacloud.ai",
probe: { ok: false, status: 404 },
});
const result = await runDeployGate(deps, FAST_CONFIG);
expect(result.phase).toBe("ready");
});
it("reports unreachable when the probe never gets a response (network error, no status)", async () => {
const deps = makeDeps({
statuses: [statusRes({ status: "READY" })],
productionUrl: "https://acme.elizacloud.ai",
probe: { ok: false, error: "ECONNREFUSED" },
});
const result = await runDeployGate(deps, FAST_CONFIG);
expect(result.phase).toBe("unreachable");
});
it("reports unreachable when READY but no production_url exists", async () => {
const deps = makeDeps({
statuses: [statusRes({ status: "READY", vercelUrl: null })],
productionUrl: null,
probe: { ok: true, status: 200 },
});
const result = await runDeployGate(deps, FAST_CONFIG);
expect(result.phase).toBe("unreachable");
expect(result.error).toBe("no_production_url");
});
});
describe("runDeployGate — poll robustness (transient errors + per-request timeout)", () => {
it("REGRESSION: one throwing status poll does NOT abort the gate — it keeps polling and still resolves READY", async () => {
// Poll 1 throws (network blip), poll 2 is BUILDING, poll 3 is READY.
let calls = 0;
const pollErrors: Array<{ error: unknown; attempt: number }> = [];
const responses = [
statusRes({ status: "BUILDING" }),
statusRes({ status: "READY" }),
];
const deps: DeployGateDeps = {
getStatus: () => {
calls += 1;
if (calls === 1) {
return Promise.reject(new Error("ECONNRESET: transient blip"));
}
return Promise.resolve(
responses[Math.min(calls - 2, responses.length - 1)],
);
},
getApp: () => Promise.resolve(appRes("https://acme.elizacloud.ai")),
probe: () => Promise.resolve({ ok: true, status: 200 }),
sleep: () => Promise.resolve(),
onPollError: (error, attempt) => pollErrors.push({ error, attempt }),
};
const result = await runDeployGate(deps, FAST_CONFIG);
expect(result.phase).toBe("ready");
expect(result.url).toBe("https://acme.elizacloud.ai");
expect(result.attempts).toBe(3);
expect(pollErrors).toHaveLength(1);
expect(pollErrors[0]?.attempt).toBe(1);
expect((pollErrors[0]?.error as Error).message).toContain("ECONNRESET");
});
it("a stalled status poll is cut off by the per-request timeout (signal aborts) and the gate continues", async () => {
// Poll 1 stalls forever unless the abort signal fires; poll 2 is READY.
let calls = 0;
let sawAbort = false;
const pollErrors: unknown[] = [];
const deps: DeployGateDeps = {
getStatus: (signal) => {
calls += 1;
if (calls === 1) {
// Simulate a signal-honoring fetch: resolve never, reject on abort.
return new Promise((_, reject) => {
signal.addEventListener(
"abort",
() => {
sawAbort = true;
reject(signal.reason);
},
{ once: true },
);
});
}
return Promise.resolve(statusRes({ status: "READY" }));
},
getApp: () => Promise.resolve(appRes("https://acme.elizacloud.ai")),
probe: () => Promise.resolve({ ok: true, status: 200 }),
sleep: () => Promise.resolve(),
onPollError: (error) => pollErrors.push(error),
};
const result = await runDeployGate(deps, {
...FAST_CONFIG,
requestTimeoutMs: 5,
});
expect(result.phase).toBe("ready");
expect(result.attempts).toBe(2);
expect(sawAbort).toBe(true);
expect(pollErrors).toHaveLength(1);
expect((pollErrors[0] as Error).message).toContain("timed out after 5ms");
});
it("even a signal-IGNORING stalled poll cannot hang the gate (raced against the deadline)", async () => {
let calls = 0;
const deps: DeployGateDeps = {
getStatus: () => {
calls += 1;
if (calls === 1) {
return new Promise(() => {}); // never settles, ignores the signal
}
return Promise.resolve(statusRes({ status: "READY" }));
},
getApp: () => Promise.resolve(appRes("https://acme.elizacloud.ai")),
probe: () => Promise.resolve({ ok: true, status: 200 }),
sleep: () => Promise.resolve(),
};
const result = await runDeployGate(deps, {
...FAST_CONFIG,
requestTimeoutMs: 5,
});
expect(result.phase).toBe("ready");
expect(result.attempts).toBe(2);
});
it("every poll failing still returns the HONEST timeout result (never claims done)", async () => {
const pollAttempts: number[] = [];
const deps: DeployGateDeps = {
getStatus: () => Promise.reject(new Error("boom")),
getApp: () => Promise.resolve(appRes("https://acme.elizacloud.ai")),
probe: () => Promise.resolve({ ok: true, status: 200 }),
sleep: () => Promise.resolve(),
onPollError: (_error, attempt) => pollAttempts.push(attempt),
};
const result = await runDeployGate(deps, {
...FAST_CONFIG,
maxAttempts: 3,
});
expect(result.phase).toBe("timeout");
expect(result.attempts).toBe(3);
expect(pollAttempts).toEqual([1, 2, 3]);
});
it("fast-fails permanent Cloud API poll errors instead of timing out as still building", async () => {
const pollError = Object.assign(new Error("Forbidden"), {
name: "CloudApiError",
statusCode: 403,
errorBody: { success: false, error: "Forbidden" },
});
const pollAttempts: number[] = [];
const deps: DeployGateDeps = {
getStatus: () => Promise.reject(pollError),
getApp: () => Promise.resolve(appRes("https://acme.elizacloud.ai")),
probe: () => Promise.resolve({ ok: true, status: 200 }),
sleep: () => Promise.resolve(),
onPollError: (_error, attempt) => pollAttempts.push(attempt),
};
const result = await runDeployGate(deps, {
...FAST_CONFIG,
maxAttempts: 3,
});
expect(result).toEqual({
phase: "error",
url: null,
status: "",
attempts: 1,
error: "Forbidden",
});
expect(pollAttempts).toEqual([]);
});
it("a stalled app re-read after READY falls back to the status vercelUrl instead of hanging", async () => {
const deps: DeployGateDeps = {
getStatus: () =>
Promise.resolve(
statusRes({ status: "READY", vercelUrl: "https://fallback.example" }),
),
getApp: () => new Promise(() => {}), // stalls forever
probe: () => Promise.resolve({ ok: true, status: 200 }),
sleep: () => Promise.resolve(),
};
const result = await runDeployGate(deps, {
...FAST_CONFIG,
requestTimeoutMs: 5,
});
expect(result.phase).toBe("ready");
expect(result.url).toBe("https://fallback.example");
});
});