diff --git a/apps/server/tests/api-routes.test.ts b/apps/server/tests/api-routes.test.ts index 71b1676..079cf74 100644 --- a/apps/server/tests/api-routes.test.ts +++ b/apps/server/tests/api-routes.test.ts @@ -1,5 +1,5 @@ // @ts-nocheck -import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; // schemas/sessions.ts calls `.openapi()` on the @openagentpack/sdk core schemas at module-eval time. That // method is added to zod's prototype as a side effect of importing @hono/zod-openapi, so the core // schemas must be built on the SAME zod instance @hono/zod-openapi patched. IMPORTANT: do NOT @@ -10,12 +10,15 @@ import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; import { z as zWithOpenApi } from "@hono/zod-openapi"; import { getPlaybookAppId, PLAYBOOK_APP_METADATA_KEY, PLAYBOOK_METADATA_KEY } from "@openagentpack/playbooks"; import * as actualCore from "@openagentpack/sdk"; +import * as runtimeFactory from "@/services/runtime-factory"; +import * as sessionRunner from "@/services/sessions/runner"; if (typeof (zWithOpenApi.string() as { openapi?: unknown }).openapi !== "function") { throw new Error("@hono/zod-openapi did not patch zod with .openapi"); } const calls = { + withAgentRuntime: [] as unknown[], listSessionsForAgent: [] as unknown[], getSessionDetail: [] as unknown[], listSessionEventsPage: [] as unknown[], @@ -47,7 +50,7 @@ const state = { listCloudAgents: async () => [sampleCloudAgent()], }; -mock.module("@/services/sessions/runner", () => ({ +const sessionStubs = { listSessionsForAgent: async (...args: unknown[]) => { calls.listSessionsForAgent.push(args); return state.listSessionsForAgent(...args); @@ -76,53 +79,41 @@ mock.module("@/services/sessions/runner", () => ({ calls.updatePlaybookAgentModel.push(args); return state.updatePlaybookAgentModel(...args); }, - reconstructSessionBuffer: async () => false, -})); - -mock.module("@/services/runtime-factory", () => ({ - loadServerRuntimeConfig: async () => ({ - projectName: "project", - config: {}, - stateBackend: {}, - stateScope: { projectId: "project" }, - }), - loadAgentRuntimeInput: async (agentId: string) => ({ - projectName: "project", - config: {}, - stateBackend: {}, - stateScope: { projectId: "project" }, - agentId, - }), - withAgentRuntime: async (agentId: string, fn: (ctx: unknown, compiled: unknown) => unknown) => { - globalThis.__withAgentRuntimeCalls ??= []; - globalThis.__withAgentRuntimeCalls.push([agentId]); - return fn( - { configPath: "/tmp/agents.yaml" }, - { agentId, agent: { id: agentId, version: "1" }, agentConfigHash: "h" }, - ); - }, -})); - -// Stub the single SDK function the agents route calls. Using spyOn (not mock.module) keeps -// @openagentpack/sdk on one zod instance so schemas/sessions.ts can attach OpenAPI names (see top note). -spyOn(actualCore, "listAgentsWithReadiness").mockImplementation(async (...args: unknown[]) => { - calls.listAgentsWithReadiness.push(args); - return state.listAgentsWithReadiness(...args); -}); +}; -spyOn(actualCore, "listCloudAgents").mockImplementation(async (...args: unknown[]) => { - calls.listCloudAgents.push(args); - return state.listCloudAgents(...args); -}); +const spies: Array<{ mockRestore(): void }> = []; + +function installMocks() { + for (const name of Object.keys(sessionStubs)) { + spies.push(spyOn(sessionRunner, name).mockImplementation(sessionStubs[name])); + } + spies.push( + spyOn(runtimeFactory, "withAgentRuntime").mockImplementation(async (agentId, fn) => { + calls.withAgentRuntime.push([agentId]); + return fn( + { configPath: "/tmp/agents.yaml" }, + { agentId, agent: { id: agentId, version: "1" }, agentConfigHash: "h" }, + ); + }), + spyOn(actualCore, "listAgentsWithReadiness").mockImplementation(async (...args: unknown[]) => { + calls.listAgentsWithReadiness.push(args); + return state.listAgentsWithReadiness(...args); + }), + spyOn(actualCore, "listCloudAgents").mockImplementation(async (...args: unknown[]) => { + calls.listCloudAgents.push(args); + return state.listCloudAgents(...args); + }), + ); +} -// Import Hono routes (they use the mocked @/services/* and @openagentpack/sdk modules above) +// Load real modules before installing per-test spies. Replacing a whole module +// hides exports used by other suites that share Bun's module cache. const { agentsRoute: agentsApp } = await import("../src/routes/agents"); const { sessionsRoute: sessionsApp } = await import("../src/routes/sessions"); describe("API routes", () => { beforeEach(() => { for (const key of Object.keys(calls)) calls[key].length = 0; - globalThis.__withAgentRuntimeCalls = []; state.listSessionsForAgent = async () => ({ sessions: [sampleSession()], nextPageToken: undefined }); state.getSessionDetail = async () => ({ session: sampleSession(), events: [sampleProviderEvent()] }); state.listSessionEventsPage = async () => ({ events: [sampleProviderEvent()], eventsNextPageToken: undefined }); @@ -144,6 +135,11 @@ describe("API routes", () => { ]; state.ensureAgentReady = async () => ({ agentId: "bailian-cli", status: "completed", results: [] }); state.listCloudAgents = async () => [sampleCloudAgent()]; + installMocks(); + }); + + afterEach(() => { + for (const spy of spies.splice(0)) spy.mockRestore(); }); test("GET /api/sessions returns the snake_case session list", async () => { @@ -291,7 +287,7 @@ describe("API routes", () => { const body = await response.json(); expect(response.status).toBe(200); - expect(globalThis.__withAgentRuntimeCalls).toEqual([["bailian-cli"]]); + expect(calls.withAgentRuntime).toEqual([["bailian-cli"]]); expect(calls.listAgentsWithReadiness[0][1]).toEqual({ refresh: false }); expect(body.agents[0].agent.id).toBe("bailian-cli"); expect(body.agents[0].readiness.agentId).toBe("bailian-cli"); @@ -303,7 +299,7 @@ describe("API routes", () => { expect(response.status).toBe(200); // Resolved against the bootstrap agent runtime once (not a per-request agentId). - expect(globalThis.__withAgentRuntimeCalls).toHaveLength(1); + expect(calls.withAgentRuntime).toHaveLength(1); expect(calls.listCloudAgents[0][1]).toEqual({ prefix: "Agents/", limit: 100 }); expect(body.agents[0].id).toBe("agt_cloud_1"); expect(body.agents[0].name).toBe("Agents/researcher"); diff --git a/apps/server/tests/deployments-manage.test.ts b/apps/server/tests/deployments-manage.test.ts index 0f0360e..167b09f 100644 --- a/apps/server/tests/deployments-manage.test.ts +++ b/apps/server/tests/deployments-manage.test.ts @@ -1,7 +1,9 @@ -import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterAll, afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import * as sdk from "@openagentpack/sdk"; +import * as runtimeFactory from "@/services/runtime-factory"; let activeProvider = "qoder"; let executionStatus: "success" | "failed" = "success"; @@ -9,8 +11,9 @@ let executionGate: Promise | undefined; const missingRemoteIds = new Set(); const unavailableProviders = new Set(); let runError: { type: string; message: string } | undefined; +let deploymentDeleteId: string | undefined; -mock.module("@/services/runtime-factory", () => ({ +const runtimeStubs = { loadCompiledRuntimeInput: async (playbookId: string, providerOverride?: string) => { const provider = providerOverride ?? activeProvider; if (unavailableProviders.has(provider)) throw new Error(`credentials unavailable for ${provider}`); @@ -33,15 +36,14 @@ mock.module("@/services/runtime-factory", () => ({ compiled: { agentId: "agent", agent: { id: playbookId }, agentConfigHash: "hash" }, }; }, -})); +}; -mock.module("@openagentpack/sdk", () => ({ - UserError: class UserError extends Error {}, +const sdkStubs = { syncAgentResourcesWithStateBackend: async () => ({ status: "completed" }), writeProjectRuntime: async (input: unknown, fn: (ctx: unknown) => unknown) => fn({ input }), planProjectContext: async (ctx: { input: { config: { deployments?: Record } } }) => { const configured = Object.keys(ctx.input.config.deployments ?? {}); - const id = configured[0] ?? globalThis.__deploymentDeleteId; + const id = configured[0] ?? deploymentDeleteId; return { executionContext: ctx, plan: { @@ -81,12 +83,45 @@ mock.module("@openagentpack/sdk", () => ({ provider: activeProvider, result: { session_id: runError ? null : "session", ...(runError ? { error: runError } : {}) }, }), -})); +}; + +const spies: Array<{ mockRestore(): void }> = []; + +function installMocks() { + // The fixtures intentionally model only the fields consumed by this service. + // Spy on functions, never replace the SDK barrel or runtime-factory exports. + spies.push( + spyOn(runtimeFactory, "loadCompiledRuntimeInput").mockImplementation( + runtimeStubs.loadCompiledRuntimeInput as unknown as typeof runtimeFactory.loadCompiledRuntimeInput, + ), + spyOn(sdk, "syncAgentResourcesWithStateBackend").mockImplementation( + sdkStubs.syncAgentResourcesWithStateBackend as typeof sdk.syncAgentResourcesWithStateBackend, + ), + spyOn(sdk, "writeProjectRuntime").mockImplementation( + sdkStubs.writeProjectRuntime as typeof sdk.writeProjectRuntime, + ), + spyOn(sdk, "planProjectContext").mockImplementation( + sdkStubs.planProjectContext as unknown as typeof sdk.planProjectContext, + ), + spyOn(sdk, "executePlannedProject").mockImplementation( + sdkStubs.executePlannedProject as typeof sdk.executePlannedProject, + ), + spyOn(sdk, "getDeploymentDetailsForContext").mockImplementation( + sdkStubs.getDeploymentDetailsForContext as unknown as typeof sdk.getDeploymentDetailsForContext, + ), + spyOn(sdk, "pauseDeploymentForContext").mockImplementation( + sdkStubs.pauseDeploymentForContext as typeof sdk.pauseDeploymentForContext, + ), + spyOn(sdk, "runDeploymentForContext").mockImplementation( + sdkStubs.runDeploymentForContext as typeof sdk.runDeploymentForContext, + ), + ); +} const manage = await import("../src/services/deployments/manage"); const testDir = await mkdtemp(join(tmpdir(), "opencma-deployments-")); const storePath = join(testDir, "deployments.json"); -process.env.AGENTS_DEPLOYMENTS_PATH = storePath; +let previousStorePath: string | undefined; function input(name: string) { return { name, playbookId: "base", prompt: "test", expression: "0 9 * * *", timezone: "Asia/Shanghai" }; @@ -103,6 +138,8 @@ async function stored() { } beforeEach(async () => { + previousStorePath = process.env.AGENTS_DEPLOYMENTS_PATH; + process.env.AGENTS_DEPLOYMENTS_PATH = storePath; await rm(storePath, { force: true }); activeProvider = "qoder"; executionStatus = "success"; @@ -110,11 +147,17 @@ beforeEach(async () => { missingRemoteIds.clear(); unavailableProviders.clear(); runError = undefined; - globalThis.__deploymentDeleteId = undefined; + deploymentDeleteId = undefined; + installMocks(); +}); + +afterEach(() => { + for (const spy of spies.splice(0)) spy.mockRestore(); + if (previousStorePath === undefined) delete process.env.AGENTS_DEPLOYMENTS_PATH; + else process.env.AGENTS_DEPLOYMENTS_PATH = previousStorePath; }); afterAll(async () => { - delete process.env.AGENTS_DEPLOYMENTS_PATH; await rm(testDir, { recursive: true, force: true }); }); @@ -127,7 +170,7 @@ describe("managed deployments consistency", () => { test("retains the local record when provider delete returns a failed result", async () => { const created = await manage.createManagedDeployment(input("keep-me")); - globalThis.__deploymentDeleteId = created.id; + deploymentDeleteId = created.id; executionStatus = "failed"; await expect(manage.deleteManagedDeployment(created.id)).rejects.toThrow("provider failed"); expect((await stored()).deployments.map((item) => item.id)).toEqual([created.id]); @@ -177,7 +220,3 @@ describe("managed deployments consistency", () => { await expect(manage.runManagedDeployment(created.id)).rejects.toThrow("provider rejected the run"); }); }); - -declare global { - var __deploymentDeleteId: string | undefined; -} diff --git a/apps/server/tests/module-isolation.test.ts b/apps/server/tests/module-isolation.test.ts new file mode 100644 index 0000000..0001913 --- /dev/null +++ b/apps/server/tests/module-isolation.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { resolve } from "node:path"; + +describe("server test module isolation", () => { + const orders = { + "API routes before deployments": [ + "./tests/api-routes.test.ts", + "./tests/deployments-manage.test.ts", + "./tests/openapi-contract.test.ts", + "./tests/runtime-config.test.ts", + ], + "deployments before runtime config and routes": [ + "./tests/deployments-manage.test.ts", + "./tests/runtime-config.test.ts", + "./tests/api-routes.test.ts", + "./tests/openapi-contract.test.ts", + ], + }; + + for (const [name, files] of Object.entries(orders)) { + test(name, () => { + // Each child shares one module cache across these suites. Do not run this + // regression file in the child or isolate each individual test file. + const child = Bun.spawnSync([process.execPath, "test", ...files], { + cwd: resolve(import.meta.dirname, ".."), + stdout: "pipe", + stderr: "pipe", + timeout: 10_000, + }); + const output = `${child.stdout.toString()}\n${child.stderr.toString()}`; + expect(child.exitCode, output).toBe(0); + }, 15_000); + } +});