Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 211 additions & 1 deletion packages/server-test/__tests__/formatters.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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.<anonymous> (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");
});
});
70 changes: 69 additions & 1 deletion packages/server-test/src/lib/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
5 changes: 3 additions & 2 deletions packages/server-test/src/schemas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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<typeof CoverageSchema>;
22 changes: 18 additions & 4 deletions packages/server-test/src/tools/coverage.ts
Original file line number Diff line number Diff line change
@@ -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[] } {
Expand Down Expand Up @@ -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);
Expand All @@ -69,7 +76,14 @@ export function registerCoverageTool(server: McpServer) {
break;
}

return dualOutput(coverage, formatCoverage);
return compactDualOutput(
coverage,
result.stdout,
formatCoverage,
compactCoverageMap,
formatCoverageCompact,
compact === false,
);
},
);
}
Loading
Loading