Skip to content

Commit f49bbfd

Browse files
committed
fix(server): isolate test mocks across suites
1 parent 91227b9 commit f49bbfd

3 files changed

Lines changed: 127 additions & 58 deletions

File tree

apps/server/tests/api-routes.test.ts

Lines changed: 39 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// @ts-nocheck
2-
import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
2+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
33
// schemas/sessions.ts calls `.openapi()` on the @openagentpack/sdk core schemas at module-eval time. That
44
// method is added to zod's prototype as a side effect of importing @hono/zod-openapi, so the core
55
// 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";
1010
import { z as zWithOpenApi } from "@hono/zod-openapi";
1111
import { getPlaybookAppId, PLAYBOOK_APP_METADATA_KEY, PLAYBOOK_METADATA_KEY } from "@openagentpack/playbooks";
1212
import * as actualCore from "@openagentpack/sdk";
13+
import * as runtimeFactory from "@/services/runtime-factory";
14+
import * as sessionRunner from "@/services/sessions/runner";
1315

1416
if (typeof (zWithOpenApi.string() as { openapi?: unknown }).openapi !== "function") {
1517
throw new Error("@hono/zod-openapi did not patch zod with .openapi");
1618
}
1719

1820
const calls = {
21+
withAgentRuntime: [] as unknown[],
1922
listSessionsForAgent: [] as unknown[],
2023
getSessionDetail: [] as unknown[],
2124
listSessionEventsPage: [] as unknown[],
@@ -47,7 +50,7 @@ const state = {
4750
listCloudAgents: async () => [sampleCloudAgent()],
4851
};
4952

50-
mock.module("@/services/sessions/runner", () => ({
53+
const sessionStubs = {
5154
listSessionsForAgent: async (...args: unknown[]) => {
5255
calls.listSessionsForAgent.push(args);
5356
return state.listSessionsForAgent(...args);
@@ -76,53 +79,41 @@ mock.module("@/services/sessions/runner", () => ({
7679
calls.updatePlaybookAgentModel.push(args);
7780
return state.updatePlaybookAgentModel(...args);
7881
},
79-
reconstructSessionBuffer: async () => false,
80-
}));
81-
82-
mock.module("@/services/runtime-factory", () => ({
83-
loadServerRuntimeConfig: async () => ({
84-
projectName: "project",
85-
config: {},
86-
stateBackend: {},
87-
stateScope: { projectId: "project" },
88-
}),
89-
loadAgentRuntimeInput: async (agentId: string) => ({
90-
projectName: "project",
91-
config: {},
92-
stateBackend: {},
93-
stateScope: { projectId: "project" },
94-
agentId,
95-
}),
96-
withAgentRuntime: async (agentId: string, fn: (ctx: unknown, compiled: unknown) => unknown) => {
97-
globalThis.__withAgentRuntimeCalls ??= [];
98-
globalThis.__withAgentRuntimeCalls.push([agentId]);
99-
return fn(
100-
{ configPath: "/tmp/agents.yaml" },
101-
{ agentId, agent: { id: agentId, version: "1" }, agentConfigHash: "h" },
102-
);
103-
},
104-
}));
105-
106-
// Stub the single SDK function the agents route calls. Using spyOn (not mock.module) keeps
107-
// @openagentpack/sdk on one zod instance so schemas/sessions.ts can attach OpenAPI names (see top note).
108-
spyOn(actualCore, "listAgentsWithReadiness").mockImplementation(async (...args: unknown[]) => {
109-
calls.listAgentsWithReadiness.push(args);
110-
return state.listAgentsWithReadiness(...args);
111-
});
82+
};
11283

113-
spyOn(actualCore, "listCloudAgents").mockImplementation(async (...args: unknown[]) => {
114-
calls.listCloudAgents.push(args);
115-
return state.listCloudAgents(...args);
116-
});
84+
const spies: Array<{ mockRestore(): void }> = [];
85+
86+
function installMocks() {
87+
for (const name of Object.keys(sessionStubs)) {
88+
spies.push(spyOn(sessionRunner, name).mockImplementation(sessionStubs[name]));
89+
}
90+
spies.push(
91+
spyOn(runtimeFactory, "withAgentRuntime").mockImplementation(async (agentId, fn) => {
92+
calls.withAgentRuntime.push([agentId]);
93+
return fn(
94+
{ configPath: "/tmp/agents.yaml" },
95+
{ agentId, agent: { id: agentId, version: "1" }, agentConfigHash: "h" },
96+
);
97+
}),
98+
spyOn(actualCore, "listAgentsWithReadiness").mockImplementation(async (...args: unknown[]) => {
99+
calls.listAgentsWithReadiness.push(args);
100+
return state.listAgentsWithReadiness(...args);
101+
}),
102+
spyOn(actualCore, "listCloudAgents").mockImplementation(async (...args: unknown[]) => {
103+
calls.listCloudAgents.push(args);
104+
return state.listCloudAgents(...args);
105+
}),
106+
);
107+
}
117108

118-
// Import Hono routes (they use the mocked @/services/* and @openagentpack/sdk modules above)
109+
// Load real modules before installing per-test spies. Replacing a whole module
110+
// hides exports used by other suites that share Bun's module cache.
119111
const { agentsRoute: agentsApp } = await import("../src/routes/agents");
120112
const { sessionsRoute: sessionsApp } = await import("../src/routes/sessions");
121113

122114
describe("API routes", () => {
123115
beforeEach(() => {
124116
for (const key of Object.keys(calls)) calls[key].length = 0;
125-
globalThis.__withAgentRuntimeCalls = [];
126117
state.listSessionsForAgent = async () => ({ sessions: [sampleSession()], nextPageToken: undefined });
127118
state.getSessionDetail = async () => ({ session: sampleSession(), events: [sampleProviderEvent()] });
128119
state.listSessionEventsPage = async () => ({ events: [sampleProviderEvent()], eventsNextPageToken: undefined });
@@ -144,6 +135,11 @@ describe("API routes", () => {
144135
];
145136
state.ensureAgentReady = async () => ({ agentId: "bailian-cli", status: "completed", results: [] });
146137
state.listCloudAgents = async () => [sampleCloudAgent()];
138+
installMocks();
139+
});
140+
141+
afterEach(() => {
142+
for (const spy of spies.splice(0)) spy.mockRestore();
147143
});
148144

149145
test("GET /api/sessions returns the snake_case session list", async () => {
@@ -291,7 +287,7 @@ describe("API routes", () => {
291287
const body = await response.json();
292288

293289
expect(response.status).toBe(200);
294-
expect(globalThis.__withAgentRuntimeCalls).toEqual([["bailian-cli"]]);
290+
expect(calls.withAgentRuntime).toEqual([["bailian-cli"]]);
295291
expect(calls.listAgentsWithReadiness[0][1]).toEqual({ refresh: false });
296292
expect(body.agents[0].agent.id).toBe("bailian-cli");
297293
expect(body.agents[0].readiness.agentId).toBe("bailian-cli");
@@ -303,7 +299,7 @@ describe("API routes", () => {
303299

304300
expect(response.status).toBe(200);
305301
// Resolved against the bootstrap agent runtime once (not a per-request agentId).
306-
expect(globalThis.__withAgentRuntimeCalls).toHaveLength(1);
302+
expect(calls.withAgentRuntime).toHaveLength(1);
307303
expect(calls.listCloudAgents[0][1]).toEqual({ prefix: "Agents/", limit: 100 });
308304
expect(body.agents[0].id).toBe("agt_cloud_1");
309305
expect(body.agents[0].name).toBe("Agents/researcher");

apps/server/tests/deployments-manage.test.ts

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
1-
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
1+
import { afterAll, afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
22
import { mkdtemp, readFile, rm } from "node:fs/promises";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
5+
import * as sdk from "@openagentpack/sdk";
6+
import * as runtimeFactory from "@/services/runtime-factory";
57

68
let activeProvider = "qoder";
79
let executionStatus: "success" | "failed" = "success";
810
let executionGate: Promise<void> | undefined;
911
const missingRemoteIds = new Set<string>();
1012
const unavailableProviders = new Set<string>();
1113
let runError: { type: string; message: string } | undefined;
14+
let deploymentDeleteId: string | undefined;
1215

13-
mock.module("@/services/runtime-factory", () => ({
16+
const runtimeStubs = {
1417
loadCompiledRuntimeInput: async (playbookId: string, providerOverride?: string) => {
1518
const provider = providerOverride ?? activeProvider;
1619
if (unavailableProviders.has(provider)) throw new Error(`credentials unavailable for ${provider}`);
@@ -33,15 +36,14 @@ mock.module("@/services/runtime-factory", () => ({
3336
compiled: { agentId: "agent", agent: { id: playbookId }, agentConfigHash: "hash" },
3437
};
3538
},
36-
}));
39+
};
3740

38-
mock.module("@openagentpack/sdk", () => ({
39-
UserError: class UserError extends Error {},
41+
const sdkStubs = {
4042
syncAgentResourcesWithStateBackend: async () => ({ status: "completed" }),
4143
writeProjectRuntime: async (input: unknown, fn: (ctx: unknown) => unknown) => fn({ input }),
4244
planProjectContext: async (ctx: { input: { config: { deployments?: Record<string, unknown> } } }) => {
4345
const configured = Object.keys(ctx.input.config.deployments ?? {});
44-
const id = configured[0] ?? globalThis.__deploymentDeleteId;
46+
const id = configured[0] ?? deploymentDeleteId;
4547
return {
4648
executionContext: ctx,
4749
plan: {
@@ -81,12 +83,45 @@ mock.module("@openagentpack/sdk", () => ({
8183
provider: activeProvider,
8284
result: { session_id: runError ? null : "session", ...(runError ? { error: runError } : {}) },
8385
}),
84-
}));
86+
};
87+
88+
const spies: Array<{ mockRestore(): void }> = [];
89+
90+
function installMocks() {
91+
// The fixtures intentionally model only the fields consumed by this service.
92+
// Spy on functions, never replace the SDK barrel or runtime-factory exports.
93+
spies.push(
94+
spyOn(runtimeFactory, "loadCompiledRuntimeInput").mockImplementation(
95+
runtimeStubs.loadCompiledRuntimeInput as unknown as typeof runtimeFactory.loadCompiledRuntimeInput,
96+
),
97+
spyOn(sdk, "syncAgentResourcesWithStateBackend").mockImplementation(
98+
sdkStubs.syncAgentResourcesWithStateBackend as typeof sdk.syncAgentResourcesWithStateBackend,
99+
),
100+
spyOn(sdk, "writeProjectRuntime").mockImplementation(
101+
sdkStubs.writeProjectRuntime as typeof sdk.writeProjectRuntime,
102+
),
103+
spyOn(sdk, "planProjectContext").mockImplementation(
104+
sdkStubs.planProjectContext as unknown as typeof sdk.planProjectContext,
105+
),
106+
spyOn(sdk, "executePlannedProject").mockImplementation(
107+
sdkStubs.executePlannedProject as typeof sdk.executePlannedProject,
108+
),
109+
spyOn(sdk, "getDeploymentDetailsForContext").mockImplementation(
110+
sdkStubs.getDeploymentDetailsForContext as unknown as typeof sdk.getDeploymentDetailsForContext,
111+
),
112+
spyOn(sdk, "pauseDeploymentForContext").mockImplementation(
113+
sdkStubs.pauseDeploymentForContext as typeof sdk.pauseDeploymentForContext,
114+
),
115+
spyOn(sdk, "runDeploymentForContext").mockImplementation(
116+
sdkStubs.runDeploymentForContext as typeof sdk.runDeploymentForContext,
117+
),
118+
);
119+
}
85120

86121
const manage = await import("../src/services/deployments/manage");
87122
const testDir = await mkdtemp(join(tmpdir(), "opencma-deployments-"));
88123
const storePath = join(testDir, "deployments.json");
89-
process.env.AGENTS_DEPLOYMENTS_PATH = storePath;
124+
let previousStorePath: string | undefined;
90125

91126
function input(name: string) {
92127
return { name, playbookId: "base", prompt: "test", expression: "0 9 * * *", timezone: "Asia/Shanghai" };
@@ -103,18 +138,26 @@ async function stored() {
103138
}
104139

105140
beforeEach(async () => {
141+
previousStorePath = process.env.AGENTS_DEPLOYMENTS_PATH;
142+
process.env.AGENTS_DEPLOYMENTS_PATH = storePath;
106143
await rm(storePath, { force: true });
107144
activeProvider = "qoder";
108145
executionStatus = "success";
109146
executionGate = undefined;
110147
missingRemoteIds.clear();
111148
unavailableProviders.clear();
112149
runError = undefined;
113-
globalThis.__deploymentDeleteId = undefined;
150+
deploymentDeleteId = undefined;
151+
installMocks();
152+
});
153+
154+
afterEach(() => {
155+
for (const spy of spies.splice(0)) spy.mockRestore();
156+
if (previousStorePath === undefined) delete process.env.AGENTS_DEPLOYMENTS_PATH;
157+
else process.env.AGENTS_DEPLOYMENTS_PATH = previousStorePath;
114158
});
115159

116160
afterAll(async () => {
117-
delete process.env.AGENTS_DEPLOYMENTS_PATH;
118161
await rm(testDir, { recursive: true, force: true });
119162
});
120163

@@ -127,7 +170,7 @@ describe("managed deployments consistency", () => {
127170

128171
test("retains the local record when provider delete returns a failed result", async () => {
129172
const created = await manage.createManagedDeployment(input("keep-me"));
130-
globalThis.__deploymentDeleteId = created.id;
173+
deploymentDeleteId = created.id;
131174
executionStatus = "failed";
132175
await expect(manage.deleteManagedDeployment(created.id)).rejects.toThrow("provider failed");
133176
expect((await stored()).deployments.map((item) => item.id)).toEqual([created.id]);
@@ -177,7 +220,3 @@ describe("managed deployments consistency", () => {
177220
await expect(manage.runManagedDeployment(created.id)).rejects.toThrow("provider rejected the run");
178221
});
179222
});
180-
181-
declare global {
182-
var __deploymentDeleteId: string | undefined;
183-
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { resolve } from "node:path";
3+
4+
describe("server test module isolation", () => {
5+
const orders = {
6+
"API routes before deployments": [
7+
"./tests/api-routes.test.ts",
8+
"./tests/deployments-manage.test.ts",
9+
"./tests/openapi-contract.test.ts",
10+
"./tests/runtime-config.test.ts",
11+
],
12+
"deployments before runtime config and routes": [
13+
"./tests/deployments-manage.test.ts",
14+
"./tests/runtime-config.test.ts",
15+
"./tests/api-routes.test.ts",
16+
"./tests/openapi-contract.test.ts",
17+
],
18+
};
19+
20+
for (const [name, files] of Object.entries(orders)) {
21+
test(name, () => {
22+
// Each child shares one module cache across these suites. Do not run this
23+
// regression file in the child or isolate each individual test file.
24+
const child = Bun.spawnSync([process.execPath, "test", ...files], {
25+
cwd: resolve(import.meta.dirname, ".."),
26+
stdout: "pipe",
27+
stderr: "pipe",
28+
timeout: 10_000,
29+
});
30+
const output = `${child.stdout.toString()}\n${child.stderr.toString()}`;
31+
expect(child.exitCode, output).toBe(0);
32+
}, 15_000);
33+
}
34+
});

0 commit comments

Comments
 (0)