From c7c047eb1fa3617c8ad1d7545cde3bb8fd8ee899 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 17 Sep 2026 23:33:15 +0900 Subject: [PATCH] test(combos): hold the zero-output bare-error case in a sibling file The file-size ratchet fails on dev: tests/server/server-combo-failover-e2e.test.ts is 4207 lines against a cap of 4166. Neither contributing change was over the cap on its own branch. #4824 took the file from 4100 to 4153 and #4817 added 54 lines that computed to 4154 against the pre-#4824 file, so both were honestly green; the sum only crossed the cap once both were on dev. updateBaseline() stores Math.min(cap, lines), so the tool lowers a cap and never raises one. A GREW offence cannot be cleared by regenerating the baseline, and raising the number by hand is the one move the ratchet exists to prevent. Move the newest case into a sibling file instead, as d3ca5522db did for the same file. The test body is moved verbatim. The new file carries only the part of the parent fixture this case uses: loopback upstreams, an isolated home, and the combo and request-log state that leaks between tests. It mocks no module, because this case drives the real openai-responses adapter. tests/server/server-combo-failover-e2e.test.ts returns to 4153 lines and the repository scan reports no offender. Co-authored-by: 404Unkown <52745108+87003697@users.noreply.github.com> Co-authored-by: agentHits <140916359+agentHits@users.noreply.github.com> --- scripts/test-layout/layout.json | 1 + tests/fixtures/test-layout-expected.json | 1 + .../server/server-combo-failover-e2e.test.ts | 54 ------ .../server-combo-zero-output-failover.test.ts | 182 ++++++++++++++++++ 4 files changed, 184 insertions(+), 54 deletions(-) create mode 100644 tests/server/server-combo-zero-output-failover.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index e9e550717d..f2b5e4d9a6 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1257,6 +1257,7 @@ "server-clickjacking-headers.test.ts": "server", "server-combo-failover-e2e.test.ts": "server", "server-combo-reasoning-replay-eligibility.test.ts": "server", + "server-combo-zero-output-failover.test.ts": "server", "server-google-antigravity-oauth-401-replay.test.ts": "server", "server-images-bodyless-content-length.test.ts": "server", "server-images.test.ts": "server", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d0a98dd762..37d760d6d6 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1085,6 +1085,7 @@ "server-clickjacking-headers.test.ts": "server", "server-combo-failover-e2e.test.ts": "server", "server-combo-reasoning-replay-eligibility.test.ts": "server", + "server-combo-zero-output-failover.test.ts": "server", "server-google-antigravity-oauth-401-replay.test.ts": "server", "server-images-bodyless-content-length.test.ts": "server", "server-images.test.ts": "server", diff --git a/tests/server/server-combo-failover-e2e.test.ts b/tests/server/server-combo-failover-e2e.test.ts index 17684c9317..a47a5b83dd 100644 --- a/tests/server/server-combo-failover-e2e.test.ts +++ b/tests/server/server-combo-failover-e2e.test.ts @@ -844,60 +844,6 @@ describe("server combo failover 030 activation matrix", () => { } }); - test("zero-output bare Responses SSE error hops before committing the child stream", async () => { - const hits: string[] = []; - const a = serve(() => { - hits.push("a"); - return new Response([ - "event: response.created", - `data: ${JSON.stringify({ type: "response.created", response: { id: "r1", status: "in_progress" } })}`, - "", - "event: error", - `data: ${JSON.stringify({ - type: "error", - message: "An error occurred while processing your request. Please include request ID r1.", - })}`, - "", - "", - ].join("\n"), { headers: { "content-type": "text/event-stream" } }); - }); - const b = serve(() => { - hits.push("b"); - return new Response([ - "event: response.completed", - `data: ${JSON.stringify({ - type: "response.completed", - response: { ...responsesSuccess("bare-error backup", "m2"), status: "completed" }, - })}`, - "", - "", - ].join("\n"), { headers: { "content-type": "text/event-stream" } }); - }); - const config = comboConfig({ - a: provider("openai-responses", baseUrl(a), "key-a"), - b: provider("openai-responses", baseUrl(b), "key-b"), - }); - - const parent: RequestLogContext = { model: "", provider: "" }; - const response = await handleResponses(new Request("http://localhost/v1/responses", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ model: "combo/free", input: "hello", stream: true }), - }), config, parent); - expect(response.status).toBe(200); - expect(await response.text()).toContain("bare-error backup"); - expect(hits).toEqual(["a", "b"]); - expect(parent).toMatchObject({ - provider: "combo", - model: "combo/free", - resolvedModel: "m2", - attempts: [ - { ordinal: 1, provider: "a", model: "m1", status: 502 }, - { ordinal: 2, provider: "b", model: "m2" }, - ], - }); - }); - test("zero-output adapter EOF hops to the next combo target", async () => { const hits: string[] = []; const a = serve(() => { diff --git a/tests/server/server-combo-zero-output-failover.test.ts b/tests/server/server-combo-zero-output-failover.test.ts new file mode 100644 index 0000000000..88adaf522d --- /dev/null +++ b/tests/server/server-combo-zero-output-failover.test.ts @@ -0,0 +1,182 @@ +import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ManagementRequest as Request } from "../helpers/management-auth"; +import { comboProviderFactory } from "../helpers/combo-provider"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; +import { clearComboRecallForTests } from "../../src/server/responses/combo-session-recall"; +import { clearKeyCooldowns } from "../../src/providers/key-failover"; +import { clearCodexUpstreamHealth } from "../../src/codex/routing"; +import { clearRequestLogsForTests, type RequestLogContext } from "../../src/server/request-log"; +import { + clearResponseStateForTests, + flushResponseState, + responseStatePersistPendingForTests, +} from "../../src/responses/state"; +import { handleResponses } from "../../src/server/responses"; +import type { OcxConfig } from "../../src/types"; + +/** + * Zero-output combo failover driven by a bare Responses SSE `error` event. + * + * This case was written in `server-combo-failover-e2e.test.ts` and moved here unchanged. + * That file carries a file-size-ratchet cap, and two separately passing pull requests + * (#4824 and #4817) grew it past that cap once both were on `dev`. The ratchet only ever + * lowers a cap, so the way back under it is to hold new cases in a sibling file rather + * than to raise the number. + * + * The harness below is the subset of that file's fixture this case actually uses: real + * loopback upstreams, an isolated home, and the combo/request-log state that leaks + * between tests. No module is mocked here, because this case drives the real + * `openai-responses` adapter. + */ + +// The parent file raises this for the same reason: a real loopback server plus combo +// failover exceeds the 5s default under full-suite load on Windows. +setDefaultTimeout(30_000); + +let testDir = ""; +let previousHome: string | undefined; +let isolatedCodexHome: IsolatedCodexHome | null = null; +const servers: Array> = []; +const provider = comboProviderFactory(() => undefined); + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + isolatedCodexHome = installIsolatedCodexHome("ocx-combo-zero-output-codex-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-combo-zero-output-")); + process.env.OPENCODEX_HOME = testDir; + clearComboSelectionState(); + clearComboRecallForTests(); + clearComboTargetCooldowns(); + clearKeyCooldowns(); + clearCodexUpstreamHealth(); + clearRequestLogsForTests(); + clearResponseStateForTests(); +}); + +afterEach(async () => { + let responseStatePending = true; + try { + for (const server of servers.splice(0)) await server.stop(true); + await flushResponseState(); + responseStatePending = responseStatePersistPendingForTests(); + } finally { + clearResponseStateForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) removeTreeWithRetry(testDir); + clearComboSelectionState(); + clearComboRecallForTests(); + clearComboTargetCooldowns(); + clearKeyCooldowns(); + clearCodexUpstreamHealth(); + clearRequestLogsForTests(); + } + expect(responseStatePending).toBe(false); +}); + +/** Loopback upstream whose lifetime the afterEach owns. */ +function serve(handler: (request: Request) => Response | Promise) { + const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: handler }); + servers.push(server); + return server; +} + +/** Provider base URL for a fixture server, without the trailing slash. */ +function baseUrl(server: ReturnType): string { + return `${server.url.toString().replace(/\/$/, "")}/v1`; +} + +/** Minimal completed Responses payload the backup target answers with. */ +function responsesSuccess(text: string, model = "responses-model"): Record { + return { + id: `resp-${model}`, + object: "response", + status: "completed", + model, + output: [{ + id: "msg_backup", + type: "message", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text, annotations: [] }], + }], + usage: { input_tokens: 2, output_tokens: 1, total_tokens: 3 }, + }; +} + +/** Failover combo over the supplied providers, one target per provider in order. */ +function comboConfig( + providers: OcxConfig["providers"], + targets = Object.keys(providers).map((name, index) => ({ provider: name, model: `m${index + 1}` })), + extra: Partial[string]> = {}, +): OcxConfig { + return { + port: 0, + defaultProvider: Object.keys(providers)[0]!, + providers, + combos: { free: { strategy: "failover", targets, ...extra } }, + }; +} + +describe("combo zero-output bare Responses error failover", () => { + test("zero-output bare Responses SSE error hops before committing the child stream", async () => { + const hits: string[] = []; + const a = serve(() => { + hits.push("a"); + return new Response([ + "event: response.created", + `data: ${JSON.stringify({ type: "response.created", response: { id: "r1", status: "in_progress" } })}`, + "", + "event: error", + `data: ${JSON.stringify({ + type: "error", + message: "An error occurred while processing your request. Please include request ID r1.", + })}`, + "", + "", + ].join("\n"), { headers: { "content-type": "text/event-stream" } }); + }); + const b = serve(() => { + hits.push("b"); + return new Response([ + "event: response.completed", + `data: ${JSON.stringify({ + type: "response.completed", + response: { ...responsesSuccess("bare-error backup", "m2"), status: "completed" }, + })}`, + "", + "", + ].join("\n"), { headers: { "content-type": "text/event-stream" } }); + }); + const config = comboConfig({ + a: provider("openai-responses", baseUrl(a), "key-a"), + b: provider("openai-responses", baseUrl(b), "key-b"), + }); + + const parent: RequestLogContext = { model: "", provider: "" }; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "combo/free", input: "hello", stream: true }), + }), config, parent); + expect(response.status).toBe(200); + expect(await response.text()).toContain("bare-error backup"); + expect(hits).toEqual(["a", "b"]); + expect(parent).toMatchObject({ + provider: "combo", + model: "combo/free", + resolvedModel: "m2", + attempts: [ + { ordinal: 1, provider: "a", model: "m1", status: 502 }, + { ordinal: 2, provider: "b", model: "m2" }, + ], + }); + }); +});