/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ /* eslint-disable import/order -- the adapter imports must follow the vi.mock() block so the intent builders are mocked before the adapters that import them are loaded; import/order can't model that hoisting constraint. */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { captureStdout } from "../../../__tests__/helpers/stdout.js"; // Mock every intent builder so the adapters can be exercised without auth, // network, or a primed schema cache. Each returns a sentinel tagged with the // builder name so we can assert the adapter dispatched to the right one. vi.mock("../../../intent/build-list.js", () => ({ buildList: vi.fn(async (spec) => ({ from: "list", spec })), })); vi.mock("../../../intent/build-detail.js", () => ({ buildDetail: vi.fn(async (spec) => ({ from: "detail", spec })), })); vi.mock("../../../intent/build-discover.js", () => ({ buildDiscover: vi.fn(async (spec) => ({ from: "discover", spec })), })); vi.mock("../../../intent/build-aggregate.js", () => ({ buildAggregate: vi.fn(async (spec) => ({ from: "aggregate", spec })), })); vi.mock("../../../intent/build-raw.js", () => ({ buildRaw: vi.fn(async (spec) => ({ from: "raw", spec })), })); vi.mock("../../../intent/build-create.js", () => ({ buildCreate: vi.fn(async (spec) => ({ from: "create", spec })), })); vi.mock("../../../intent/build-update.js", () => ({ buildUpdate: vi.fn(async (spec) => ({ from: "update", spec })), })); vi.mock("../../../intent/build-delete.js", () => ({ buildDelete: vi.fn(async (spec) => ({ from: "delete", spec })), })); vi.mock("../../../intent/build-connect.js", () => ({ buildConnect: vi.fn(async (spec) => ({ from: "connect", spec })), })); import { MIRRORS } from "../commands.js"; interface Case { name: string; run: (json?: string) => Promise; validJson: string; expectedFrom: string; } // Per-tool fixtures: a representative valid payload and the sentinel `from` tag // its mocked builder returns (the mock tags by short name, e.g. "list"). const FIXTURES: Record = { "sf-gql-list": { validJson: '{"org":"o","object":"Account","fields":["Id"]}', expectedFrom: "list", }, "sf-gql-detail": { validJson: '{"org":"o","object":"Account","fields":["Id"]}', expectedFrom: "detail", }, "sf-gql-discover": { validJson: '{"org":"o","mode":"list_objects"}', expectedFrom: "discover" }, "sf-gql-aggregate": { validJson: '{"org":"o","object":"Case","groupBy":["Status"]}', expectedFrom: "aggregate", }, "sf-gql-raw": { validJson: '{"org":"o","commands":["select uiapi/query/Case/edges/node/Id/value"]}', expectedFrom: "raw", }, "sf-gql-create": { validJson: '{"org":"o","object":"Account"}', expectedFrom: "create" }, "sf-gql-update": { validJson: '{"org":"o","object":"Account"}', expectedFrom: "update" }, "sf-gql-delete": { validJson: '{"org":"o","object":"Account"}', expectedFrom: "delete" }, "sf-gql-connect": { validJson: '{"org":"o"}', expectedFrom: "connect" }, }; // Drive the suite off MIRRORS so a new tool is covered automatically once it has // a fixture; a missing fixture fails loudly rather than silently skipping. const CASES: Case[] = MIRRORS.map((m) => { const fixture = FIXTURES[m.name]; if (!fixture) throw new Error(`No test fixture for mirror command "${m.name}"`); return { name: m.name, run: m.run, ...fixture }; }); // `sf-gql-detail`'s adapter, looked up from the table for the .strict() test. const sfGqlDetail = MIRRORS.find((m) => m.name === "sf-gql-detail")!.run; beforeEach(() => { process.exitCode = 0; }); describe("mcp-mirror command adapters", () => { it.each(CASES)( "$name dispatches to its builder and prints the result as JSON", async ({ run, validJson, expectedFrom }) => { const out = await captureStdout(() => run(validJson)); const parsed = JSON.parse(out); expect(parsed.from).toBe(expectedFrom); // The adapter must hand the builder the *validated* input, not the raw // string or a fixed object. The mocks echo `spec`, so this catches a // payload-handoff regression (e.g. passing raw json instead of // result.data) that the `from` tag alone would miss. expect(parsed.spec).toEqual(JSON.parse(validJson)); expect(process.exitCode).toBe(0); }, ); it.each(CASES)("$name emits INVALID_ARGS on a bad arg", async ({ run }) => { const out = await captureStdout(() => run("{not json")); expect(JSON.parse(out).error.code).toBe("INVALID_ARGS"); expect(process.exitCode).toBe(1); }); it.each(CASES)( "$name emits INVALID_ARGS on a well-formed object that fails schema validation", async ({ run }) => { // A valid JSON object missing required fields must route through the // Zod-failure branch (not just the JSON.parse branch). `{}` is missing // `org` for every tool. const out = await captureStdout(() => run("{}")); const parsed = JSON.parse(out); expect(parsed.error.code).toBe("INVALID_ARGS"); expect(parsed.error.details).toBeTruthy(); expect(process.exitCode).toBe(1); }, ); it("sf-gql-detail preserves DETAIL_INPUT's .strict() rejection of unknown top-level keys", async () => { // DETAIL_INPUT is the one schema using .strict() (vs the .shape tools). // `filter` is a sibling-tool param that detail intentionally rejects, so a // hallucinating caller gets INVALID_ARGS rather than silent omission. This // guards that the strict behavior survives the CLI adapter. const out = await captureStdout(() => sfGqlDetail('{"org":"o","object":"Account","fields":["Id"],"filter":{}}'), ); const parsed = JSON.parse(out); expect(parsed.error.code).toBe("INVALID_ARGS"); expect(process.exitCode).toBe(1); }); });