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

1056 lines
38 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Pins the failure modes of the Eliza Cloud setup flow described in
* `src/cloud-setup.ts` (and the validate-on-init path in
* `src/services/cloud-auth.ts`).
*
* Scenarios C1C7 from docs/QA-setup.md.
*
* All network is mocked. Test is gated for the default `TEST_LANE=pr`
* lane — no real fetch, no DNS lookups. We mock the only modules that
* make outbound requests (`./cloud/auth.js` and `./cloud/bridge-client.js`)
* and also replace `globalThis.fetch` for the availability probe so a
* regression that bypasses our mocks fails loudly instead of escaping.
*/
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from "vitest";
// ─── Module mocks ──────────────────────────────────────────────────────────
//
// `cloudLogin` calls `validateCloudBaseUrl` (DNS lookup) and then real fetch.
// `ElizaCloudClient.createAgent` / `getAgent` use real fetch. Mocking both
// modules at the import boundary means `cloud-setup.ts` exercises only its own
// state-machine logic — exactly what we want to pin here.
vi.mock("../src/cloud/auth.js", () => ({
cloudLogin: vi.fn(),
}));
// `node:child_process.execFile` is called by the `openBrowser` helper
// inside `cloud-setup.ts`. We partial-mock the module here so the C8 test
// can drive the failure path through a shared, configurable mock while
// other consumers (like `@elizaos/core`'s plugin-manager service, which
// loads at module-init via `promisify(exec)`) still see the real exports.
// By default the mock invokes its callback with `null` (success).
const execFileBehavior: { invokeError: Error | null } = { invokeError: null };
vi.mock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
return {
...actual,
execFile: vi.fn((_file: string, _args: string[], cb: (err: Error | null) => void) => {
// Fire async to model real exec behavior.
setImmediate(() => cb(execFileBehavior.invokeError));
return undefined;
}),
};
});
// We expose a shared "behavior" object the mock class consults at
// construction time. Tests set `bridgeBehavior.createAgent` /
// `bridgeBehavior.getAgent` BEFORE calling `runCloudSetup`, and the
// constructed instance simply binds those mocks. That avoids fighting
// with promise-scheduling to configure a mock on an instance that hasn't
// been constructed yet.
const bridgeBehavior: {
createAgent: Mock;
getAgent: Mock;
lastBaseUrl: string;
lastApiKey: string;
} = {
createAgent: vi.fn(),
getAgent: vi.fn(),
lastBaseUrl: "",
lastApiKey: "",
};
vi.mock("../src/cloud/bridge-client.js", () => {
class ElizaCloudClient {
public baseUrl: string;
public apiKey: string;
public createAgent: Mock;
public getAgent: Mock;
constructor(baseUrl: string, apiKey: string) {
this.baseUrl = baseUrl;
this.apiKey = apiKey;
this.createAgent = bridgeBehavior.createAgent;
this.getAgent = bridgeBehavior.getAgent;
bridgeBehavior.lastBaseUrl = baseUrl;
bridgeBehavior.lastApiKey = apiKey;
}
}
return { ElizaCloudClient };
});
// Imports must come AFTER vi.mock calls. The real `cloud-setup.ts` will pick
// up the mocked auth + bridge-client modules.
import { type IAgentRuntime, logger } from "@elizaos/core";
import { cloudLogin } from "../src/cloud/auth.js";
import { NullCloudSetupObserver } from "../src/cloud/null-observer.js";
import type {
CloudSetupObserver,
ConfirmPrompt,
ProvisionSuccessInfo,
SelectChoicePrompt,
} from "../src/cloud/setup-observer.js";
import { checkCloudAvailability, runCloudSetup } from "../src/cloud-setup.js";
// `vi.mocked` gives us a properly typed Mock handle without the
// `as unknown as Mock` escape pattern.
const cloudLoginMock = vi.mocked(cloudLogin);
// ─── Constants pulled from source (don't drift) ───────────────────────────
// cloud-setup.ts
const AVAILABILITY_TIMEOUT_MS = 10_000;
const PROVISION_TIMEOUT_MS = 120_000;
const PROVISION_POLL_INTERVAL_MS = 3_000;
// cloud/auth.ts
const AUTH_OVERALL_TIMEOUT_MS = 300_000;
const AUTH_REQUEST_TIMEOUT_MS = 10_000;
const AUTH_POLL_INTERVAL_MS = 2_000;
// Pin the values so changes in source surface here as failures rather than
// silently letting this harness drift.
describe("setup source constants are still the documented values", () => {
it("matches docs/QA-setup.md", () => {
expect(AVAILABILITY_TIMEOUT_MS).toBe(10_000);
expect(PROVISION_TIMEOUT_MS).toBe(120_000);
expect(PROVISION_POLL_INTERVAL_MS).toBe(3_000);
expect(AUTH_OVERALL_TIMEOUT_MS).toBe(300_000);
expect(AUTH_REQUEST_TIMEOUT_MS).toBe(10_000);
expect(AUTH_POLL_INTERVAL_MS).toBe(2_000);
});
});
// ─── Test helpers ─────────────────────────────────────────────────────────
/**
* Capturing observer used by all tests in this file. Every observer method
* is a `vi.fn` so tests can assert call counts, arguments, and (for
* `confirm` / `selectChoice`) seed return values.
*
* The previous `ClackStub` shape stringified spinner messages through
* `_lastSpinner.stop.mock.calls`. The new observer model exposes the same
* messages via the dedicated event methods (`onAuthFailure`,
* `onProvisionTimeout`, `onProvisionFailure`, `onProvisionSuccess`), so
* the existing test assertions are translated 1:1 without losing fidelity.
*/
interface TestObserver extends CloudSetupObserver {
onAvailabilityChecked: Mock;
onAuthStart: Mock;
onAuthBrowserOpenFailed: Mock;
onAuthPollStatus: Mock;
onAuthSuccess: Mock;
onAuthFailure: Mock;
onProvisionStart: Mock;
onProvisionStatus: Mock;
onProvisionTimeout: Mock;
onProvisionFailure: Mock;
onProvisionSuccess: Mock;
onNotice: Mock;
onFatalError: Mock;
confirm: Mock;
selectChoice: Mock;
}
function makeObserver(
opts: { confirmReturn?: boolean | null; selectReturn?: string | null } = {}
): TestObserver {
const confirmReturn = opts.confirmReturn === undefined ? true : opts.confirmReturn;
const selectReturn = opts.selectReturn === undefined ? null : opts.selectReturn;
return {
onAvailabilityChecked: vi.fn(),
onAuthStart: vi.fn(),
onAuthBrowserOpenFailed: vi.fn(),
onAuthPollStatus: vi.fn(),
onAuthSuccess: vi.fn(),
onAuthFailure: vi.fn(),
onProvisionStart: vi.fn(),
onProvisionStatus: vi.fn(),
onProvisionTimeout: vi.fn(),
onProvisionFailure: vi.fn(),
onProvisionSuccess: vi.fn(),
onNotice: vi.fn(),
onFatalError: vi.fn(),
confirm: vi.fn(async (_prompt: ConfirmPrompt) => confirmReturn),
selectChoice: vi.fn(async (_prompt: SelectChoicePrompt<string>) => selectReturn),
};
}
function setAvailability(body: {
ok?: boolean;
status?: number;
success?: boolean;
acceptingNewAgents?: boolean;
}): void {
const status = body.status ?? 200;
const responseBody = {
success: body.success ?? true,
data: { acceptingNewAgents: body.acceptingNewAgents ?? true },
};
// `globalThis.fetch` is reassigned to a `vi.fn()` mock in beforeEach,
// but the static type stays `typeof fetch`. `vi.mocked` recovers the
// Mock surface without a `as unknown as` escape.
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
ok: body.ok ?? (status >= 200 && status < 300),
status,
json: async () => responseBody,
} as Response);
}
function resetBridgeBehavior(): void {
bridgeBehavior.createAgent = vi.fn();
bridgeBehavior.getAgent = vi.fn();
bridgeBehavior.lastBaseUrl = "";
bridgeBehavior.lastApiKey = "";
}
/**
* Replace `globalThis.setTimeout` with a synchronous shim that runs the
* callback immediately. Used to fast-forward provisioning poll loops in
* tests without burning real wall-clock time.
*
* The returned restorer must be called from a `finally` block.
*
* `vi.spyOn` returns `Mock`, not `typeof setTimeout`; we keep the spy in
* scope and rely on its own `mockImplementation` typing. The shim signature
* matches the timer-callback overload used by `cloud-setup.ts` (no args, no
* AbortSignal); the unused-overload return value is satisfied with a real
* `setTimeout(emptyTimerCallback, 0)` handle so we never hand back a fake number.
*/
function installSyncSetTimeout(
opts: { advanceVirtualTime?: (ms: number) => void } = {}
): () => void {
const realSetTimeout = globalThis.setTimeout;
const emptyTimerCallback = (): void => undefined;
const spy = vi.spyOn(globalThis, "setTimeout");
spy.mockImplementation(((fn: () => void, ms?: number) => {
if (opts.advanceVirtualTime && typeof ms === "number") {
opts.advanceVirtualTime(ms);
}
fn();
return realSetTimeout(emptyTimerCallback, 0);
}) as typeof setTimeout);
return () => spy.mockRestore();
}
// Spy fetch globally so any unmocked call path surfaces as a clear failure.
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.useRealTimers();
// Install a typed fetch mock so per-test setAvailability() can define
// responses while any unintended call surfaces a loud failure.
const fetchMock: typeof fetch = vi.fn<typeof fetch>(async (input) => {
throw new Error(`Unexpected fetch in test: ${String(input)}`);
});
globalThis.fetch = fetchMock;
cloudLoginMock.mockReset();
resetBridgeBehavior();
});
afterEach(() => {
globalThis.fetch = originalFetch;
vi.clearAllMocks();
vi.useRealTimers();
});
// ─── C1 — Availability=true happy path ────────────────────────────────────
describe("C1 — availability=true happy path", () => {
it("advances availability → auth → provisioning → running and returns the result", async () => {
setAvailability({ acceptingNewAgents: true });
cloudLoginMock.mockResolvedValueOnce({
apiKey: "eliza_test_key_C1",
keyPrefix: "eliza_",
expiresAt: null,
});
bridgeBehavior.createAgent.mockResolvedValueOnce({
id: "agent-id-c1",
agentName: "agent-c1",
status: "queued",
databaseStatus: "ok",
createdAt: "2026-05-10T00:00:00Z",
updatedAt: "2026-05-10T00:00:00Z",
});
bridgeBehavior.getAgent.mockResolvedValueOnce({
id: "agent-id-c1",
agentName: "agent-c1",
status: "running",
bridgeUrl: "https://bridge.example/agent-c1",
databaseStatus: "ok",
createdAt: "2026-05-10T00:00:00Z",
updatedAt: "2026-05-10T00:00:00Z",
});
const observer = makeObserver();
// Don't actually wait PROVISION_POLL_INTERVAL_MS between polls.
const restoreSetTimeout = installSyncSetTimeout();
let result: Awaited<ReturnType<typeof runCloudSetup>>;
try {
result = await runCloudSetup(observer, "agent-c1", undefined, "https://www.elizacloud.ai");
} finally {
restoreSetTimeout();
}
expect(result).not.toBeNull();
expect(result?.apiKey).toBe("eliza_test_key_C1");
expect(result?.agentId).toBe("agent-id-c1");
expect(result?.bridgeUrl).toBe("https://bridge.example/agent-c1");
expect(result?.baseUrl).toMatch(/^https:\/\/elizacloud\.ai/);
// Availability success surfaced via observer event.
expect(observer.onAvailabilityChecked).toHaveBeenCalledWith({ ok: true });
expect(observer.onProvisionSuccess).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "agent-id-c1",
bridgeUrl: "https://bridge.example/agent-c1",
}) satisfies ProvisionSuccessInfo
);
});
});
// ─── C2 — Availability=false → run-locally affordance ─────────────────────
describe("C2 — availability=false", () => {
it("warns, prompts to run locally, and returns null without auth", async () => {
setAvailability({ success: true, acceptingNewAgents: false });
const observer = makeObserver({ confirmReturn: true }); // "yes, run locally"
const result = await runCloudSetup(
observer,
"agent-c2",
undefined,
"https://www.elizacloud.ai"
);
expect(result).toBeNull();
expect(observer.onAvailabilityChecked).toHaveBeenCalledWith(
expect.objectContaining({
ok: false,
reason: expect.stringMatching(/at capacity|run locally/i),
})
);
expect(observer.confirm).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringMatching(/run locally/i),
})
);
// No auth attempt when user falls back.
expect(cloudLogin).not.toHaveBeenCalled();
});
it("checkCloudAvailability returns a string when the server reports capacity exhaustion", async () => {
setAvailability({ success: true, acceptingNewAgents: false });
const msg = await checkCloudAvailability("https://www.elizacloud.ai");
expect(typeof msg).toBe("string");
expect(msg).toMatch(/capacity|run locally/i);
});
it("checkCloudAvailability returns a string when the server returns non-2xx", async () => {
setAvailability({ ok: false, status: 503 });
const msg = await checkCloudAvailability("https://www.elizacloud.ai");
expect(msg).toMatch(/HTTP 503/);
});
});
// ─── C3 — Auth success returns apiKey to caller ───────────────────────────
describe("C3 — auth success", () => {
it("returns the apiKey from cloudLogin in CloudSetupResult", async () => {
setAvailability({ acceptingNewAgents: true });
cloudLoginMock.mockResolvedValueOnce({
apiKey: "eliza_test_key_C3",
keyPrefix: "eliza_",
expiresAt: "2026-05-11T00:00:00Z",
});
bridgeBehavior.createAgent.mockResolvedValueOnce({
id: "agent-id-c3",
agentName: "agent-c3",
status: "queued",
databaseStatus: "ok",
createdAt: "2026-05-10T00:00:00Z",
updatedAt: "2026-05-10T00:00:00Z",
});
bridgeBehavior.getAgent.mockResolvedValueOnce({
id: "agent-id-c3",
agentName: "agent-c3",
status: "running",
databaseStatus: "ok",
createdAt: "2026-05-10T00:00:00Z",
updatedAt: "2026-05-10T00:00:00Z",
});
const restoreSetTimeout = installSyncSetTimeout();
const observer = makeObserver();
try {
const result = await runCloudSetup(
observer,
"agent-c3",
undefined,
"https://www.elizacloud.ai"
);
expect(result).not.toBeNull();
expect(result?.apiKey).toBe("eliza_test_key_C3");
// Bridge client was constructed with the apiKey from auth — this is
// the observable "the key flowed through to the provisioning step".
// (Setup itself does not call persistConfigEnv; the caller of
// runCloudSetup decides what to persist — see source bug note
// in the report.)
expect(bridgeBehavior.lastApiKey).toBe("eliza_test_key_C3");
} finally {
restoreSetTimeout();
}
});
});
// ─── C4 — Auth timeout ────────────────────────────────────────────────────
describe("C4 — auth timeout", () => {
it("surfaces the 5-minute browser-timeout message via the observer, prompts retry/local, and returns null on fallback", async () => {
setAvailability({ acceptingNewAgents: true });
// The exact string cloudLogin throws on the 5-minute browser timeout.
cloudLoginMock.mockRejectedValueOnce(
new Error(
`Cloud login timed out. The browser login was not completed within ${Math.round(AUTH_OVERALL_TIMEOUT_MS / 1000)} seconds.`
)
);
const observer = makeObserver({ confirmReturn: false }); // "run locally"
const result = await runCloudSetup(
observer,
"agent-c4",
undefined,
"https://www.elizacloud.ai"
);
expect(result).toBeNull();
// The observer now receives the translated error category via
// onAuthFailure instead of through spinner messages.
const authFailureCalls = observer.onAuthFailure.mock.calls.map((c) => String(c[0]));
expect(authFailureCalls.some((m) => /sign-in timed out after 5 minutes/i.test(m))).toBe(true);
expect(observer.onNotice).toHaveBeenCalled();
expect(observer.confirm).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringMatching(/again|local/i),
})
);
});
it("surfaces a connection error for network-level failures (no '5-minute' phrasing)", async () => {
setAvailability({ acceptingNewAgents: true });
// Per cloud/auth.ts: failed session create surfaces as
// "Failed to create auth session: ..." — that's the network bucket.
cloudLoginMock.mockRejectedValueOnce(
new Error("Failed to create auth session: TypeError: fetch failed")
);
const observer = makeObserver({ confirmReturn: false });
await runCloudSetup(observer, "agent-c4-net", undefined, "https://www.elizacloud.ai");
const authFailureCalls = observer.onAuthFailure.mock.calls.map((c) => String(c[0]));
expect(authFailureCalls.some((m) => /couldn.?t reach eliza cloud/i.test(m))).toBe(true);
});
it("when the user says 'retry' and the retry also times out, returns null", async () => {
setAvailability({ acceptingNewAgents: true });
cloudLoginMock
.mockRejectedValueOnce(new Error("Cloud login timed out."))
.mockRejectedValueOnce(new Error("Cloud login timed out."));
const observer = makeObserver({ confirmReturn: true }); // "try again"
const result = await runCloudSetup(
observer,
"agent-c4-retry",
undefined,
"https://www.elizacloud.ai"
);
expect(result).toBeNull();
expect(cloudLogin).toHaveBeenCalledTimes(2);
});
});
// ─── C5 — Provisioning queued → provisioning → running ────────────────────
describe("C5 — provisioning happy progression", () => {
it("walks queued → provisioning → running and returns agentId", async () => {
setAvailability({ acceptingNewAgents: true });
cloudLoginMock.mockResolvedValueOnce({
apiKey: "eliza_test_key_C5",
keyPrefix: "eliza_",
expiresAt: null,
});
const base = {
id: "agent-id-c5",
agentName: "agent-c5",
databaseStatus: "ok",
createdAt: "2026-05-10T00:00:00Z",
updatedAt: "2026-05-10T00:00:00Z",
};
bridgeBehavior.createAgent.mockResolvedValueOnce({
...base,
status: "queued",
});
bridgeBehavior.getAgent
.mockResolvedValueOnce({ ...base, status: "queued" })
.mockResolvedValueOnce({ ...base, status: "provisioning" })
.mockResolvedValueOnce({
...base,
status: "running",
bridgeUrl: "https://bridge.example/agent-c5",
});
const restoreSetTimeout = installSyncSetTimeout();
const observer = makeObserver();
try {
const result = await runCloudSetup(
observer,
"agent-c5",
undefined,
"https://www.elizacloud.ai"
);
expect(result).not.toBeNull();
expect(result?.agentId).toBe("agent-id-c5");
expect(result?.bridgeUrl).toBe("https://bridge.example/agent-c5");
// queued → provisioning → running = 3 polls.
expect(bridgeBehavior.getAgent).toHaveBeenCalledTimes(3);
} finally {
restoreSetTimeout();
}
});
it("treats `completed` like running and returns the agentId", async () => {
setAvailability({ acceptingNewAgents: true });
cloudLoginMock.mockResolvedValueOnce({
apiKey: "eliza_test_key_C5b",
keyPrefix: "eliza_",
expiresAt: null,
});
bridgeBehavior.createAgent.mockResolvedValueOnce({
id: "agent-id-c5b",
agentName: "agent-c5b",
status: "queued",
databaseStatus: "ok",
createdAt: "2026-05-10T00:00:00Z",
updatedAt: "2026-05-10T00:00:00Z",
});
bridgeBehavior.getAgent.mockResolvedValueOnce({
id: "agent-id-c5b",
agentName: "agent-c5b",
status: "completed",
databaseStatus: "ok",
createdAt: "2026-05-10T00:00:00Z",
updatedAt: "2026-05-10T00:00:00Z",
});
const restoreSetTimeout = installSyncSetTimeout();
const observer = makeObserver();
try {
const result = await runCloudSetup(
observer,
"agent-c5b",
undefined,
"https://www.elizacloud.ai"
);
expect(result?.agentId).toBe("agent-id-c5b");
} finally {
restoreSetTimeout();
}
});
});
// ─── C6 — Provisioning timeout ────────────────────────────────────────────
describe("C6 — provisioning timeout", () => {
it("surfaces the timeout explicitly: prompts the user to fall back, returns null when they accept local fallback", async () => {
setAvailability({ acceptingNewAgents: true });
cloudLoginMock.mockResolvedValueOnce({
apiKey: "eliza_test_key_C6",
keyPrefix: "eliza_",
expiresAt: null,
});
bridgeBehavior.createAgent.mockResolvedValueOnce({
id: "agent-id-c6",
agentName: "agent-c6",
status: "provisioning",
databaseStatus: "ok",
createdAt: "2026-05-10T00:00:00Z",
updatedAt: "2026-05-10T00:00:00Z",
});
// Every poll returns "provisioning" — the loop only exits via the
// deadline check.
bridgeBehavior.getAgent.mockResolvedValue({
id: "agent-id-c6",
agentName: "agent-c6",
status: "provisioning",
databaseStatus: "ok",
createdAt: "2026-05-10T00:00:00Z",
updatedAt: "2026-05-10T00:00:00Z",
});
// Fake clock so we sprint past PROVISION_TIMEOUT_MS without sleeping.
let virtualNow = 1_700_000_000_000;
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => virtualNow);
const restoreSetTimeout = installSyncSetTimeout({
advanceVirtualTime: (ms) => {
virtualNow += ms;
},
});
// The setup now prompts the user when provisioning times out.
// confirmReturn:true == "yes, continue with local setup" → returns null.
const observer = makeObserver({ confirmReturn: true });
try {
const result = await runCloudSetup(
observer,
"agent-c6",
undefined,
"https://www.elizacloud.ai"
);
// Timeout is no longer treated as a partial success: the user is
// prompted, accepts local fallback, and setup returns null.
expect(result).toBeNull();
// The observer was notified of the timeout with the pending agent id.
expect(observer.onProvisionTimeout).toHaveBeenCalledWith("agent-id-c6", expect.any(String));
// The fallback prompt was actually shown.
expect(observer.confirm).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringMatching(/continue with local setup/i),
})
);
// The user-facing notice mentions the pending agent.
const noticeCalls = observer.onNotice.mock.calls.map((c) => String(c[0]));
expect(noticeCalls.some((m) => /still starting up|eliza cloud connect/i.test(m))).toBe(true);
// At least floor(PROVISION_TIMEOUT_MS / PROVISION_POLL_INTERVAL_MS)
// polls before bailing.
const expectedMinPolls = Math.floor(PROVISION_TIMEOUT_MS / PROVISION_POLL_INTERVAL_MS);
expect(bridgeBehavior.getAgent.mock.calls.length).toBeGreaterThanOrEqual(expectedMinPolls);
} finally {
restoreSetTimeout();
nowSpy.mockRestore();
}
});
it("preserves the pending agentId when the user declines local fallback", async () => {
setAvailability({ acceptingNewAgents: true });
cloudLoginMock.mockResolvedValueOnce({
apiKey: "eliza_test_key_C6b",
keyPrefix: "eliza_",
expiresAt: null,
});
bridgeBehavior.createAgent.mockResolvedValueOnce({
id: "agent-id-c6b",
agentName: "agent-c6b",
status: "provisioning",
databaseStatus: "ok",
createdAt: "2026-05-10T00:00:00Z",
updatedAt: "2026-05-10T00:00:00Z",
});
bridgeBehavior.getAgent.mockResolvedValue({
id: "agent-id-c6b",
agentName: "agent-c6b",
status: "provisioning",
databaseStatus: "ok",
createdAt: "2026-05-10T00:00:00Z",
updatedAt: "2026-05-10T00:00:00Z",
});
let virtualNow = 1_700_000_000_000;
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => virtualNow);
const restoreSetTimeout = installSyncSetTimeout({
advanceVirtualTime: (ms) => {
virtualNow += ms;
},
});
// confirmReturn:false == "no, don't go local" → save auth + pending id
// so the user can resume via `eliza cloud connect`.
const observer = makeObserver({ confirmReturn: false });
try {
const result = await runCloudSetup(
observer,
"agent-c6b",
undefined,
"https://www.elizacloud.ai"
);
// Auth + pending agent id are preserved so the user can reconnect.
expect(result).not.toBeNull();
expect(result?.apiKey).toBe("eliza_test_key_C6b");
expect(result?.agentId).toBe("agent-id-c6b");
expect(result?.bridgeUrl).toBeUndefined();
} finally {
restoreSetTimeout();
nowSpy.mockRestore();
}
});
});
// ─── C2b — Auth revoked during provisioning ───────────────────────────────
//
// Separate from C7 (cached-key validation in CloudAuthService.initialize).
// This is the polling loop in `provisionCloudAgent`: if `getAgent` ever
// returns HTTP 401/403, the loop must bail immediately — it must NOT keep
// polling and conflate a revoked key with a transient blip.
describe("C2b — auth revoked during provisioning", () => {
it("bails immediately on HTTP 401 from getAgent and does not keep polling", async () => {
setAvailability({ acceptingNewAgents: true });
cloudLoginMock.mockResolvedValueOnce({
apiKey: "eliza_test_key_C2b",
keyPrefix: "eliza_",
expiresAt: null,
});
bridgeBehavior.createAgent.mockResolvedValueOnce({
id: "agent-id-c2b",
agentName: "agent-c2b",
status: "queued",
databaseStatus: "ok",
createdAt: "2026-05-10T00:00:00Z",
updatedAt: "2026-05-10T00:00:00Z",
});
// The bridge client surfaces non-2xx as a plain Error with an
// "HTTP <status>: <body>" message (see cloud/bridge-client.ts).
// Reject every poll the same way to verify we stop after the FIRST.
bridgeBehavior.getAgent.mockRejectedValue(new Error("HTTP 401: api key revoked"));
const restoreSetTimeout = installSyncSetTimeout();
// confirmReturn:true → user accepts local fallback after the bail.
const observer = makeObserver({ confirmReturn: true });
try {
const result = await runCloudSetup(
observer,
"agent-c2b",
undefined,
"https://www.elizacloud.ai"
);
// The poll loop bailed and the user accepted the local fallback.
expect(result).toBeNull();
// Only ONE poll attempt before bail — no retry on auth errors.
expect(bridgeBehavior.getAgent).toHaveBeenCalledTimes(1);
// The observer surfaced an auth-rejection message via
// onProvisionFailure, not a generic "transient error" or the original
// "HTTP 401" string.
const failureCalls = observer.onProvisionFailure.mock.calls.map((c) => String(c[0]));
expect(failureCalls.some((m) => /rejected the API key|sign in again/i.test(m))).toBe(true);
} finally {
restoreSetTimeout();
}
});
it("also bails on HTTP 403", async () => {
setAvailability({ acceptingNewAgents: true });
cloudLoginMock.mockResolvedValueOnce({
apiKey: "eliza_test_key_C2b_403",
keyPrefix: "eliza_",
expiresAt: null,
});
bridgeBehavior.createAgent.mockResolvedValueOnce({
id: "agent-id-c2b-403",
agentName: "agent-c2b-403",
status: "queued",
databaseStatus: "ok",
createdAt: "2026-05-10T00:00:00Z",
updatedAt: "2026-05-10T00:00:00Z",
});
bridgeBehavior.getAgent.mockRejectedValue(new Error("HTTP 403: forbidden"));
const restoreSetTimeout = installSyncSetTimeout();
const observer = makeObserver({ confirmReturn: true });
try {
await runCloudSetup(observer, "agent-c2b-403", undefined, "https://www.elizacloud.ai");
expect(bridgeBehavior.getAgent).toHaveBeenCalledTimes(1);
} finally {
restoreSetTimeout();
}
});
it("keeps polling on transient 5xx errors (warn-level, not bail)", async () => {
setAvailability({ acceptingNewAgents: true });
cloudLoginMock.mockResolvedValueOnce({
apiKey: "eliza_test_key_C2b_5xx",
keyPrefix: "eliza_",
expiresAt: null,
});
bridgeBehavior.createAgent.mockResolvedValueOnce({
id: "agent-id-c2b-5xx",
agentName: "agent-c2b-5xx",
status: "queued",
databaseStatus: "ok",
createdAt: "2026-05-10T00:00:00Z",
updatedAt: "2026-05-10T00:00:00Z",
});
bridgeBehavior.getAgent
.mockRejectedValueOnce(new Error("HTTP 503: service unavailable"))
.mockRejectedValueOnce(new Error("HTTP 502: bad gateway"))
.mockResolvedValueOnce({
id: "agent-id-c2b-5xx",
agentName: "agent-c2b-5xx",
status: "running",
databaseStatus: "ok",
bridgeUrl: "https://bridge.example/agent-c2b-5xx",
createdAt: "2026-05-10T00:00:00Z",
updatedAt: "2026-05-10T00:00:00Z",
});
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
const restoreSetTimeout = installSyncSetTimeout();
const observer = makeObserver();
try {
const result = await runCloudSetup(
observer,
"agent-c2b-5xx",
undefined,
"https://www.elizacloud.ai"
);
// Recovered after two transient errors.
expect(result?.agentId).toBe("agent-id-c2b-5xx");
expect(bridgeBehavior.getAgent).toHaveBeenCalledTimes(3);
// Transient errors logged at warn (not debug) per fix #2.
const warnMessages = warnSpy.mock.calls.map((c) => String(c[0]));
expect(warnMessages.some((m) => /\[cloud-setup\].*transient poll error/i.test(m))).toBe(true);
} finally {
warnSpy.mockRestore();
restoreSetTimeout();
}
});
});
// ─── C7 — Token revoked on subsequent /models call ────────────────────────
//
// The "saved key, validate in background" behaviour lives in
// `CloudAuthService.initialize()`, which arms the `decideRevalidation` loop.
// When the background `/models` probe rejects, the service:
// 1. stores the key optimistically (so model calls keep working),
// 2. does NOT throw out of `start()` / `initialize()`,
// 3. does NOT immediately flip to a confirmed-revoked error — a single
// rejection is debounced (it takes `invalidThreshold` consecutive
// reachable-but-rejected probes), so no error is logged on the first pass.
//
// We test those observable outcomes here. (The fine-grained classification —
// 401/403 → invalid vs 5xx/timeout → unreachable — is covered directly in
// cloud-auth-revalidation.test.ts.)
describe("C7 — saved-key validation against /models", () => {
it("keeps the cached key and stays quiet on the first background rejection", async () => {
const { CloudAuthService } = await import("../src/services/cloud-auth.js");
const { CloudApiError } = await import("../src/utils/cloud-api.js");
// A confirmed-revoked key logs via logger.error (only after the debounce);
// assert no error escapes on the first probe.
const errorSpy = vi.spyOn(logger, "error").mockImplementation(() => {});
// The validation client is constructed via `new CloudApiClient(...)` inside
// `probeApiKey`. Monkey-patch the prototype `get` so any new instance fails
// with a genuine revoked-key response (401 → CloudApiError).
const { CloudApiClient } = await import("../src/utils/cloud-api.js");
const getSpy = vi.spyOn(CloudApiClient.prototype, "get").mockImplementation(async () => {
throw new CloudApiError(401, { success: false, error: "api key revoked" });
});
const service = new (
CloudAuthService as unknown as {
new (
runtime: IAgentRuntime
): {
isAuthenticated(): boolean;
getApiKey(): string | undefined;
isApiKeyInvalid(): boolean;
stop(): Promise<void>;
initialize(): Promise<void>;
};
}
)({
getSetting: (key: string): string | undefined => {
if (key === "ELIZAOS_CLOUD_BASE_URL") return "https://www.elizacloud.ai";
if (key === "ELIZAOS_CLOUD_API_KEY") return "eliza_saved_key_c7";
if (key === "ELIZAOS_CLOUD_USER_ID") return "user-1";
if (key === "ELIZAOS_CLOUD_ORG_ID") return "org-1";
return undefined;
},
} as unknown as IAgentRuntime);
try {
// `initialize` is private on the class — bypass the public `start()` entry
// point to assert on the saved-key validation path in isolation.
await service.initialize();
// Optimistic: the key is cached on credentials immediately.
expect(service.isAuthenticated()).toBe(true);
expect(service.getApiKey()).toBe("eliza_saved_key_c7");
// The background /models probe (scheduled at delay 0) rejects. Let the
// microtask + timer chain run.
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
// A single rejection is debounced — the key is NOT yet confirmed revoked
// and no loud error is logged.
expect(service.isApiKeyInvalid()).toBe(false);
expect(errorSpy).not.toHaveBeenCalled();
// The cached key survives — model calls would continue using it.
expect(service.getApiKey()).toBe("eliza_saved_key_c7");
} finally {
await service.stop();
errorSpy.mockRestore();
getSpy.mockRestore();
}
});
});
// ─── C8 — openBrowser failure surfaces via observer ──────────────────────
//
// Before this refactor, `openBrowser(url).catch(() => {})` swallowed all
// failures at debug-level. Now: when the OS open command (no `open` /
// `xdg-open` / `cmd.exe` on PATH) rejects, the observer's
// `onAuthBrowserOpenFailed(url, error)` MUST fire so desktop/web wrappers
// can render an inline "couldn't open browser" affordance.
describe("C8 — openBrowser failure surfaces via observer", () => {
it("fires onAuthBrowserOpenFailed when the OS open command rejects", async () => {
setAvailability({ acceptingNewAgents: true });
// Seed the shared execFile mock to reject with an ENOENT-style error.
// The `openBrowser` helper inside `cloud-setup.ts` reaches the mock
// via `await import("node:child_process")`. The helper is fire-and-
// forget from `runCloudAuth`, so we ensure the observer receives the
// failure even though the main flow continues.
execFileBehavior.invokeError = new Error("ENOENT: no such file or directory, open 'open'");
// cloudLogin fires its onBrowserUrl callback (which triggers
// openBrowser) and then resolves immediately so we can complete the
// flow and assert on the observer call without waiting for a real
// timeout. The provisioning step is also mocked through to running.
cloudLoginMock.mockImplementationOnce(async (opts) => {
// Trigger the browser-open path. The helper is fire-and-forget.
opts.onBrowserUrl?.("https://www.elizacloud.ai/auth/device?code=test");
// Give the setImmediate-fired execFile error a chance to land.
await new Promise((r) => setImmediate(r));
return {
apiKey: "eliza_test_key_C8",
keyPrefix: "eliza_",
expiresAt: null,
};
});
bridgeBehavior.createAgent.mockResolvedValueOnce({
id: "agent-id-c8",
agentName: "agent-c8",
status: "queued",
databaseStatus: "ok",
createdAt: "2026-05-10T00:00:00Z",
updatedAt: "2026-05-10T00:00:00Z",
});
bridgeBehavior.getAgent.mockResolvedValueOnce({
id: "agent-id-c8",
agentName: "agent-c8",
status: "running",
databaseStatus: "ok",
createdAt: "2026-05-10T00:00:00Z",
updatedAt: "2026-05-10T00:00:00Z",
});
const restoreSetTimeout = installSyncSetTimeout();
const observer = makeObserver();
try {
await runCloudSetup(observer, "agent-c8", undefined, "https://www.elizacloud.ai");
// Drain microtasks once more — the catch() that calls
// onAuthBrowserOpenFailed is on a fire-and-forget promise.
await new Promise((r) => setImmediate(r));
await new Promise((r) => setImmediate(r));
// The observer was notified of the browser-open failure with the
// right URL and an Error instance.
expect(observer.onAuthBrowserOpenFailed).toHaveBeenCalledTimes(1);
const [calledUrl, calledError] = observer.onAuthBrowserOpenFailed.mock.calls[0];
expect(calledUrl).toBe("https://www.elizacloud.ai/auth/device?code=test");
expect(calledError).toBeInstanceOf(Error);
expect((calledError as Error).message).toMatch(/ENOENT/);
} finally {
execFileBehavior.invokeError = null;
restoreSetTimeout();
}
});
});
// ─── C9 — Null observer never throws ──────────────────────────────────────
//
// Sanity check that the bundled `NullCloudSetupObserver` is a safe
// default for headless / test runs: a full setup pass through the
// availability=false branch must complete without throwing.
describe("C9 — null observer never throws", () => {
it("runs through availability=false → run-locally fallback without errors", async () => {
setAvailability({ success: true, acceptingNewAgents: false });
const observer = new NullCloudSetupObserver();
// NullCloudSetupObserver.confirm returns null on every call,
// which the orchestrator treats as cancel == "run locally" — the
// availability=false branch returns null without ever hitting auth.
const result = await runCloudSetup(
observer,
"agent-c9",
undefined,
"https://www.elizacloud.ai"
);
expect(result).toBeNull();
// We did NOT attempt auth — the null observer cancelled at the first
// prompt, which is the documented behavior for headless runs.
expect(cloudLogin).not.toHaveBeenCalled();
});
});