项目文件夹

文件
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 行
38 KiB
TypeScript

/**
* 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 C1–C7 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();
});
});