import { describe, it, expect, vi, beforeEach } from "vitest"; import { wrapWithLifeline } from "../index.js"; function captureStderr(): { lines: string[]; restore: () => void } { const lines: string[] = []; const original = process.stderr.write.bind(process.stderr); const spy = vi .spyOn(process.stderr, "write") .mockImplementation((chunk: unknown) => { lines.push(String(chunk)); return true; }); return { lines, restore: () => { spy.mockRestore(); process.stderr.write = original; }, }; } beforeEach(() => { process.env.MCP_SPAWN_TEE_NAME = "test-plugin"; }); describe("wrapWithLifeline — op=request before handler", () => { it("writes op=request before the handler runs", async () => { const { lines, restore } = captureStderr(); const order: string[] = []; const handler = async () => { order.push("handler"); return { content: [] }; }; const wrapped = wrapWithLifeline("my-tool", handler); await wrapped({}); restore(); const requestIdx = lines.findIndex((l) => l.includes("op=request")); expect(requestIdx).toBeGreaterThanOrEqual(0); expect(order[0]).toBe("handler"); const resultIdx = lines.findIndex((l) => l.includes("op=result")); expect(requestIdx).toBeLessThan(resultIdx); }); }); describe("wrapWithLifeline — token correlation", () => { it("op=request and op=result share the same token", async () => { const { lines, restore } = captureStderr(); const wrapped = wrapWithLifeline("my-tool", async () => ({ content: [] })); await wrapped({}); restore(); const reqLine = lines.find((l) => l.includes("op=request")); const resLine = lines.find((l) => l.includes("op=result")); expect(reqLine).toBeDefined(); expect(resLine).toBeDefined(); const reqToken = reqLine!.match(/token=(\S+)/)?.[1]; const resToken = resLine!.match(/token=(\S+)/)?.[1]; expect(reqToken).toBeDefined(); expect(reqToken).toBe(resToken); }); }); describe("wrapWithLifeline — outcome=ok on success", () => { it("writes outcome=ok when handler resolves", async () => { const { lines, restore } = captureStderr(); const wrapped = wrapWithLifeline("my-tool", async () => ({ content: [] })); await wrapped({}); restore(); const resLine = lines.find((l) => l.includes("op=result")); expect(resLine).toContain("outcome=ok"); }); }); describe("wrapWithLifeline — outcome=error on throw, error re-thrown", () => { it("writes outcome=error and re-throws the original error", async () => { const { lines, restore } = captureStderr(); const boom = new Error("handler-boom"); const wrapped = wrapWithLifeline("my-tool", async () => { throw boom; }); let caught: unknown; try { await wrapped({}); } catch (e) { caught = e; } restore(); expect(caught).toBe(boom); const resLine = lines.find((l) => l.includes("op=result")); expect(resLine).toContain("outcome=error"); }); }); describe("wrapWithLifeline — outcome=error when the handler RETURNS isError", () => { it("writes outcome=error and returns the payload unchanged", async () => { const { lines, restore } = captureStderr(); const payload = { content: [{ type: "text", text: "boom" }], isError: true }; const wrapped = wrapWithLifeline("my-tool", async () => payload); const result = await wrapped({}); restore(); // The payload must reach the caller untouched — only the log token changes. expect(result).toBe(payload); const resLine = lines.find((l) => l.includes("op=result")); expect(resLine).toContain("outcome=error"); }); it("still writes outcome=ok when isError is absent or false", async () => { const { lines, restore } = captureStderr(); const wrapped = wrapWithLifeline("my-tool", async () => ({ content: [{ type: "text", text: "fine" }], isError: false, })); await wrapped({}); restore(); const resLine = lines.find((l) => l.includes("op=result")); expect(resLine).toContain("outcome=ok"); }); }); describe("wrapWithLifeline — ms field", () => { it("op=result contains a non-negative integer ms= field", async () => { const { lines, restore } = captureStderr(); const wrapped = wrapWithLifeline("my-tool", async () => ({ content: [] })); await wrapped({}); restore(); const resLine = lines.find((l) => l.includes("op=result")); const ms = resLine?.match(/ms=(\d+)/)?.[1]; expect(ms).toBeDefined(); expect(Number(ms)).toBeGreaterThanOrEqual(0); }); }); describe("wrapWithLifeline — handles field", () => { it("op=result contains a handles= field", async () => { const { lines, restore } = captureStderr(); const wrapped = wrapWithLifeline("my-tool", async () => ({ content: [] })); await wrapped({}); restore(); const resLine = lines.find((l) => l.includes("op=result")); expect(resLine).toMatch(/handles=\S+/); }); }); describe("wrapWithLifeline — fail-open on broken stderr", () => { it("runs handler even if pre-log write throws", async () => { let handlerRan = false; const writeStub = vi .spyOn(process.stderr, "write") .mockImplementationOnce(() => { throw new Error("pipe broken"); }) .mockImplementation(() => true); const wrapped = wrapWithLifeline("my-tool", async () => { handlerRan = true; return { content: [] }; }); await wrapped({}); writeStub.mockRestore(); expect(handlerRan).toBe(true); }); }); describe("wrapWithLifeline — plugin label from env", () => { it("uses MCP_SPAWN_TEE_NAME env var in log lines", async () => { process.env.MCP_SPAWN_TEE_NAME = "email"; const { lines, restore } = captureStderr(); const wrapped = wrapWithLifeline("my-tool", async () => ({ content: [] })); await wrapped({}); restore(); delete process.env.MCP_SPAWN_TEE_NAME; expect(lines.some((l) => l.startsWith("[email]"))).toBe(true); }); });