headroom/plugins/openclaw/test/engine.test.ts

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

200 lines
6.3 KiB
TypeScript
Raw Normal View History

import { afterEach, describe, expect, it, vi } from "vitest";
const mocked = vi.hoisted(() => ({
start: vi.fn(async () => "http://127.0.0.1:8787"),
stop: vi.fn(async () => undefined),
logger: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}));
vi.mock("headroom-ai", () => ({
compress: vi.fn(),
}));
vi.mock("../src/proxy-manager.js", () => ({
ProxyManager: class {
start = mocked.start;
stop = mocked.stop;
},
defaultLogger: mocked.logger,
}));
import { HeadroomContextEngine } from "../src/engine.js";
afterEach(() => {
mocked.start.mockReset();
mocked.start.mockResolvedValue("http://127.0.0.1:8787");
mocked.stop.mockClear();
mocked.logger.debug.mockClear();
mocked.logger.error.mockClear();
mocked.logger.info.mockClear();
mocked.logger.warn.mockClear();
});
describe("HeadroomContextEngine proxy startup helpers", () => {
it("bootstraps by scheduling proxy startup when enabled", async () => {
const engine = new HeadroomContextEngine();
await expect(
engine.bootstrap({
sessionId: "session-1",
sessionFile: "session.jsonl",
}),
).resolves.toEqual({
bootstrapped: true,
reason: "proxy startup scheduled",
});
expect(mocked.start).toHaveBeenCalledTimes(1);
});
it("removes unsubscribed proxy listeners before notifying readiness", async () => {
const engine = new HeadroomContextEngine();
const first = vi.fn();
const second = vi.fn();
const unsubscribeFirst = engine.onProxyReady(first);
engine.onProxyReady(second);
unsubscribeFirst();
engine.ensureProxyStarted();
await engine.ensureProxyUrl();
expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledWith("http://127.0.0.1:8787");
});
it("returns the existing proxy URL without starting again", async () => {
const engine = new HeadroomContextEngine();
(engine as { proxyUrl: string | null }).proxyUrl = "http://127.0.0.1:8787";
await expect(engine.ensureProxyUrl()).resolves.toBe("http://127.0.0.1:8787");
expect(mocked.start).not.toHaveBeenCalled();
});
it("throws when proxy startup is disabled", async () => {
const engine = new HeadroomContextEngine({ enabled: false });
await expect(engine.ensureProxyUrl()).rejects.toThrow("Headroom proxy startup is disabled");
expect(mocked.start).not.toHaveBeenCalled();
});
Harden OpenClaw plugin proxy routing (#1074) ## Description Hardens the bundled OpenClaw plugin so configured proxy routing is fail-closed and `autoStart` is opt-in. Closes: N/A This follow-up is intentionally separate from the ContentRouter cache fix because it changes plugin/gateway behavior rather than core compression routing. The plugin should not mutate upstream provider routing unless a configured proxy URL is reachable and looks like Headroom. It should also avoid unhandled startup promise rejections when proxy startup is fire-and-forget. Why this shape: - `autoStart: false` by default matches deployments where Headroom is supervised externally, for example by systemd. The plugin should not silently start or assume ownership of a proxy unless the operator opted in. - Provider routing is fail-closed: a configured URL must first respond like Headroom, not merely expose a generic liveness endpoint. This prevents accidentally routing model traffic through the wrong local service. - `/readyz` is treated as liveness, not identity. Identity comes from Headroom-shaped stats endpoints (`/v1/retrieve/stats` or `/stats`) because those are harder for unrelated services to satisfy by accident. - Startup remains asynchronous, but errors are captured and exposed instead of becoming unhandled promise rejections. - This is a separate PR because the core cache fix is about compression correctness, while this patch is about integration safety around OpenClaw gateway routing. ## Type of Change - [x] Bug fix (non-breaking change fixes issue) - [ ] New feature (non-breaking change adds functionality) - [ ] Breaking change (fix or feature would cause existing functionality change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Make proxy `autoStart` opt-in (`default: false`). - Probe configured `proxyUrl` before applying provider routing. - Treat `/readyz` as liveness only; require Headroom-shaped `/v1/retrieve/stats` or `/stats` for identity. - Observe fire-and-forget startup promise rejection and expose startup error for callers. - Isolate proxy-ready listener failures. - Keep provider routing deferred when no active/probed Headroom proxy exists. - Register retrieve tool with explicit `headroom_retrieve` name. - Extend plugin/unit tests for configured proxy failures, generic non-Headroom endpoints, path collisions, and routing behavior. Changed files: - `plugins/openclaw/README.md` - `plugins/openclaw/openclaw.plugin.json` - `plugins/openclaw/src/engine.ts` - `plugins/openclaw/src/plugin/index.ts` - `plugins/openclaw/src/proxy-manager.ts` - `plugins/openclaw/test/engine.test.ts` - `plugins/openclaw/test/gateway-config.test.ts` - `plugins/openclaw/test/plugin-runtime-routing.test.ts` - `plugins/openclaw/test/proxy-manager.test.ts` ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added new functionality - [x] Manual testing performed ### Test Output ```text $ npm test Test Files 6 passed (6) Tests 74 passed (74) $ npm run typecheck tsc --noEmit $ npm run build tsup && node prepare-dist.mjs Build success ``` ## Real Behavior Proof - Environment: local OpenClaw plugin package in the Headroom repo. - Exact command / steps: - Run plugin test suite. - Run TypeScript typecheck. - Run plugin build. - Observed result: - Tests passed: `74/74`. - Typecheck passed. - Build passed. - Not tested: - Full OpenClaw Gateway integration as part of this standalone PR prep. ## Review Readiness - [x] I performed self-review - [x] This PR ready for human review ## Checklist - [x] My code follows project's style guidelines - [x] I performed self-review my code - [ ] I commented my code, particularly in hard-to-understand areas - [x] I made corresponding changes documentation - [x] My changes generate no new warnings - [x] I added tests prove fix is effective or feature works - [x] New and existing unit tests pass locally my changes - [ ] I updated CHANGELOG.md if applicable ## Screenshots (if applicable) N/A. ## Additional Notes Checklist items left unchecked intentionally: - No CHANGELOG update included. - No extra comments were needed beyond existing code structure. Co-authored-by: Björn-Christian Bönkost <bjoern@v2202603344248440850.hotsrv.de>
2026-06-23 05:52:59 +02:00
it("does not emit an unhandledRejection when fire-and-forget startup fails", async () => {
mocked.start.mockReset();
mocked.start.mockRejectedValue(new Error("proxy boom"));
const engine = new HeadroomContextEngine();
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => unhandled.push(reason);
process.on("unhandledRejection", onUnhandled);
try {
// Fire-and-forget: caller intentionally does not await.
engine.ensureProxyStarted();
// Let the startup promise settle and any microtasks/macrotasks flush.
await new Promise((resolve) => setTimeout(resolve, 0));
expect(unhandled).toEqual([]);
expect(mocked.logger.warn).toHaveBeenCalledWith(
expect.stringContaining("Headroom proxy unavailable"),
);
} finally {
process.off("unhandledRejection", onUnhandled);
}
});
it("stores the startup failure in getProxyStartupError()", async () => {
const failure = new Error("proxy boom");
mocked.start.mockReset();
mocked.start.mockRejectedValue(failure);
const engine = new HeadroomContextEngine();
expect(engine.getProxyStartupError()).toBeNull();
engine.ensureProxyStarted();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(engine.getProxyStartupError()).toBe(failure);
});
it("allows retrying startup after a failure", async () => {
mocked.start.mockReset();
mocked.start
.mockRejectedValueOnce(new Error("proxy boom"))
.mockResolvedValueOnce("http://127.0.0.1:8787");
const engine = new HeadroomContextEngine();
engine.ensureProxyStarted();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(engine.getProxyStartupError()).toBeInstanceOf(Error);
// A second attempt is possible once the failed promise has cleared.
const url = await engine.ensureProxyUrl();
expect(url).toBe("http://127.0.0.1:8787");
expect(engine.getProxyStartupError()).toBeNull();
expect(mocked.start).toHaveBeenCalledTimes(2);
});
it("ensureProxyUrl rejects cleanly on startup failure without unhandledRejection", async () => {
const failure = new Error("proxy boom");
mocked.start.mockReset();
mocked.start.mockRejectedValue(failure);
const engine = new HeadroomContextEngine();
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown) => unhandled.push(reason);
process.on("unhandledRejection", onUnhandled);
try {
await expect(engine.ensureProxyUrl()).rejects.toBe(failure);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(unhandled).toEqual([]);
} finally {
process.off("unhandledRejection", onUnhandled);
}
});
it("isolates and logs proxy-ready listener rejections", async () => {
const engine = new HeadroomContextEngine();
const failing = vi.fn(async () => {
throw new Error("listener boom");
});
const healthy = vi.fn();
engine.onProxyReady(failing);
engine.onProxyReady(healthy);
engine.ensureProxyStarted();
// ensureProxyUrl must still resolve despite the listener throwing.
await expect(engine.ensureProxyUrl()).resolves.toBe("http://127.0.0.1:8787");
expect(failing).toHaveBeenCalled();
expect(healthy).toHaveBeenCalledWith("http://127.0.0.1:8787");
expect(mocked.logger.warn).toHaveBeenCalledWith(
expect.stringContaining("Headroom proxy ready listener failed"),
);
expect(engine.getProxyStartupError()).toBeNull();
});
it("schedules startup and returns original messages when assembling before proxy readiness", async () => {
const engine = new HeadroomContextEngine();
const messages = [{ role: "user", content: "hello" }];
await expect(
engine.assemble({
sessionId: "session-1",
messages,
}),
).resolves.toEqual({
messages,
estimatedTokens: 0,
});
expect(mocked.start).toHaveBeenCalledTimes(1);
});
});