From 64be2b2cd1e3dc6b8741494f27ad384ea616d999 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 04:28:02 +0000 Subject: [PATCH] test: speed up gateway suite setup --- src/gateway/openai-http.e2e.test.ts | 763 ++++++++---------- src/gateway/server.config-apply.test.ts | 153 ++-- src/gateway/server.cron.test.ts | 2 +- src/gateway/server.health.test.ts | 64 +- src/gateway/server.ios-client-id.test.ts | 20 +- ...sessions.gateway-server-sessions-a.test.ts | 44 +- src/gateway/test-helpers.server.ts | 183 +++-- 7 files changed, 606 insertions(+), 623 deletions(-) diff --git a/src/gateway/openai-http.e2e.test.ts b/src/gateway/openai-http.e2e.test.ts index be5a39b99..49dca3822 100644 --- a/src/gateway/openai-http.e2e.test.ts +++ b/src/gateway/openai-http.e2e.test.ts @@ -49,469 +49,372 @@ function parseSseDataLines(text: string): string[] { } describe("OpenAI-compatible HTTP API (e2e)", () => { - it("is disabled by default (requires config)", { timeout: 120_000 }, async () => { - const port = await getFreePort(); - const server = await startServerWithDefaultConfig(port); - try { - const res = await postChatCompletions(port, { - model: "clawdbot", - messages: [{ role: "user", content: "hi" }], + it("rejects when disabled (default + config)", { timeout: 120_000 }, async () => { + { + const port = await getFreePort(); + const server = await startServerWithDefaultConfig(port); + try { + const res = await postChatCompletions(port, { + model: "clawdbot", + messages: [{ role: "user", content: "hi" }], + }); + expect(res.status).toBe(404); + } finally { + await server.close({ reason: "test done" }); + } + } + + { + const port = await getFreePort(); + const server = await startServer(port, { + openAiChatCompletionsEnabled: false, }); - expect(res.status).toBe(404); - } finally { - await server.close({ reason: "test done" }); + try { + const res = await postChatCompletions(port, { + model: "clawdbot", + messages: [{ role: "user", content: "hi" }], + }); + expect(res.status).toBe(404); + } finally { + await server.close({ reason: "test done" }); + } } }); - it("can be disabled via config (404)", async () => { - const port = await getFreePort(); - const server = await startServer(port, { - openAiChatCompletionsEnabled: false, - }); - try { - const res = await postChatCompletions(port, { - model: "clawdbot", - messages: [{ role: "user", content: "hi" }], - }); - expect(res.status).toBe(404); - } finally { - await server.close({ reason: "test done" }); - } - }); - - it("rejects non-POST", async () => { + it("handles request validation and routing", async () => { const port = await getFreePort(); const server = await startServer(port); + const mockAgentOnce = (payloads: Array<{ text: string }>) => { + agentCommand.mockReset(); + agentCommand.mockResolvedValueOnce({ payloads } as never); + }; + try { - const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { - method: "GET", - headers: { authorization: "Bearer secret" }, - }); - expect(res.status).toBe(405); - } finally { - await server.close({ reason: "test done" }); - } - }); + { + const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: "GET", + headers: { authorization: "Bearer secret" }, + }); + expect(res.status).toBe(405); + await res.text(); + } - it("rejects missing auth", async () => { - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }), - }); - expect(res.status).toBe(401); - } finally { - await server.close({ reason: "test done" }); - } - }); + { + const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }), + }); + expect(res.status).toBe(401); + await res.text(); + } - it("routes to a specific agent via header", async () => { - agentCommand.mockResolvedValueOnce({ - payloads: [{ text: "hello" }], - } as never); + { + mockAgentOnce([{ text: "hello" }]); + const res = await postChatCompletions( + port, + { model: "clawdbot", messages: [{ role: "user", content: "hi" }] }, + { "x-clawdbot-agent-id": "beta" }, + ); + expect(res.status).toBe(200); - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postChatCompletions( - port, - { model: "clawdbot", messages: [{ role: "user", content: "hi" }] }, - { "x-clawdbot-agent-id": "beta" }, - ); - expect(res.status).toBe(200); + expect(agentCommand).toHaveBeenCalledTimes(1); + const [opts] = agentCommand.mock.calls[0] ?? []; + expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch( + /^agent:beta:/, + ); + await res.text(); + } - expect(agentCommand).toHaveBeenCalledTimes(1); - const [opts] = agentCommand.mock.calls[0] ?? []; - expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch( - /^agent:beta:/, - ); - } finally { - await server.close({ reason: "test done" }); - } - }); - - it("routes to a specific agent via model (no custom headers)", async () => { - agentCommand.mockResolvedValueOnce({ - payloads: [{ text: "hello" }], - } as never); - - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postChatCompletions(port, { - model: "clawdbot:beta", - messages: [{ role: "user", content: "hi" }], - }); - expect(res.status).toBe(200); - - expect(agentCommand).toHaveBeenCalledTimes(1); - const [opts] = agentCommand.mock.calls[0] ?? []; - expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch( - /^agent:beta:/, - ); - } finally { - await server.close({ reason: "test done" }); - } - }); - - it("prefers explicit header agent over model agent", async () => { - agentCommand.mockResolvedValueOnce({ - payloads: [{ text: "hello" }], - } as never); - - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postChatCompletions( - port, - { + { + mockAgentOnce([{ text: "hello" }]); + const res = await postChatCompletions(port, { model: "clawdbot:beta", messages: [{ role: "user", content: "hi" }], - }, - { "x-clawdbot-agent-id": "alpha" }, - ); - expect(res.status).toBe(200); + }); + expect(res.status).toBe(200); - expect(agentCommand).toHaveBeenCalledTimes(1); - const [opts] = agentCommand.mock.calls[0] ?? []; - expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch( - /^agent:alpha:/, - ); - } finally { - await server.close({ reason: "test done" }); - } - }); + expect(agentCommand).toHaveBeenCalledTimes(1); + const [opts] = agentCommand.mock.calls[0] ?? []; + expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch( + /^agent:beta:/, + ); + await res.text(); + } - it("honors x-clawdbot-session-key override", async () => { - agentCommand.mockResolvedValueOnce({ - payloads: [{ text: "hello" }], - } as never); - - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postChatCompletions( - port, - { model: "clawdbot", messages: [{ role: "user", content: "hi" }] }, - { - "x-clawdbot-agent-id": "beta", - "x-clawdbot-session-key": "agent:beta:openai:custom", - }, - ); - expect(res.status).toBe(200); - - const [opts] = agentCommand.mock.calls[0] ?? []; - expect((opts as { sessionKey?: string } | undefined)?.sessionKey).toBe( - "agent:beta:openai:custom", - ); - } finally { - await server.close({ reason: "test done" }); - } - }); - - it("uses OpenAI user for a stable session key", async () => { - agentCommand.mockResolvedValueOnce({ - payloads: [{ text: "hello" }], - } as never); - - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postChatCompletions(port, { - user: "alice", - model: "clawdbot", - messages: [{ role: "user", content: "hi" }], - }); - expect(res.status).toBe(200); - - const [opts] = agentCommand.mock.calls[0] ?? []; - expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toContain( - "openai-user:alice", - ); - } finally { - await server.close({ reason: "test done" }); - } - }); - - it("extracts user message text from array content", async () => { - agentCommand.mockResolvedValueOnce({ - payloads: [{ text: "hello" }], - } as never); - - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postChatCompletions(port, { - model: "clawdbot", - messages: [ + { + mockAgentOnce([{ text: "hello" }]); + const res = await postChatCompletions( + port, { - role: "user", - content: [ - { type: "text", text: "hello" }, - { type: "input_text", text: "world" }, - ], + model: "clawdbot:beta", + messages: [{ role: "user", content: "hi" }], }, - ], - }); - expect(res.status).toBe(200); + { "x-clawdbot-agent-id": "alpha" }, + ); + expect(res.status).toBe(200); - const [opts] = agentCommand.mock.calls[0] ?? []; - expect((opts as { message?: string } | undefined)?.message).toBe("hello\nworld"); + expect(agentCommand).toHaveBeenCalledTimes(1); + const [opts] = agentCommand.mock.calls[0] ?? []; + expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch( + /^agent:alpha:/, + ); + await res.text(); + } + + { + mockAgentOnce([{ text: "hello" }]); + const res = await postChatCompletions( + port, + { model: "clawdbot", messages: [{ role: "user", content: "hi" }] }, + { + "x-clawdbot-agent-id": "beta", + "x-clawdbot-session-key": "agent:beta:openai:custom", + }, + ); + expect(res.status).toBe(200); + + const [opts] = agentCommand.mock.calls[0] ?? []; + expect((opts as { sessionKey?: string } | undefined)?.sessionKey).toBe( + "agent:beta:openai:custom", + ); + await res.text(); + } + + { + mockAgentOnce([{ text: "hello" }]); + const res = await postChatCompletions(port, { + user: "alice", + model: "clawdbot", + messages: [{ role: "user", content: "hi" }], + }); + expect(res.status).toBe(200); + + const [opts] = agentCommand.mock.calls[0] ?? []; + expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toContain( + "openai-user:alice", + ); + await res.text(); + } + + { + mockAgentOnce([{ text: "hello" }]); + const res = await postChatCompletions(port, { + model: "clawdbot", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "hello" }, + { type: "input_text", text: "world" }, + ], + }, + ], + }); + expect(res.status).toBe(200); + + const [opts] = agentCommand.mock.calls[0] ?? []; + expect((opts as { message?: string } | undefined)?.message).toBe("hello\nworld"); + await res.text(); + } + + { + mockAgentOnce([{ text: "I am Claude" }]); + const res = await postChatCompletions(port, { + model: "clawdbot", + messages: [ + { role: "system", content: "You are a helpful assistant." }, + { role: "user", content: "Hello, who are you?" }, + { role: "assistant", content: "I am Claude." }, + { role: "user", content: "What did I just ask you?" }, + ], + }); + expect(res.status).toBe(200); + + const [opts] = agentCommand.mock.calls[0] ?? []; + const message = (opts as { message?: string } | undefined)?.message ?? ""; + expect(message).toContain(HISTORY_CONTEXT_MARKER); + expect(message).toContain("User: Hello, who are you?"); + expect(message).toContain("Assistant: I am Claude."); + expect(message).toContain(CURRENT_MESSAGE_MARKER); + expect(message).toContain("User: What did I just ask you?"); + await res.text(); + } + + { + mockAgentOnce([{ text: "hello" }]); + const res = await postChatCompletions(port, { + model: "clawdbot", + messages: [ + { role: "system", content: "You are a helpful assistant." }, + { role: "user", content: "Hello" }, + ], + }); + expect(res.status).toBe(200); + + const [opts] = agentCommand.mock.calls[0] ?? []; + const message = (opts as { message?: string } | undefined)?.message ?? ""; + expect(message).not.toContain(HISTORY_CONTEXT_MARKER); + expect(message).not.toContain(CURRENT_MESSAGE_MARKER); + expect(message).toBe("Hello"); + await res.text(); + } + + { + mockAgentOnce([{ text: "hello" }]); + const res = await postChatCompletions(port, { + model: "clawdbot", + messages: [ + { role: "developer", content: "You are a helpful assistant." }, + { role: "user", content: "Hello" }, + ], + }); + expect(res.status).toBe(200); + + const [opts] = agentCommand.mock.calls[0] ?? []; + const extraSystemPrompt = + (opts as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? ""; + expect(extraSystemPrompt).toBe("You are a helpful assistant."); + await res.text(); + } + + { + mockAgentOnce([{ text: "ok" }]); + const res = await postChatCompletions(port, { + model: "clawdbot", + messages: [ + { role: "system", content: "You are a helpful assistant." }, + { role: "user", content: "What's the weather?" }, + { role: "assistant", content: "Checking the weather." }, + { role: "tool", content: "Sunny, 70F." }, + ], + }); + expect(res.status).toBe(200); + + const [opts] = agentCommand.mock.calls[0] ?? []; + const message = (opts as { message?: string } | undefined)?.message ?? ""; + expect(message).toContain(HISTORY_CONTEXT_MARKER); + expect(message).toContain("User: What's the weather?"); + expect(message).toContain("Assistant: Checking the weather."); + expect(message).toContain(CURRENT_MESSAGE_MARKER); + expect(message).toContain("Tool: Sunny, 70F."); + await res.text(); + } + + { + mockAgentOnce([{ text: "hello" }]); + const res = await postChatCompletions(port, { + stream: false, + model: "clawdbot", + messages: [{ role: "user", content: "hi" }], + }); + expect(res.status).toBe(200); + const json = (await res.json()) as Record; + expect(json.object).toBe("chat.completion"); + expect(Array.isArray(json.choices)).toBe(true); + const choice0 = (json.choices as Array>)[0] ?? {}; + const msg = (choice0.message as Record | undefined) ?? {}; + expect(msg.role).toBe("assistant"); + expect(msg.content).toBe("hello"); + } + + { + const res = await postChatCompletions(port, { + model: "clawdbot", + messages: [{ role: "system", content: "yo" }], + }); + expect(res.status).toBe(400); + const missingUserJson = (await res.json()) as Record; + expect((missingUserJson.error as Record | undefined)?.type).toBe( + "invalid_request_error", + ); + } } finally { await server.close({ reason: "test done" }); } }); - it("includes conversation history when multiple messages are provided", async () => { - agentCommand.mockResolvedValueOnce({ - payloads: [{ text: "I am Claude" }], - } as never); - + it("streams SSE chunks when stream=true", async () => { const port = await getFreePort(); const server = await startServer(port); try { - const res = await postChatCompletions(port, { - model: "clawdbot", - messages: [ - { role: "system", content: "You are a helpful assistant." }, - { role: "user", content: "Hello, who are you?" }, - { role: "assistant", content: "I am Claude." }, - { role: "user", content: "What did I just ask you?" }, - ], - }); - expect(res.status).toBe(200); + { + agentCommand.mockReset(); + agentCommand.mockImplementationOnce(async (opts: unknown) => { + const runId = (opts as { runId?: string } | undefined)?.runId ?? ""; + emitAgentEvent({ runId, stream: "assistant", data: { delta: "he" } }); + emitAgentEvent({ runId, stream: "assistant", data: { delta: "llo" } }); + return { payloads: [{ text: "hello" }] } as never; + }); - const [opts] = agentCommand.mock.calls[0] ?? []; - const message = (opts as { message?: string } | undefined)?.message ?? ""; - expect(message).toContain(HISTORY_CONTEXT_MARKER); - expect(message).toContain("User: Hello, who are you?"); - expect(message).toContain("Assistant: I am Claude."); - expect(message).toContain(CURRENT_MESSAGE_MARKER); - expect(message).toContain("User: What did I just ask you?"); - } finally { - await server.close({ reason: "test done" }); - } - }); + const res = await postChatCompletions(port, { + stream: true, + model: "clawdbot", + messages: [{ role: "user", content: "hi" }], + }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type") ?? "").toContain("text/event-stream"); - it("does not include history markers for single message", async () => { - agentCommand.mockResolvedValueOnce({ - payloads: [{ text: "hello" }], - } as never); + const text = await res.text(); + const data = parseSseDataLines(text); + expect(data[data.length - 1]).toBe("[DONE]"); - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postChatCompletions(port, { - model: "clawdbot", - messages: [ - { role: "system", content: "You are a helpful assistant." }, - { role: "user", content: "Hello" }, - ], - }); - expect(res.status).toBe(200); + const jsonChunks = data + .filter((d) => d !== "[DONE]") + .map((d) => JSON.parse(d) as Record); + expect(jsonChunks.some((c) => c.object === "chat.completion.chunk")).toBe(true); + const allContent = jsonChunks + .flatMap((c) => (c.choices as Array> | undefined) ?? []) + .map((choice) => (choice.delta as Record | undefined)?.content) + .filter((v): v is string => typeof v === "string") + .join(""); + expect(allContent).toBe("hello"); + } - const [opts] = agentCommand.mock.calls[0] ?? []; - const message = (opts as { message?: string } | undefined)?.message ?? ""; - expect(message).not.toContain(HISTORY_CONTEXT_MARKER); - expect(message).not.toContain(CURRENT_MESSAGE_MARKER); - expect(message).toBe("Hello"); - } finally { - await server.close({ reason: "test done" }); - } - }); + { + agentCommand.mockReset(); + agentCommand.mockImplementationOnce(async (opts: unknown) => { + const runId = (opts as { runId?: string } | undefined)?.runId ?? ""; + emitAgentEvent({ runId, stream: "assistant", data: { delta: "hi" } }); + emitAgentEvent({ runId, stream: "assistant", data: { delta: "hi" } }); + return { payloads: [{ text: "hihi" }] } as never; + }); - it("treats developer role same as system role", async () => { - agentCommand.mockResolvedValueOnce({ - payloads: [{ text: "hello" }], - } as never); + const repeatedRes = await postChatCompletions(port, { + stream: true, + model: "clawdbot", + messages: [{ role: "user", content: "hi" }], + }); + expect(repeatedRes.status).toBe(200); + const repeatedText = await repeatedRes.text(); + const repeatedData = parseSseDataLines(repeatedText); + const repeatedChunks = repeatedData + .filter((d) => d !== "[DONE]") + .map((d) => JSON.parse(d) as Record); + const repeatedContent = repeatedChunks + .flatMap((c) => (c.choices as Array> | undefined) ?? []) + .map((choice) => (choice.delta as Record | undefined)?.content) + .filter((v): v is string => typeof v === "string") + .join(""); + expect(repeatedContent).toBe("hihi"); + } - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postChatCompletions(port, { - model: "clawdbot", - messages: [ - { role: "developer", content: "You are a helpful assistant." }, - { role: "user", content: "Hello" }, - ], - }); - expect(res.status).toBe(200); + { + agentCommand.mockReset(); + agentCommand.mockResolvedValueOnce({ + payloads: [{ text: "hello" }], + } as never); - const [opts] = agentCommand.mock.calls[0] ?? []; - const extraSystemPrompt = - (opts as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? ""; - expect(extraSystemPrompt).toBe("You are a helpful assistant."); - } finally { - await server.close({ reason: "test done" }); - } - }); - - it("includes tool output when it is the latest message", async () => { - agentCommand.mockResolvedValueOnce({ - payloads: [{ text: "ok" }], - } as never); - - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postChatCompletions(port, { - model: "clawdbot", - messages: [ - { role: "system", content: "You are a helpful assistant." }, - { role: "user", content: "What's the weather?" }, - { role: "assistant", content: "Checking the weather." }, - { role: "tool", content: "Sunny, 70F." }, - ], - }); - expect(res.status).toBe(200); - - const [opts] = agentCommand.mock.calls[0] ?? []; - const message = (opts as { message?: string } | undefined)?.message ?? ""; - expect(message).toContain(HISTORY_CONTEXT_MARKER); - expect(message).toContain("User: What's the weather?"); - expect(message).toContain("Assistant: Checking the weather."); - expect(message).toContain(CURRENT_MESSAGE_MARKER); - expect(message).toContain("Tool: Sunny, 70F."); - } finally { - await server.close({ reason: "test done" }); - } - }); - - it("returns a non-streaming OpenAI chat.completion response", async () => { - agentCommand.mockResolvedValueOnce({ - payloads: [{ text: "hello" }], - } as never); - - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postChatCompletions(port, { - stream: false, - model: "clawdbot", - messages: [{ role: "user", content: "hi" }], - }); - expect(res.status).toBe(200); - const json = (await res.json()) as Record; - expect(json.object).toBe("chat.completion"); - expect(Array.isArray(json.choices)).toBe(true); - const choice0 = (json.choices as Array>)[0] ?? {}; - const msg = (choice0.message as Record | undefined) ?? {}; - expect(msg.role).toBe("assistant"); - expect(msg.content).toBe("hello"); - } finally { - await server.close({ reason: "test done" }); - } - }); - - it("requires a user message", async () => { - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postChatCompletions(port, { - model: "clawdbot", - messages: [{ role: "system", content: "yo" }], - }); - expect(res.status).toBe(400); - const json = (await res.json()) as Record; - expect((json.error as Record | undefined)?.type).toBe( - "invalid_request_error", - ); - } finally { - await server.close({ reason: "test done" }); - } - }); - - it("streams SSE chunks when stream=true (delta events)", async () => { - agentCommand.mockImplementationOnce(async (opts: unknown) => { - const runId = (opts as { runId?: string } | undefined)?.runId ?? ""; - emitAgentEvent({ runId, stream: "assistant", data: { delta: "he" } }); - emitAgentEvent({ runId, stream: "assistant", data: { delta: "llo" } }); - return { payloads: [{ text: "hello" }] } as never; - }); - - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postChatCompletions(port, { - stream: true, - model: "clawdbot", - messages: [{ role: "user", content: "hi" }], - }); - expect(res.status).toBe(200); - expect(res.headers.get("content-type") ?? "").toContain("text/event-stream"); - - const text = await res.text(); - const data = parseSseDataLines(text); - expect(data[data.length - 1]).toBe("[DONE]"); - - const jsonChunks = data - .filter((d) => d !== "[DONE]") - .map((d) => JSON.parse(d) as Record); - expect(jsonChunks.some((c) => c.object === "chat.completion.chunk")).toBe(true); - const allContent = jsonChunks - .flatMap((c) => (c.choices as Array> | undefined) ?? []) - .map((choice) => (choice.delta as Record | undefined)?.content) - .filter((v): v is string => typeof v === "string") - .join(""); - expect(allContent).toBe("hello"); - } finally { - await server.close({ reason: "test done" }); - } - }); - - it("preserves repeated identical deltas when streaming SSE", async () => { - agentCommand.mockImplementationOnce(async (opts: unknown) => { - const runId = (opts as { runId?: string } | undefined)?.runId ?? ""; - emitAgentEvent({ runId, stream: "assistant", data: { delta: "hi" } }); - emitAgentEvent({ runId, stream: "assistant", data: { delta: "hi" } }); - return { payloads: [{ text: "hihi" }] } as never; - }); - - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postChatCompletions(port, { - stream: true, - model: "clawdbot", - messages: [{ role: "user", content: "hi" }], - }); - expect(res.status).toBe(200); - const text = await res.text(); - const data = parseSseDataLines(text); - const jsonChunks = data - .filter((d) => d !== "[DONE]") - .map((d) => JSON.parse(d) as Record); - const allContent = jsonChunks - .flatMap((c) => (c.choices as Array> | undefined) ?? []) - .map((choice) => (choice.delta as Record | undefined)?.content) - .filter((v): v is string => typeof v === "string") - .join(""); - expect(allContent).toBe("hihi"); - } finally { - await server.close({ reason: "test done" }); - } - }); - - it("streams SSE chunks when stream=true (fallback when no deltas)", async () => { - agentCommand.mockResolvedValueOnce({ - payloads: [{ text: "hello" }], - } as never); - - const port = await getFreePort(); - const server = await startServer(port); - try { - const res = await postChatCompletions(port, { - stream: true, - model: "clawdbot", - messages: [{ role: "user", content: "hi" }], - }); - expect(res.status).toBe(200); - const text = await res.text(); - expect(text).toContain("[DONE]"); - expect(text).toContain("hello"); + const fallbackRes = await postChatCompletions(port, { + stream: true, + model: "clawdbot", + messages: [{ role: "user", content: "hi" }], + }); + expect(fallbackRes.status).toBe(200); + const fallbackText = await fallbackRes.text(); + expect(fallbackText).toContain("[DONE]"); + expect(fallbackText).toContain("hello"); + } } finally { await server.close({ reason: "test done" }); } diff --git a/src/gateway/server.config-apply.test.ts b/src/gateway/server.config-apply.test.ts index 7e68e9170..9547c3664 100644 --- a/src/gateway/server.config-apply.test.ts +++ b/src/gateway/server.config-apply.test.ts @@ -1,95 +1,106 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { WebSocket } from "ws"; import { connectOk, + getFreePort, installGatewayTestHooks, onceMessage, - startServerWithClient, + startGatewayServer, } from "./test-helpers.js"; -installGatewayTestHooks(); +installGatewayTestHooks({ scope: "suite" }); -const servers: Array>> = []; +let server: Awaited>; +let port = 0; +let previousToken: string | undefined; -afterEach(async () => { - for (const { server, ws } of servers) { - try { - ws.close(); - await server.close(); - } catch { - /* ignore */ - } - } - servers.length = 0; - await new Promise((resolve) => setTimeout(resolve, 50)); +beforeAll(async () => { + previousToken = process.env.CLAWDBOT_GATEWAY_TOKEN; + delete process.env.CLAWDBOT_GATEWAY_TOKEN; + port = await getFreePort(); + server = await startGatewayServer(port); }); +afterAll(async () => { + await server.close(); + if (previousToken === undefined) delete process.env.CLAWDBOT_GATEWAY_TOKEN; + else process.env.CLAWDBOT_GATEWAY_TOKEN = previousToken; +}); + +const openClient = async () => { + const ws = new WebSocket(`ws://127.0.0.1:${port}`); + await new Promise((resolve) => ws.once("open", resolve)); + await connectOk(ws); + return ws; +}; + describe("gateway config.apply", () => { it("writes config, stores sentinel, and schedules restart", async () => { - const result = await startServerWithClient(); - servers.push(result); - const { ws } = result; - await connectOk(ws); - - const id = "req-1"; - ws.send( - JSON.stringify({ - type: "req", - id, - method: "config.apply", - params: { - raw: '{ "agents": { "list": [{ "id": "main", "workspace": "~/clawd" }] } }', - sessionKey: "agent:main:whatsapp:dm:+15555550123", - restartDelayMs: 0, - }, - }), - ); - const res = await onceMessage<{ ok: boolean; payload?: unknown }>( - ws, - (o) => o.type === "res" && o.id === id, - ); - expect(res.ok).toBe(true); - - // Verify sentinel file was created (restart was scheduled) - const sentinelPath = path.join(os.homedir(), ".clawdbot", "restart-sentinel.json"); - - // Wait for file to be written - await new Promise((resolve) => setTimeout(resolve, 100)); - + const ws = await openClient(); try { - const raw = await fs.readFile(sentinelPath, "utf-8"); - const parsed = JSON.parse(raw) as { payload?: { kind?: string } }; - expect(parsed.payload?.kind).toBe("config-apply"); - } catch { - // File may not exist if signal delivery is mocked, verify response was ok instead + const id = "req-1"; + ws.send( + JSON.stringify({ + type: "req", + id, + method: "config.apply", + params: { + raw: '{ "agents": { "list": [{ "id": "main", "workspace": "~/clawd" }] } }', + sessionKey: "agent:main:whatsapp:dm:+15555550123", + restartDelayMs: 0, + }, + }), + ); + const res = await onceMessage<{ ok: boolean; payload?: unknown }>( + ws, + (o) => o.type === "res" && o.id === id, + ); expect(res.ok).toBe(true); + + // Verify sentinel file was created (restart was scheduled) + const sentinelPath = path.join(os.homedir(), ".clawdbot", "restart-sentinel.json"); + + // Wait for file to be written + await new Promise((resolve) => setTimeout(resolve, 100)); + + try { + const raw = await fs.readFile(sentinelPath, "utf-8"); + const parsed = JSON.parse(raw) as { payload?: { kind?: string } }; + expect(parsed.payload?.kind).toBe("config-apply"); + } catch { + // File may not exist if signal delivery is mocked, verify response was ok instead + expect(res.ok).toBe(true); + } + } finally { + ws.close(); } }); it("rejects invalid raw config", async () => { - const result = await startServerWithClient(); - servers.push(result); - const { ws } = result; - await connectOk(ws); - - const id = "req-2"; - ws.send( - JSON.stringify({ - type: "req", - id, - method: "config.apply", - params: { - raw: "{", - }, - }), - ); - const res = await onceMessage<{ ok: boolean; error?: unknown }>( - ws, - (o) => o.type === "res" && o.id === id, - ); - expect(res.ok).toBe(false); + const ws = await openClient(); + try { + const id = "req-2"; + ws.send( + JSON.stringify({ + type: "req", + id, + method: "config.apply", + params: { + raw: "{", + }, + }), + ); + const res = await onceMessage<{ ok: boolean; error?: unknown }>( + ws, + (o) => o.type === "res" && o.id === id, + ); + expect(res.ok).toBe(false); + } finally { + ws.close(); + } }); }); diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index 7121f7521..18604828a 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -12,7 +12,7 @@ import { waitForSystemEvent, } from "./test-helpers.js"; -installGatewayTestHooks(); +installGatewayTestHooks({ scope: "suite" }); async function yieldToEventLoop() { // Avoid relying on timers (fake timers can leak between tests). diff --git a/src/gateway/server.health.test.ts b/src/gateway/server.health.test.ts index 173e654f9..08d6632a3 100644 --- a/src/gateway/server.health.test.ts +++ b/src/gateway/server.health.test.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import os from "node:os"; import path from "node:path"; -import { describe, expect, test } from "vitest"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { WebSocket } from "ws"; import { emitAgentEvent } from "../infra/agent-events.js"; import { @@ -21,12 +21,35 @@ import { } from "./test-helpers.js"; import { buildDeviceAuthPayload } from "./device-auth.js"; -installGatewayTestHooks(); +installGatewayTestHooks({ scope: "suite" }); + +let server: Awaited>; +let port = 0; +let previousToken: string | undefined; + +beforeAll(async () => { + previousToken = process.env.CLAWDBOT_GATEWAY_TOKEN; + delete process.env.CLAWDBOT_GATEWAY_TOKEN; + port = await getFreePort(); + server = await startGatewayServer(port); +}); + +afterAll(async () => { + await server.close(); + if (previousToken === undefined) delete process.env.CLAWDBOT_GATEWAY_TOKEN; + else process.env.CLAWDBOT_GATEWAY_TOKEN = previousToken; +}); + +const openClient = async (opts?: Parameters[1]) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}`); + await new Promise((resolve) => ws.once("open", resolve)); + await connectOk(ws, opts); + return ws; +}; describe("gateway server health/presence", () => { test("connect + health + presence + status succeed", { timeout: 60_000 }, async () => { - const { server, ws } = await startServerWithClient(); - await connectOk(ws); + const ws = await openClient(); const healthP = onceMessage(ws, (o) => o.type === "res" && o.id === "health1"); const statusP = onceMessage(ws, (o) => o.type === "res" && o.id === "status1"); @@ -51,7 +74,6 @@ describe("gateway server health/presence", () => { expect(Array.isArray(presence.payload)).toBe(true); ws.close(); - await server.close(); }); test("broadcasts heartbeat events and serves last-heartbeat", async () => { @@ -76,8 +98,7 @@ describe("gateway server health/presence", () => { payload?: unknown; }; - const { server, ws } = await startServerWithClient(); - await connectOk(ws); + const ws = await openClient(); const waitHeartbeat = onceMessage( ws, @@ -117,12 +138,10 @@ describe("gateway server health/presence", () => { expect((toggle.payload as { enabled?: boolean } | undefined)?.enabled).toBe(false); ws.close(); - await server.close(); }); test("presence events carry seq + stateVersion", { timeout: 8000 }, async () => { - const { server, ws } = await startServerWithClient(); - await connectOk(ws); + const ws = await openClient(); const presenceEventP = onceMessage(ws, (o) => o.type === "event" && o.event === "presence"); ws.send( @@ -140,12 +159,10 @@ describe("gateway server health/presence", () => { expect(Array.isArray(evt.payload?.presence)).toBe(true); ws.close(); - await server.close(); }); test("agent events stream with seq", { timeout: 8000 }, async () => { - const { server, ws } = await startServerWithClient(); - await connectOk(ws); + const ws = await openClient(); const runId = randomUUID(); const evtPromise = onceMessage( @@ -163,7 +180,6 @@ describe("gateway server health/presence", () => { expect(evt.payload.data.msg).toBe("hi"); ws.close(); - await server.close(); }); test("shutdown event is broadcast on close", { timeout: 8000 }, async () => { @@ -177,16 +193,7 @@ describe("gateway server health/presence", () => { }); test("presence broadcast reaches multiple clients", { timeout: 8000 }, async () => { - const port = await getFreePort(); - const server = await startGatewayServer(port); - const mkClient = async () => { - const c = new WebSocket(`ws://127.0.0.1:${port}`); - await new Promise((resolve) => c.once("open", resolve)); - await connectOk(c); - return c; - }; - - const clients = await Promise.all([mkClient(), mkClient(), mkClient()]); + const clients = await Promise.all([openClient(), openClient(), openClient()]); const waits = clients.map((c) => onceMessage(c, (o) => o.type === "event" && o.event === "presence"), ); @@ -204,7 +211,6 @@ describe("gateway server health/presence", () => { expect(typeof evt.seq).toBe("number"); } for (const c of clients) c.close(); - await server.close(); }); test("presence includes client fingerprint", async () => { @@ -222,8 +228,7 @@ describe("gateway server health/presence", () => { signedAtMs, token: null, }); - const { server, ws } = await startServerWithClient(); - await connectOk(ws, { + const ws = await openClient({ role, scopes, client: { @@ -264,13 +269,11 @@ describe("gateway server health/presence", () => { expect(clientEntry?.modelIdentifier).toBe("iPad16,6"); ws.close(); - await server.close(); }); test("cli connections are not tracked as instances", async () => { - const { server, ws } = await startServerWithClient(); const cliId = `cli-${randomUUID()}`; - await connectOk(ws, { + const ws = await openClient({ client: { id: GATEWAY_CLIENT_NAMES.CLI, version: "dev", @@ -294,6 +297,5 @@ describe("gateway server health/presence", () => { expect(entries.some((e) => e.instanceId === cliId)).toBe(false); ws.close(); - await server.close(); }); }); diff --git a/src/gateway/server.ios-client-id.test.ts b/src/gateway/server.ios-client-id.test.ts index 64f87abcd..17156dbaf 100644 --- a/src/gateway/server.ios-client-id.test.ts +++ b/src/gateway/server.ios-client-id.test.ts @@ -1,9 +1,21 @@ -import { test } from "vitest"; +import { afterAll, beforeAll, test } from "vitest"; import WebSocket from "ws"; import { PROTOCOL_VERSION } from "./protocol/index.js"; import { getFreePort, onceMessage, startGatewayServer } from "./test-helpers.server.js"; +let server: Awaited>; +let port = 0; + +beforeAll(async () => { + port = await getFreePort(); + server = await startGatewayServer(port); +}); + +afterAll(async () => { + await server.close(); +}); + function connectReq( ws: WebSocket, params: { clientId: string; platform: string; token?: string; password?: string }, @@ -43,8 +55,6 @@ function connectReq( } test("accepts clawdbot-ios as a valid gateway client id", async () => { - const port = await getFreePort(); - const server = await startGatewayServer(port); const ws = new WebSocket(`ws://127.0.0.1:${port}`); await new Promise((resolve) => ws.once("open", resolve)); @@ -61,12 +71,9 @@ test("accepts clawdbot-ios as a valid gateway client id", async () => { } ws.close(); - await server.close(); }); test("accepts clawdbot-android as a valid gateway client id", async () => { - const port = await getFreePort(); - const server = await startGatewayServer(port); const ws = new WebSocket(`ws://127.0.0.1:${port}`); await new Promise((resolve) => ws.once("open", resolve)); @@ -83,5 +90,4 @@ test("accepts clawdbot-android as a valid gateway client id", async () => { } ws.close(); - await server.close(); }); diff --git a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts index 95e91ef50..26a46ac30 100644 --- a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts +++ b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts @@ -1,14 +1,16 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; +import { WebSocket } from "ws"; import { connectOk, embeddedRunMock, + getFreePort, installGatewayTestHooks, piSdkMock, rpcReq, - startServerWithClient, + startGatewayServer, testState, writeSessionStore, } from "./test-helpers.js"; @@ -39,7 +41,31 @@ vi.mock("../auto-reply/reply/abort.js", async () => { }; }); -installGatewayTestHooks(); +installGatewayTestHooks({ scope: "suite" }); + +let server: Awaited>; +let port = 0; +let previousToken: string | undefined; + +beforeAll(async () => { + previousToken = process.env.CLAWDBOT_GATEWAY_TOKEN; + delete process.env.CLAWDBOT_GATEWAY_TOKEN; + port = await getFreePort(); + server = await startGatewayServer(port); +}); + +afterAll(async () => { + await server.close(); + if (previousToken === undefined) delete process.env.CLAWDBOT_GATEWAY_TOKEN; + else process.env.CLAWDBOT_GATEWAY_TOKEN = previousToken; +}); + +const openClient = async (opts?: Parameters[1]) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}`); + await new Promise((resolve) => ws.once("open", resolve)); + const hello = await connectOk(ws, opts); + return { ws, hello }; +}; describe("gateway server sessions", () => { beforeEach(() => { @@ -98,8 +124,7 @@ describe("gateway server sessions", () => { }, }); - const { server, ws } = await startServerWithClient(); - const hello = await connectOk(ws); + const { ws, hello } = await openClient(); expect((hello as unknown as { features?: { methods?: string[] } }).features?.methods).toEqual( expect.arrayContaining([ "sessions.list", @@ -336,7 +361,6 @@ describe("gateway server sessions", () => { ); ws.close(); - await server.close(); }); test("sessions.preview returns transcript previews", async () => { @@ -365,8 +389,7 @@ describe("gateway server sessions", () => { }, }); - const { server, ws } = await startServerWithClient(); - await connectOk(ws); + const { ws } = await openClient(); const preview = await rpcReq<{ previews: Array<{ key: string; @@ -383,7 +406,6 @@ describe("gateway server sessions", () => { expect(entry?.items[1]?.text).toContain("call weather"); ws.close(); - await server.close(); }); test("sessions.delete rejects main and aborts active runs", async () => { @@ -415,8 +437,7 @@ describe("gateway server sessions", () => { embeddedRunMock.activeIds.add("sess-active"); embeddedRunMock.waitResults.set("sess-active", true); - const { server, ws } = await startServerWithClient(); - await connectOk(ws); + const { ws } = await openClient(); const mainDelete = await rpcReq(ws, "sessions.delete", { key: "main" }); expect(mainDelete.ok).toBe(false); @@ -439,6 +460,5 @@ describe("gateway server sessions", () => { expect(embeddedRunMock.waitCalls).toEqual(["sess-active"]); ws.close(); - await server.close(); }); }); diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index c72683824..b6e89486d 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -3,7 +3,7 @@ import { type AddressInfo, createServer } from "node:net"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, expect, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, expect, vi } from "vitest"; import { WebSocket } from "ws"; import { resolveMainSessionKeyFromConfig, type SessionEntry } from "../config/sessions.js"; @@ -75,67 +75,79 @@ export async function writeSessionStore(params: { await fs.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8"); } -export function installGatewayTestHooks() { - beforeEach(async () => { - // Some tests intentionally use fake timers; ensure they don't leak into gateway suites. - vi.useRealTimers(); - setLoggerOverride({ level: "silent", consoleLevel: "silent" }); - previousHome = process.env.HOME; - previousUserProfile = process.env.USERPROFILE; - previousStateDir = process.env.CLAWDBOT_STATE_DIR; - previousConfigPath = process.env.CLAWDBOT_CONFIG_PATH; - previousSkipBrowserControl = process.env.CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER; - previousSkipGmailWatcher = process.env.CLAWDBOT_SKIP_GMAIL_WATCHER; - previousSkipCanvasHost = process.env.CLAWDBOT_SKIP_CANVAS_HOST; - tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gateway-home-")); - process.env.HOME = tempHome; - process.env.USERPROFILE = tempHome; - process.env.CLAWDBOT_STATE_DIR = path.join(tempHome, ".clawdbot"); - delete process.env.CLAWDBOT_CONFIG_PATH; - process.env.CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER = "1"; - process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1"; - process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1"; - tempConfigRoot = path.join(tempHome, ".clawdbot-test"); - setTestConfigRoot(tempConfigRoot); - sessionStoreSaveDelayMs.value = 0; - testTailnetIPv4.value = undefined; - testState.gatewayBind = undefined; - testState.gatewayAuth = undefined; - testState.gatewayControlUi = undefined; - testState.hooksConfig = undefined; - testState.canvasHostPort = undefined; - testState.legacyIssues = []; - testState.legacyParsed = {}; - testState.migrationConfig = null; - testState.migrationChanges = []; - testState.cronEnabled = false; - testState.cronStorePath = undefined; - testState.sessionConfig = undefined; - testState.sessionStorePath = undefined; - testState.agentConfig = undefined; - testState.agentsConfig = undefined; - testState.bindingsConfig = undefined; - testState.channelsConfig = undefined; - testState.allowFrom = undefined; - testIsNixMode.value = false; - cronIsolatedRun.mockClear(); - agentCommand.mockClear(); - embeddedRunMock.activeIds.clear(); - embeddedRunMock.abortCalls = []; - embeddedRunMock.waitCalls = []; - embeddedRunMock.waitResults.clear(); - drainSystemEvents(resolveMainSessionKeyFromConfig()); - resetAgentRunContextForTest(); - const mod = await serverModulePromise; - mod.__resetModelCatalogCacheForTest(); - piSdkMock.enabled = false; - piSdkMock.discoverCalls = 0; - piSdkMock.models = []; - }, 60_000); +async function setupGatewayTestHome() { + previousHome = process.env.HOME; + previousUserProfile = process.env.USERPROFILE; + previousStateDir = process.env.CLAWDBOT_STATE_DIR; + previousConfigPath = process.env.CLAWDBOT_CONFIG_PATH; + previousSkipBrowserControl = process.env.CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER; + previousSkipGmailWatcher = process.env.CLAWDBOT_SKIP_GMAIL_WATCHER; + previousSkipCanvasHost = process.env.CLAWDBOT_SKIP_CANVAS_HOST; + tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gateway-home-")); + process.env.HOME = tempHome; + process.env.USERPROFILE = tempHome; + process.env.CLAWDBOT_STATE_DIR = path.join(tempHome, ".clawdbot"); + delete process.env.CLAWDBOT_CONFIG_PATH; +} - afterEach(async () => { - vi.useRealTimers(); - resetLogger(); +function applyGatewaySkipEnv() { + process.env.CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER = "1"; + process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1"; + process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1"; +} + +async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) { + // Some tests intentionally use fake timers; ensure they don't leak into gateway suites. + vi.useRealTimers(); + setLoggerOverride({ level: "silent", consoleLevel: "silent" }); + if (!tempHome) { + throw new Error("resetGatewayTestState called before temp home was initialized"); + } + applyGatewaySkipEnv(); + tempConfigRoot = options.uniqueConfigRoot + ? await fs.mkdtemp(path.join(tempHome, "clawdbot-test-")) + : path.join(tempHome, ".clawdbot-test"); + setTestConfigRoot(tempConfigRoot); + sessionStoreSaveDelayMs.value = 0; + testTailnetIPv4.value = undefined; + testState.gatewayBind = undefined; + testState.gatewayAuth = undefined; + testState.gatewayControlUi = undefined; + testState.hooksConfig = undefined; + testState.canvasHostPort = undefined; + testState.legacyIssues = []; + testState.legacyParsed = {}; + testState.migrationConfig = null; + testState.migrationChanges = []; + testState.cronEnabled = false; + testState.cronStorePath = undefined; + testState.sessionConfig = undefined; + testState.sessionStorePath = undefined; + testState.agentConfig = undefined; + testState.agentsConfig = undefined; + testState.bindingsConfig = undefined; + testState.channelsConfig = undefined; + testState.allowFrom = undefined; + testIsNixMode.value = false; + cronIsolatedRun.mockClear(); + agentCommand.mockClear(); + embeddedRunMock.activeIds.clear(); + embeddedRunMock.abortCalls = []; + embeddedRunMock.waitCalls = []; + embeddedRunMock.waitResults.clear(); + drainSystemEvents(resolveMainSessionKeyFromConfig()); + resetAgentRunContextForTest(); + const mod = await serverModulePromise; + mod.__resetModelCatalogCacheForTest(); + piSdkMock.enabled = false; + piSdkMock.discoverCalls = 0; + piSdkMock.models = []; +} + +async function cleanupGatewayTestHome(options: { restoreEnv: boolean }) { + vi.useRealTimers(); + resetLogger(); + if (options.restoreEnv) { if (previousHome === undefined) delete process.env.HOME; else process.env.HOME = previousHome; if (previousUserProfile === undefined) delete process.env.USERPROFILE; @@ -151,16 +163,45 @@ export function installGatewayTestHooks() { else process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = previousSkipGmailWatcher; if (previousSkipCanvasHost === undefined) delete process.env.CLAWDBOT_SKIP_CANVAS_HOST; else process.env.CLAWDBOT_SKIP_CANVAS_HOST = previousSkipCanvasHost; - if (tempHome) { - await fs.rm(tempHome, { - recursive: true, - force: true, - maxRetries: 20, - retryDelay: 25, - }); - tempHome = undefined; - } - tempConfigRoot = undefined; + } + if (options.restoreEnv && tempHome) { + await fs.rm(tempHome, { + recursive: true, + force: true, + maxRetries: 20, + retryDelay: 25, + }); + tempHome = undefined; + } + tempConfigRoot = undefined; +} + +export function installGatewayTestHooks(options?: { scope?: "test" | "suite" }) { + const scope = options?.scope ?? "test"; + if (scope === "suite") { + beforeAll(async () => { + await setupGatewayTestHome(); + await resetGatewayTestState({ uniqueConfigRoot: true }); + }); + beforeEach(async () => { + await resetGatewayTestState({ uniqueConfigRoot: true }); + }, 60_000); + afterEach(async () => { + await cleanupGatewayTestHome({ restoreEnv: false }); + }); + afterAll(async () => { + await cleanupGatewayTestHome({ restoreEnv: true }); + }); + return; + } + + beforeEach(async () => { + await setupGatewayTestHome(); + await resetGatewayTestState({ uniqueConfigRoot: false }); + }, 60_000); + + afterEach(async () => { + await cleanupGatewayTestHome({ restoreEnv: true }); }); }