Files
ccs/src/cliproxy/executor/__tests__/proxy-chain-builder.test.ts
T
Tam Nhu Tran 8b39d8a25f refactor(cliproxy/executor): extract proxy-chain-builder from index.ts
Phase 07 of #1162. Splits tool-sanitization + codex-reasoning proxy spawn
out of the orchestrator:

- src/cliproxy/executor/proxy-chain-builder.ts (185 LOC):
  buildProxyChain + ProxyChainContext / ProxyChainResult types.
  DI escape hatch (_ToolSanitizationProxy / _CodexReasoningProxy) for
  testability without refactoring the proxy classes (Bun module cache
  blocks mock.module of already-loaded modules).
- 9 unit tests covering codex-only, tool-san only, both-together,
  spawn failure swallowing, env propagation.

index.ts: 716 -> 665 LOC (-51). HTTPS tunnel kept inline because
tunnelPort is needed by image-analysis resolution before first-pass
buildClaudeEnvironment — folding it in would also require moving image
analysis. Two-pass buildClaudeEnvironment dance preserved exactly.
Behavior unchanged; full suite passes 1824/1824.

Refs #1162
2026-05-02 22:32:37 -04:00

274 lines
10 KiB
TypeScript

/**
* Unit tests for proxy-chain-builder.ts (Phase 07)
*
* Mocking strategy: proxy constructors are injected via the _ToolSanitizationProxy
* and _CodexReasoningProxy fields on ProxyChainContext (dependency-injection escape
* hatch added for testability). This avoids Bun module-cache limitations with
* mock.module / jest.mock and never spins up real HTTP servers.
*
* Tests cover:
* - Tool sanitization proxy started when ANTHROPIC_BASE_URL is set
* - Tool sanitization proxy skipped when ANTHROPIC_BASE_URL is absent
* - Tool sanitization proxy start failure swallowed (null returned, verbose warn)
* - Codex reasoning proxy started for single-provider codex
* - Codex reasoning proxy skipped for composite codex (cfg.isComposite true)
* - Codex reasoning proxy skipped for non-codex provider
* - Codex reasoning proxy uses post-sanitization URL when tool-san is active
* - Codex reasoning proxy start failure swallowed (null returned)
* - All results null when ANTHROPIC_BASE_URL absent
*/
import { describe, expect, it, jest, beforeEach } from 'bun:test';
import { buildProxyChain } from '../proxy-chain-builder';
import type { ProxyChainContext } from '../proxy-chain-builder';
import type { ToolSanitizationProxy } from '../../proxy/tool-sanitization-proxy';
import type { CodexReasoningProxy } from '../../ai-providers/codex-reasoning-proxy';
// ── Stub factory ──────────────────────────────────────────────────────────────
type ProxyStub = { start: jest.Mock; stop: jest.Mock; _port?: number };
function makeStubCtor(resolvePort: number | (() => Promise<number> | number)): {
ctor: jest.Mock;
instance: ProxyStub;
} {
const instance: ProxyStub = {
start: jest
.fn()
.mockImplementation(
typeof resolvePort === 'function' ? resolvePort : () => Promise.resolve(resolvePort)
),
stop: jest.fn(),
};
const ctor = jest.fn().mockReturnValue(instance);
return { ctor, instance };
}
function makeFailCtor(message: string): { ctor: jest.Mock; instance: ProxyStub } {
const instance: ProxyStub = {
start: jest.fn().mockRejectedValue(new Error(message)),
stop: jest.fn(),
};
const ctor = jest.fn().mockReturnValue(instance);
return { ctor, instance };
}
// ── Context helpers ───────────────────────────────────────────────────────────
function makeThinkingCfg() {
return { mode: 'auto' as const, override: undefined };
}
function makeProxyConfig() {
return {
mode: 'local' as const,
port: 8317,
protocol: 'http' as const,
fallbackEnabled: false,
autoStartLocal: true,
remoteOnly: false,
forceLocal: false,
};
}
function makeCfg(overrides: Partial<{ isComposite: boolean }> = {}) {
return { port: 8317, timeout: 5000, verbose: false, pollInterval: 100, ...overrides };
}
// Stub ctors shared across tests, reset in beforeEach
let defaultToolSan: ReturnType<typeof makeStubCtor>;
let defaultCodex: ReturnType<typeof makeStubCtor>;
beforeEach(() => {
defaultToolSan = makeStubCtor(19001);
defaultCodex = makeStubCtor(19002);
});
function baseCtx(overrides: Partial<ProxyChainContext> = {}): ProxyChainContext {
return {
provider: 'gemini',
useRemoteProxy: false,
proxyConfig: makeProxyConfig(),
cfg: makeCfg(),
initialEnvVars: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317' },
thinkingOverride: undefined,
thinkingCfg: makeThinkingCfg(),
verbose: false,
log: () => {},
_ToolSanitizationProxy: defaultToolSan.ctor as unknown as new (
cfg: object
) => ToolSanitizationProxy,
_CodexReasoningProxy: defaultCodex.ctor as unknown as new (cfg: object) => CodexReasoningProxy,
...overrides,
};
}
// ── Tool sanitization: started when ANTHROPIC_BASE_URL set ───────────────────
describe('buildProxyChain — tool sanitization: started when ANTHROPIC_BASE_URL set', () => {
it('starts proxy and returns instance + port', async () => {
const { ctor, instance } = makeStubCtor(13001);
const result = await buildProxyChain(baseCtx({ _ToolSanitizationProxy: ctor as never }));
expect(ctor).toHaveBeenCalledTimes(1);
expect(result.toolSanitizationProxy).toBe(instance);
expect(result.toolSanitizationPort).toBe(13001);
});
});
// ── Tool sanitization: skipped when ANTHROPIC_BASE_URL absent ────────────────
describe('buildProxyChain — tool sanitization: skipped when no ANTHROPIC_BASE_URL', () => {
it('returns null proxy and port without constructing', async () => {
const { ctor } = makeStubCtor(13001);
const result = await buildProxyChain(
baseCtx({ initialEnvVars: {}, _ToolSanitizationProxy: ctor as never })
);
expect(ctor).not.toHaveBeenCalled();
expect(result.toolSanitizationProxy).toBeNull();
expect(result.toolSanitizationPort).toBeNull();
});
});
// ── Tool sanitization: start failure swallowed ────────────────────────────────
describe('buildProxyChain — tool sanitization: start failure swallowed', () => {
it('returns null and emits verbose warn without throwing', async () => {
const { ctor } = makeFailCtor('port in use');
const warnSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const result = await buildProxyChain(
baseCtx({ verbose: true, _ToolSanitizationProxy: ctor as never })
);
expect(result.toolSanitizationProxy).toBeNull();
expect(result.toolSanitizationPort).toBeNull();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('port in use'));
warnSpy.mockRestore();
});
});
// ── Codex reasoning: started for single-provider codex ───────────────────────
describe('buildProxyChain — codex reasoning: started for single-provider codex', () => {
it('starts codex proxy and returns instance + port', async () => {
const sanCtorPair = makeStubCtor(13010);
const codexCtorPair = makeStubCtor(13011);
const result = await buildProxyChain(
baseCtx({
provider: 'codex',
cfg: makeCfg({ isComposite: false }),
_ToolSanitizationProxy: sanCtorPair.ctor as never,
_CodexReasoningProxy: codexCtorPair.ctor as never,
})
);
expect(codexCtorPair.ctor).toHaveBeenCalledTimes(1);
expect(result.codexReasoningProxy).toBe(codexCtorPair.instance);
expect(result.codexReasoningPort).toBe(13011);
});
});
// ── Codex reasoning: skipped for composite codex ─────────────────────────────
describe('buildProxyChain — codex reasoning: skipped for composite codex', () => {
it('does not construct CodexReasoningProxy when cfg.isComposite is true', async () => {
const { ctor } = makeStubCtor(13020);
const result = await buildProxyChain(
baseCtx({
provider: 'codex',
cfg: makeCfg({ isComposite: true }),
_CodexReasoningProxy: ctor as never,
})
);
expect(ctor).not.toHaveBeenCalled();
expect(result.codexReasoningProxy).toBeNull();
expect(result.codexReasoningPort).toBeNull();
});
});
// ── Codex reasoning: skipped for non-codex provider ──────────────────────────
describe('buildProxyChain — codex reasoning: skipped for non-codex provider', () => {
it('does not construct CodexReasoningProxy for gemini', async () => {
const { ctor } = makeStubCtor(13030);
await buildProxyChain(baseCtx({ provider: 'gemini', _CodexReasoningProxy: ctor as never }));
expect(ctor).not.toHaveBeenCalled();
});
});
// ── Codex reasoning: uses post-sanitization URL ───────────────────────────────
describe('buildProxyChain — codex reasoning: uses post-sanitization base URL', () => {
it('passes http://127.0.0.1:<sanPort> as upstreamBaseUrl', async () => {
const sanCtorPair = makeStubCtor(13040);
const codexCtorPair = makeStubCtor(13041);
await buildProxyChain(
baseCtx({
provider: 'codex',
cfg: makeCfg({ isComposite: false }),
_ToolSanitizationProxy: sanCtorPair.ctor as never,
_CodexReasoningProxy: codexCtorPair.ctor as never,
})
);
expect(codexCtorPair.ctor).toHaveBeenCalledWith(
expect.objectContaining({ upstreamBaseUrl: 'http://127.0.0.1:13040' })
);
});
});
// ── Codex reasoning: start failure swallowed ─────────────────────────────────
describe('buildProxyChain — codex reasoning: start failure swallowed', () => {
it('returns null codex proxy/port without throwing', async () => {
const sanCtorPair = makeStubCtor(13050);
const { ctor: codexFailCtor } = makeFailCtor('bind failed');
const result = await buildProxyChain(
baseCtx({
provider: 'codex',
cfg: makeCfg({ isComposite: false }),
_ToolSanitizationProxy: sanCtorPair.ctor as never,
_CodexReasoningProxy: codexFailCtor as never,
})
);
expect(result.codexReasoningProxy).toBeNull();
expect(result.codexReasoningPort).toBeNull();
});
});
// ── All results null when no ANTHROPIC_BASE_URL ───────────────────────────────
describe('buildProxyChain — all results null when no ANTHROPIC_BASE_URL', () => {
it('returns four nulls for gemini without base URL', async () => {
const sanCtor = jest.fn();
const codexCtor = jest.fn();
const result = await buildProxyChain(
baseCtx({
initialEnvVars: {},
_ToolSanitizationProxy: sanCtor as never,
_CodexReasoningProxy: codexCtor as never,
})
);
expect(sanCtor).not.toHaveBeenCalled();
expect(codexCtor).not.toHaveBeenCalled();
expect(result.toolSanitizationProxy).toBeNull();
expect(result.toolSanitizationPort).toBeNull();
expect(result.codexReasoningProxy).toBeNull();
expect(result.codexReasoningPort).toBeNull();
});
});