diff --git a/packages/server-test/__tests__/formatters.test.ts b/packages/server-test/__tests__/formatters.test.ts index ed2e6781..aa9d220c 100644 --- a/packages/server-test/__tests__/formatters.test.ts +++ b/packages/server-test/__tests__/formatters.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect } from "vitest"; -import { formatTestRun, formatCoverage } from "../src/lib/formatters.js"; +import { + formatTestRun, + formatCoverage, + compactTestRunMap, + formatTestRunCompact, + compactCoverageMap, + formatCoverageCompact, +} from "../src/lib/formatters.js"; import type { TestRun, Coverage } from "../src/schemas/index.js"; describe("formatTestRun", () => { @@ -229,3 +236,206 @@ describe("formatCoverage", () => { expect(output.split("\n")).toHaveLength(2); }); }); + +describe("compactTestRunMap", () => { + it("keeps summary, framework, and failure names only (drops messages, stacks, locations)", () => { + const run: TestRun = { + framework: "jest", + summary: { total: 10, passed: 7, failed: 3, skipped: 0, duration: 2.1 }, + failures: [ + { + name: "should validate email", + file: "api.test.ts", + line: 15, + message: "Expected valid@email.com to be valid", + stack: "Error: Expected valid@email.com...\n at Object. (api.test.ts:15)", + expected: "true", + actual: "false", + }, + { + name: "should parse config", + file: "utils.test.ts", + message: "Received null instead of config object", + }, + { + name: "should handle timeout", + message: "Timeout after 5000ms", + }, + ], + }; + + const compact = compactTestRunMap(run); + + expect(compact.framework).toBe("jest"); + expect(compact.summary).toEqual({ + total: 10, + passed: 7, + failed: 3, + skipped: 0, + duration: 2.1, + }); + expect(compact.failures).toEqual([ + { name: "should validate email" }, + { name: "should parse config" }, + { name: "should handle timeout" }, + ]); + // Verify verbose fields are stripped from each failure + expect(compact.failures[0]).not.toHaveProperty("message"); + expect(compact.failures[0]).not.toHaveProperty("file"); + expect(compact.failures[0]).not.toHaveProperty("line"); + expect(compact.failures[0]).not.toHaveProperty("stack"); + expect(compact.failures[0]).not.toHaveProperty("expected"); + expect(compact.failures[0]).not.toHaveProperty("actual"); + }); + + it("returns empty failures for passing run", () => { + const run: TestRun = { + framework: "vitest", + summary: { total: 5, passed: 5, failed: 0, skipped: 0, duration: 0.3 }, + failures: [], + }; + + const compact = compactTestRunMap(run); + + expect(compact.failures).toEqual([]); + expect(compact.summary.failed).toBe(0); + }); +}); + +describe("formatTestRunCompact", () => { + it("formats compact test run with failure names only", () => { + const compact = { + framework: "jest", + summary: { total: 10, passed: 7, failed: 3, skipped: 0, duration: 2.1 }, + failures: [ + { name: "should validate email" }, + { name: "should parse config" }, + { name: "should handle timeout" }, + ], + }; + + const output = formatTestRunCompact(compact); + + expect(output).toContain("FAIL"); + expect(output).toContain("(jest)"); + expect(output).toContain("10 tests"); + expect(output).toContain("3 failed"); + expect(output).toContain("FAIL should validate email"); + expect(output).toContain("FAIL should parse config"); + expect(output).toContain("FAIL should handle timeout"); + // Should NOT contain verbose failure details + expect(output).not.toContain("Expected"); + expect(output).not.toContain("Received"); + expect(output).not.toContain("api.test.ts"); + }); + + it("formats compact passing run as PASS with no failure lines", () => { + const compact = { + framework: "vitest", + summary: { total: 5, passed: 5, failed: 0, skipped: 0, duration: 0.3 }, + failures: [] as Array<{ name: string }>, + }; + + const output = formatTestRunCompact(compact); + + expect(output).toContain("PASS"); + expect(output.split("\n")).toHaveLength(1); + }); +}); + +describe("compactCoverageMap", () => { + it("keeps summary and totalFiles, drops per-file details", () => { + const cov: Coverage = { + framework: "jest", + summary: { lines: 87.5, branches: 66.67, functions: 100 }, + files: [ + { file: "src/auth.ts", lines: 92, branches: 80, functions: 100 }, + { file: "src/api.ts", lines: 80, branches: 50, functions: 100 }, + { file: "src/utils.ts", lines: 95, branches: 70, functions: 100 }, + ], + }; + + const compact = compactCoverageMap(cov); + + expect(compact.framework).toBe("jest"); + expect(compact.summary).toEqual({ lines: 87.5, branches: 66.67, functions: 100 }); + expect(compact.totalFiles).toBe(3); + // Verify per-file details are not present + expect(compact).not.toHaveProperty("files"); + }); + + it("returns totalFiles 0 for empty files array", () => { + const cov: Coverage = { + framework: "pytest", + summary: { lines: 0 }, + files: [], + }; + + const compact = compactCoverageMap(cov); + + expect(compact.totalFiles).toBe(0); + }); + + it("preserves optional summary fields when present", () => { + const cov: Coverage = { + framework: "mocha", + summary: { lines: 50 }, + files: [{ file: "app.js", lines: 50 }], + }; + + const compact = compactCoverageMap(cov); + + expect(compact.summary.lines).toBe(50); + expect(compact.summary.branches).toBeUndefined(); + expect(compact.summary.functions).toBeUndefined(); + expect(compact.totalFiles).toBe(1); + }); +}); + +describe("formatCoverageCompact", () => { + it("formats compact coverage with summary and file count", () => { + const compact = { + framework: "jest", + summary: { lines: 87.5, branches: 66.67, functions: 100 }, + totalFiles: 3, + }; + + const output = formatCoverageCompact(compact); + + expect(output).toContain("Coverage (jest)"); + expect(output).toContain("87.5% lines"); + expect(output).toContain("66.67% branches"); + expect(output).toContain("100% functions"); + expect(output).toContain("3 file(s) analyzed"); + // Should NOT contain per-file details + expect(output).not.toContain("src/auth.ts"); + }); + + it("formats compact coverage without optional branches/functions", () => { + const compact = { + framework: "pytest", + summary: { lines: 75 }, + totalFiles: 5, + }; + + const output = formatCoverageCompact(compact); + + expect(output).toContain("75% lines"); + expect(output).toContain("5 file(s) analyzed"); + expect(output).not.toContain("branches"); + expect(output).not.toContain("functions"); + }); + + it("formats compact coverage with zero files", () => { + const compact = { + framework: "mocha", + summary: { lines: 0, branches: 0, functions: 0 }, + totalFiles: 0, + }; + + const output = formatCoverageCompact(compact); + + expect(output).toContain("0% lines"); + expect(output).toContain("0 file(s) analyzed"); + }); +}); diff --git a/packages/server-test/src/lib/formatters.ts b/packages/server-test/src/lib/formatters.ts index 2fb5ebf6..c453859c 100644 --- a/packages/server-test/src/lib/formatters.ts +++ b/packages/server-test/src/lib/formatters.ts @@ -22,9 +22,77 @@ export function formatCoverage(c: Coverage): string { if (c.summary.branches !== undefined) parts[0] += `, ${c.summary.branches}% branches`; if (c.summary.functions !== undefined) parts[0] += `, ${c.summary.functions}% functions`; - for (const f of c.files) { + for (const f of c.files ?? []) { parts.push(` ${f.file}: ${f.lines}% lines`); } return parts.join("\n"); } + +// ── Compact types, mappers, and formatters ─────────────────────────── + +/** Compact test run: summary + framework + failure names only (no messages, stacks, expected/actual). */ +export interface TestRunCompact { + [key: string]: unknown; + framework: string; + summary: { + total: number; + passed: number; + failed: number; + skipped: number; + duration: number; + }; + failures: Array<{ name: string }>; +} + +export function compactTestRunMap(r: TestRun): TestRunCompact { + return { + framework: r.framework, + summary: { ...r.summary }, + failures: r.failures.map((f) => ({ name: f.name })), + }; +} + +export function formatTestRunCompact(r: TestRunCompact): string { + const status = r.summary.failed > 0 ? "FAIL" : "PASS"; + const parts = [ + `${status} (${r.framework}) ${r.summary.total} tests: ${r.summary.passed} passed, ${r.summary.failed} failed, ${r.summary.skipped} skipped [${r.summary.duration}s]`, + ]; + + for (const f of r.failures) { + parts.push(` FAIL ${f.name}`); + } + + return parts.join("\n"); +} + +/** Compact coverage: summary totals + file count only (no per-file details). */ +export interface CoverageCompact { + [key: string]: unknown; + framework: string; + summary: { + lines: number; + branches?: number; + functions?: number; + }; + totalFiles: number; +} + +export function compactCoverageMap(c: Coverage): CoverageCompact { + return { + framework: c.framework, + summary: { ...c.summary }, + totalFiles: (c.files ?? []).length, + }; +} + +export function formatCoverageCompact(c: CoverageCompact): string { + const parts = [`Coverage (${c.framework}): ${c.summary.lines}% lines`]; + + if (c.summary.branches !== undefined) parts[0] += `, ${c.summary.branches}% branches`; + if (c.summary.functions !== undefined) parts[0] += `, ${c.summary.functions}% functions`; + + parts.push(`${c.totalFiles} file(s) analyzed`); + + return parts.join("\n"); +} diff --git a/packages/server-test/src/schemas/index.ts b/packages/server-test/src/schemas/index.ts index a869d17c..bbed225a 100644 --- a/packages/server-test/src/schemas/index.ts +++ b/packages/server-test/src/schemas/index.ts @@ -5,7 +5,7 @@ export const TestFailureSchema = z.object({ name: z.string(), file: z.string().optional(), line: z.number().optional(), - message: z.string(), + message: z.string().optional(), expected: z.string().optional(), actual: z.string().optional(), stack: z.string().optional(), @@ -45,7 +45,8 @@ export const CoverageSchema = z.object({ branches: z.number().optional(), functions: z.number().optional(), }), - files: z.array(CoverageFileSchema), + files: z.array(CoverageFileSchema).optional(), + totalFiles: z.number().optional(), }); export type Coverage = z.infer; diff --git a/packages/server-test/src/tools/coverage.ts b/packages/server-test/src/tools/coverage.ts index 788fe883..08dd2194 100644 --- a/packages/server-test/src/tools/coverage.ts +++ b/packages/server-test/src/tools/coverage.ts @@ -1,12 +1,12 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { dualOutput, run, INPUT_LIMITS } from "@paretools/shared"; +import { compactDualOutput, run, INPUT_LIMITS } from "@paretools/shared"; import { detectFramework, type Framework } from "../lib/detect.js"; import { parsePytestCoverage } from "../lib/parsers/pytest.js"; import { parseJestCoverage } from "../lib/parsers/jest.js"; import { parseVitestCoverage } from "../lib/parsers/vitest.js"; import { parseMochaCoverage } from "../lib/parsers/mocha.js"; -import { formatCoverage } from "../lib/formatters.js"; +import { formatCoverage, compactCoverageMap, formatCoverageCompact } from "../lib/formatters.js"; import { CoverageSchema } from "../schemas/index.js"; function getCoverageCommand(framework: Framework): { cmd: string; cmdArgs: string[] } { @@ -42,10 +42,17 @@ export function registerCoverageTool(server: McpServer) { .enum(["pytest", "jest", "vitest", "mocha"]) .optional() .describe("Force a specific framework instead of auto-detecting"), + compact: z + .boolean() + .optional() + .default(true) + .describe( + "Auto-compact when structured output exceeds raw CLI tokens. Set false to always get full schema.", + ), }, outputSchema: CoverageSchema, }, - async ({ path, framework }) => { + async ({ path, framework, compact }) => { const cwd = path || process.cwd(); const detected = framework || (await detectFramework(cwd)); const { cmd, cmdArgs } = getCoverageCommand(detected); @@ -69,7 +76,14 @@ export function registerCoverageTool(server: McpServer) { break; } - return dualOutput(coverage, formatCoverage); + return compactDualOutput( + coverage, + result.stdout, + formatCoverage, + compactCoverageMap, + formatCoverageCompact, + compact === false, + ); }, ); } diff --git a/packages/server-test/src/tools/run.ts b/packages/server-test/src/tools/run.ts index 6ab4c7a9..18036103 100644 --- a/packages/server-test/src/tools/run.ts +++ b/packages/server-test/src/tools/run.ts @@ -4,13 +4,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { randomUUID } from "node:crypto"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { dualOutput, run, assertNoFlagInjection, INPUT_LIMITS } from "@paretools/shared"; +import { compactDualOutput, run, assertNoFlagInjection, INPUT_LIMITS } from "@paretools/shared"; import { detectFramework, type Framework } from "../lib/detect.js"; import { parsePytestOutput } from "../lib/parsers/pytest.js"; import { parseJestJson } from "../lib/parsers/jest.js"; import { parseVitestJson } from "../lib/parsers/vitest.js"; import { parseMochaJson } from "../lib/parsers/mocha.js"; -import { formatTestRun } from "../lib/formatters.js"; +import { formatTestRun, compactTestRunMap, formatTestRunCompact } from "../lib/formatters.js"; import { TestRunSchema } from "../schemas/index.js"; function getRunCommand(framework: Framework, args: string[]): { cmd: string; cmdArgs: string[] } { @@ -59,10 +59,17 @@ export function registerRunTool(server: McpServer) { .optional() .default([]) .describe("Additional arguments to pass to the test runner"), + compact: z + .boolean() + .optional() + .default(true) + .describe( + "Auto-compact when structured output exceeds raw CLI tokens. Set false to always get full schema.", + ), }, outputSchema: TestRunSchema, }, - async ({ path, framework, filter, updateSnapshots, args }) => { + async ({ path, framework, filter, updateSnapshots, args, compact }) => { for (const a of args ?? []) { assertNoFlagInjection(a, "args"); } @@ -133,7 +140,14 @@ export function registerRunTool(server: McpServer) { } } - return dualOutput(testRun, formatTestRun); + return compactDualOutput( + testRun, + result.stdout, + formatTestRun, + compactTestRunMap, + formatTestRunCompact, + compact === false, + ); }, ); }