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
6 changes: 2 additions & 4 deletions src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,9 @@ export async function renderDiagram(options: RenderOptions, liveFilePath: string
await copyFile(outputFile, liveFilePath);
mcpLogger.info(`Diagram rendered successfully: ${previewId}`);
} catch (error) {
const stderr =
error instanceof Error && "stderr" in error && (error as Error & { stderr: string }).stderr
? `\n${(error as Error & { stderr: string }).stderr}`
: "";
const message = error instanceof Error ? error.message : String(error);
const stderrValue = error instanceof Error && "stderr" in error ? (error as any).stderr : "";
const stderr = stderrValue ? `\n${stderrValue}` : "";
mcpLogger.error(`Diagram rendering failed: ${previewId}`, { error: message });
throw new Error(`${message}${stderr}`);
}
Expand Down
57 changes: 5 additions & 52 deletions test/diagram-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Tests business logic for diagram data access and management
*/

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
listDiagrams,
getDiagramInfo,
Expand All @@ -12,70 +12,23 @@ import {
getDiagramCount,
deleteDiagram,
} from "../src/diagram-service.js";
import * as fileUtils from "../src/file-utils.js";
import { readdir, stat, mkdir, writeFile, rmdir, unlink, utimes } from "fs/promises";
import { stat, mkdir, writeFile, utimes } from "fs/promises";
import { join } from "path";
import { tmpdir } from "os";
import { setupTestEnv, restoreTestEnv } from "./helpers/env-helpers.js";

describe("Diagram Service", () => {
let testHomeDir: string;
let testLiveDir: string;
let originalHome: string | undefined;
let originalXdgConfigHome: string | undefined;

beforeEach(async () => {
// Save original environment
originalHome = process.env.HOME;
originalXdgConfigHome = process.env.XDG_CONFIG_HOME;

// Clear XDG_CONFIG_HOME to ensure HOME is used
delete process.env.XDG_CONFIG_HOME;

// Create temporary HOME directory to isolate tests
testHomeDir = join(tmpdir(), `diagram-service-test-home-${Date.now()}`);
process.env.HOME = testHomeDir;

// Live dir will be: testHomeDir/.config/claude-mermaid/live
testHomeDir = await setupTestEnv({ setXdgConfig: false });
testLiveDir = join(testHomeDir, ".config", "claude-mermaid", "live");
await mkdir(testLiveDir, { recursive: true });
});

afterEach(async () => {
// Restore original environment
if (originalHome) {
process.env.HOME = originalHome;
} else {
delete process.env.HOME;
}

if (originalXdgConfigHome) {
process.env.XDG_CONFIG_HOME = originalXdgConfigHome;
} else {
delete process.env.XDG_CONFIG_HOME;
}

// Cleanup test directory
try {
const entries = await readdir(testLiveDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(testLiveDir, entry.name);
if (entry.isDirectory()) {
const files = await readdir(fullPath);
for (const file of files) {
await unlink(join(fullPath, file));
}
await rmdir(fullPath);
} else {
await unlink(fullPath);
}
}
await rmdir(testLiveDir);
await rmdir(join(testHomeDir, ".config", "claude-mermaid"));
await rmdir(join(testHomeDir, ".config"));
await rmdir(testHomeDir);
} catch (error) {
// Ignore cleanup errors
}
await restoreTestEnv();
});

describe("listDiagrams", () => {
Expand Down
28 changes: 6 additions & 22 deletions test/file-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,18 @@ import {
getConfigDir,
validateSavePath,
} from "../src/file-utils.js";
import { writeFile, unlink, mkdir, rmdir, readdir, mkdtemp } from "fs/promises";
import { unlink, mkdir, rmdir, readdir, mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { setupTestEnv, restoreTestEnv } from "./helpers/env-helpers.js";

describe("File Utilities", () => {
let originalHome: string | undefined;

// Ensure tests operate in a temporary HOME to avoid touching real config
beforeAll(async () => {
originalHome = process.env.HOME;
const tempHome = await mkdtemp(join(tmpdir(), "claude-mermaid-test-home-"));
process.env.HOME = tempHome;
await setupTestEnv();
});

afterAll(() => {
if (originalHome) {
process.env.HOME = originalHome;
} else {
delete process.env.HOME;
}
afterAll(async () => {
await restoreTestEnv();
});
describe("getLiveDir", () => {
it("should return path containing .config/claude-mermaid/live", () => {
Expand Down Expand Up @@ -217,15 +209,7 @@ describe("File Utilities", () => {
});

afterEach(async () => {
try {
const files = await readdir(testDir);
for (const file of files) {
await unlink(join(testDir, file));
}
await rmdir(testDir);
} catch {
// Ignore errors
}
await rm(testDir, { recursive: true, force: true }).catch(() => {});
});

it("should save and load diagram source", async () => {
Expand Down
146 changes: 33 additions & 113 deletions test/handlers.test.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,30 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { handleMermaidPreview, handleMermaidSave } from "../src/handlers.js";
import { getPreviewDir, getDiagramFilePath } from "../src/file-utils.js";
import { mkdir, readdir, unlink, access, mkdtemp } from "fs/promises";
import { join } from "path";
import { readdir, unlink, access } from "fs/promises";
import { execFile } from "child_process";
import { tmpdir } from "os";
import { setupTestEnvWithPreview, restoreTestEnv } from "./helpers/env-helpers.js";

// Mock execFile to avoid actually running mmdc and create fake output files
vi.mock("child_process", () => ({
execFile: vi.fn((_file: string, args: string[], callback: Function) => {
// Find the output file from args array
const outputIndex = args.indexOf("-o");
if (outputIndex !== -1 && outputIndex + 1 < args.length) {
const outputFile = args[outputIndex + 1];
// Create a fake output file synchronously
const fs = require("fs");
const path = require("path");
const dir = path.dirname(outputFile);

// Ensure directory exists
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}

// Write fake content based on file extension
const ext = path.extname(outputFile);
if (ext === ".svg") {
fs.writeFileSync(outputFile, "<svg>test</svg>", "utf-8");
} else if (ext === ".png") {
// Write minimal PNG header
fs.writeFileSync(outputFile, Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
} else if (ext === ".pdf") {
// Write minimal PDF header
fs.writeFileSync(outputFile, "%PDF-1.4\n", "utf-8");
} else {
fs.writeFileSync(outputFile, "test", "utf-8");
Expand All @@ -44,7 +37,6 @@ vi.mock("child_process", () => ({
}),
}));

// Mock live server functions
vi.mock("../src/live-server.js", () => ({
ensureLiveServer: vi.fn(async () => 3737),
addLiveDiagram: vi.fn(async () => {}),
Expand All @@ -54,53 +46,24 @@ vi.mock("../src/live-server.js", () => ({
describe("handleMermaidPreview", () => {
const testPreviewId = "test-preview";
let testDir: string;
let originalHome: string | undefined;
let originalXdgConfig: string | undefined;

beforeEach(async () => {
// Override config dirs to use a temp HOME/XDG path for isolation
originalHome = process.env.HOME;
originalXdgConfig = process.env.XDG_CONFIG_HOME;
const tempHome = await mkdtemp(join(tmpdir(), "claude-mermaid-test-home-"));
const tempConfigDir = join(tempHome, ".config");
process.env.HOME = tempHome;
process.env.XDG_CONFIG_HOME = tempConfigDir;

await mkdir(tempConfigDir, { recursive: true });
testDir = getPreviewDir(testPreviewId);
await mkdir(testDir, { recursive: true });
testDir = await setupTestEnvWithPreview(testPreviewId);
});

afterEach(async () => {
// Restore original config env vars
if (originalHome) {
process.env.HOME = originalHome;
} else {
delete process.env.HOME;
}

if (originalXdgConfig) {
process.env.XDG_CONFIG_HOME = originalXdgConfig;
} else {
delete process.env.XDG_CONFIG_HOME;
}
await restoreTestEnv();
});

it("should throw error when diagram parameter is missing", async () => {
await expect(
handleMermaidPreview({
diagram: undefined,
preview_id: testPreviewId,
})
handleMermaidPreview({ diagram: undefined, preview_id: testPreviewId })
).rejects.toThrow("diagram parameter is required");
});

it("should throw error when preview_id parameter is missing", async () => {
await expect(
handleMermaidPreview({
diagram: "graph TD; A-->B",
preview_id: undefined,
})
handleMermaidPreview({ diagram: "graph TD; A-->B", preview_id: undefined })
).rejects.toThrow("preview_id parameter is required");
});

Expand Down Expand Up @@ -176,69 +139,44 @@ describe("handleMermaidPreview", () => {

it("should include stderr details in error when rendering fails", async () => {
const mockExecFile = vi.mocked(execFile);
const originalImpl = mockExecFile.getMockImplementation()!;

try {
mockExecFile.mockImplementation((_file: string, _args: any, callback: any) => {
const error: any = new Error("Command failed: npx mmdc");
error.stderr = "Parse error on line 3: invalid syntax near 'graph'";
callback(error, { stdout: "", stderr: error.stderr });
});
mockExecFile.mockImplementationOnce((_file: string, _args: any, callback: any) => {
const error: any = new Error("Command failed: npx mmdc");
error.stderr = "Parse error on line 3: invalid syntax near 'graph'";
callback(error, { stdout: "", stderr: error.stderr });
});

const result = await handleMermaidPreview({
diagram: "invalid diagram syntax",
preview_id: testPreviewId,
});
const result = await handleMermaidPreview({
diagram: "invalid diagram syntax",
preview_id: testPreviewId,
});

expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Parse error on line 3");
expect(result.content[0].text).toContain("Command failed");
} finally {
mockExecFile.mockImplementation(originalImpl);
}
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Parse error on line 3");
expect(result.content[0].text).toContain("Command failed");
});

it("should show original error message when stderr is empty", async () => {
const mockExecFile = vi.mocked(execFile);
const originalImpl = mockExecFile.getMockImplementation()!;

try {
mockExecFile.mockImplementation((_file: string, _args: any, callback: any) => {
const error = new Error("Command failed: npx mmdc");
callback(error, { stdout: "", stderr: "" });
});
mockExecFile.mockImplementationOnce((_file: string, _args: any, callback: any) => {
const error = new Error("Command failed: npx mmdc");
callback(error, { stdout: "", stderr: "" });
});

const result = await handleMermaidPreview({
diagram: "invalid diagram syntax",
preview_id: testPreviewId,
});
const result = await handleMermaidPreview({
diagram: "invalid diagram syntax",
preview_id: testPreviewId,
});

expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Command failed");
} finally {
mockExecFile.mockImplementation(originalImpl);
}
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Command failed");
});
});

describe("handleMermaidSave", () => {
const testPreviewId = "test-save";
let testDir: string;
let originalHome: string | undefined;
let originalXdgConfig: string | undefined;

beforeEach(async () => {
// Override config dirs to use a temp HOME/XDG path for isolation
originalHome = process.env.HOME;
originalXdgConfig = process.env.XDG_CONFIG_HOME;
const tempHome = await mkdtemp(join(tmpdir(), "claude-mermaid-test-home-"));
const tempConfigDir = join(tempHome, ".config");
process.env.HOME = tempHome;
process.env.XDG_CONFIG_HOME = tempConfigDir;

await mkdir(tempConfigDir, { recursive: true });
testDir = getPreviewDir(testPreviewId);
await mkdir(testDir, { recursive: true });
await setupTestEnvWithPreview(testPreviewId);

await handleMermaidPreview({
diagram: "graph TD; A-->B",
Expand All @@ -248,35 +186,18 @@ describe("handleMermaidSave", () => {
});

afterEach(async () => {
// Restore original config env vars
if (originalHome) {
process.env.HOME = originalHome;
} else {
delete process.env.HOME;
}

if (originalXdgConfig) {
process.env.XDG_CONFIG_HOME = originalXdgConfig;
} else {
delete process.env.XDG_CONFIG_HOME;
}
await restoreTestEnv();
});

it("should throw error when save_path parameter is missing", async () => {
await expect(
handleMermaidSave({
save_path: undefined,
preview_id: testPreviewId,
})
handleMermaidSave({ save_path: undefined, preview_id: testPreviewId })
).rejects.toThrow("save_path parameter is required");
});

it("should throw error when preview_id parameter is missing", async () => {
await expect(
handleMermaidSave({
save_path: "./test.svg",
preview_id: undefined,
})
handleMermaidSave({ save_path: "./test.svg", preview_id: undefined })
).rejects.toThrow("preview_id parameter is required");
});

Expand Down Expand Up @@ -320,10 +241,9 @@ describe("handleMermaidSave", () => {
});

it("should handle missing diagram source when saving", async () => {
const nonExistentId = "non-existent-preview";
const result = await handleMermaidSave({
save_path: "/tmp/test-diagram.svg",
preview_id: nonExistentId,
preview_id: "non-existent-preview",
});

expect(result.isError).toBe(true);
Expand Down
Loading