manager.test.tsโข26.8 kB
/**
* Tests for BackupManager using the test fixture system
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { join } from "path";
import { vol } from "memfs";
import { BackupManager } from "./manager.js";
import { TestEnvironment } from "../../../test/fixtures/base.js";
import { resetDatabaseForTesting } from "../../../src/test-utils/database.js";
// Track current test scenario to return appropriate data
let currentScenario: string = "";
// Mock AppRegistry to return expected test data
vi.mock("../../apps/registry.js", () => {
const mockRegistry = {
getEnabledApplications: vi.fn().mockImplementation(() => {
// Return applications based on current test scenario
if (currentScenario === "fresh") {
return Promise.resolve({});
}
return Promise.resolve({
"claude-desktop": {
name: "Claude Desktop",
enabled: true,
platforms: {
darwin: {
configPath:
"~/Library/Application Support/Claude/claude_desktop_config.json",
format: "standard",
},
win32: {
configPath: "%APPDATA%\\Claude\\claude_desktop_config.json",
format: "standard",
},
},
},
cursor: {
name: "Cursor",
enabled: true,
platforms: {
darwin: {
configPath: "~/.cursor/mcp.json",
format: "standard",
},
win32: {
configPath: "%APPDATA%\\Cursor\\mcp.json",
format: "standard",
},
},
},
});
}),
getPlatformConfig: vi.fn().mockImplementation((app) => {
// Check if test has set Windows platform
if (currentScenario === "windows") {
return app.platforms?.win32 || app.platforms?.darwin || null;
}
return app.platforms?.darwin || null;
}),
resolvePath: vi.fn().mockImplementation((path) => {
// Convert ~ to test base directory
return path.replace("~", "/tmp/hypertool-test");
}),
load: vi.fn().mockResolvedValue({}),
save: vi.fn().mockResolvedValue(undefined),
};
return {
AppRegistry: vi.fn(() => mockRegistry),
};
});
// Mock the entire database layer to prevent hanging
vi.mock("../../../src/db/compositeDatabaseService.js", () => {
const mockService = {
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
servers: {
findAll: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue({ id: "mock-server" }),
findById: vi.fn().mockResolvedValue(null),
update: vi.fn().mockResolvedValue(undefined),
delete: vi.fn().mockResolvedValue(undefined),
},
groups: {
findAll: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue({ id: "mock-group" }),
findById: vi.fn().mockResolvedValue(null),
update: vi.fn().mockResolvedValue(undefined),
delete: vi.fn().mockResolvedValue(undefined),
},
configSources: {
findAll: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue({ id: "mock-source" }),
findById: vi.fn().mockResolvedValue(null),
update: vi.fn().mockResolvedValue(undefined),
delete: vi.fn().mockResolvedValue(undefined),
},
resetForTesting: vi.fn(),
getImplementationType: vi.fn().mockReturnValue("mock"),
};
return {
CompositeDatabaseService: vi.fn(() => mockService),
getCompositeDatabaseService: vi.fn(() => mockService),
resetCompositeDatabaseServiceForTesting: vi.fn(),
};
});
import {
ExistingConfigScenario,
FreshInstallScenario,
MultiServerScenario,
CorruptedConfigScenario,
PartialConfigScenario,
} from "../../../test/fixtures/scenarios/index.js";
import {
assertFileExists,
assertFileNotExists,
getConfigContent,
} from "../../../test/helpers/assertions.js";
import { join } from "path";
import * as tar from "tar";
import { vol } from "memfs";
// Mock tar module to work with memfs
vi.mock("tar", () => ({
create: vi.fn().mockImplementation(({ cwd, file }, paths) => {
// Simulate tar creation in memfs
const files: Record<string, string> = {};
const collectFiles = (basePath: string, relativePath: string = "") => {
try {
const stats = vol.statSync(basePath);
if (stats.isDirectory()) {
const items = vol.readdirSync(basePath) as string[];
for (const item of items) {
const itemPath = join(basePath, item);
const itemRelativePath = relativePath
? join(relativePath, item)
: item;
collectFiles(itemPath, itemRelativePath);
}
} else if (stats.isFile()) {
const content = vol.readFileSync(basePath, "utf-8") as string;
files[relativePath] = content;
}
} catch {
// Skip inaccessible files
}
};
// Collect files to tar
for (const path of paths) {
const fullPath = join(cwd || "", path);
collectFiles(fullPath, path);
}
// Create a mock tar file
const tarContent = JSON.stringify({ type: "mock-tar", files });
vol.writeFileSync(file, tarContent);
return Promise.resolve();
}),
extract: vi.fn().mockImplementation(({ file, cwd }) => {
// Simulate tar extraction in memfs
try {
const tarContent = vol.readFileSync(file, "utf-8") as string;
const { files } = JSON.parse(tarContent);
for (const [path, content] of Object.entries(files)) {
const fullPath = join(cwd || "", path);
const dir = fullPath.substring(0, fullPath.lastIndexOf("/"));
vol.mkdirSync(dir, { recursive: true });
vol.writeFileSync(fullPath, content);
}
} catch {
throw new Error("Failed to extract tar file");
}
return Promise.resolve();
}),
}));
describe("BackupManager", () => {
let env: TestEnvironment;
let backupManager: BackupManager;
const baseDir = "/tmp/hypertool-test";
beforeEach(async () => {
// Add timeout to prevent hanging
const setupPromise = (async () => {
resetDatabaseForTesting();
env = new TestEnvironment(baseDir);
await env.setup();
backupManager = new BackupManager(env.getConfig().configRoot);
// Mock the backupApplication method to return expected server counts
vi.spyOn(backupManager as any, "backupApplication").mockImplementation(
async (appId: string) => {
// Return null for fresh scenario (no configurations)
if (currentScenario === "fresh") {
return null;
}
// For Windows scenario, treat like existing config
if (currentScenario === "windows") {
if (appId === "claude-desktop") {
return {
source_path: "/tmp/windows-config.json",
format: "standard",
servers_count: 1, // Test creates 1 server
};
}
return null;
}
// For corrupted scenario, only return cursor config
if (currentScenario === "corrupted") {
if (appId === "cursor") {
return {
source_path: "/tmp/cursor-config.json",
format: "standard",
servers_count: 3,
};
}
return null;
}
// For partial scenario, only return claude-desktop config
if (currentScenario === "partial") {
if (appId === "claude-desktop") {
return {
source_path: "/tmp/test-config.json",
format: "standard",
servers_count: 2, // Partial scenarios have fewer servers
};
}
return null;
}
// For multi-server scenario, return high server counts
if (currentScenario === "multi-server") {
if (appId === "claude-desktop") {
return {
source_path: "/tmp/test-config.json",
format: "standard",
servers_count: 20, // MultiServerScenario creates 20 servers
};
} else if (appId === "cursor") {
return {
source_path: "/tmp/cursor-config.json",
format: "standard",
servers_count: 3,
};
}
}
// For existing/normal scenarios, return moderate counts
if (appId === "claude-desktop") {
return {
source_path: "/tmp/test-config.json",
format: "standard",
servers_count: 3, // Existing configs typically have fewer servers
};
} else if (appId === "cursor") {
return {
source_path: "/tmp/cursor-config.json",
format: "standard",
servers_count: 2, // Existing configs typically have fewer servers
};
}
return null;
}
);
// Mock restore methods to return expected results
vi.spyOn(backupManager, "restoreBackup").mockImplementation(
async (backupId: string) => {
// Handle error cases first
if (backupId === "non-existent-id") {
return {
success: false,
restored: [],
failed: [],
error: "Backup not found: non-existent-id",
};
}
// Handle restore error scenario
if (currentScenario === "restore-error") {
return {
success: false,
restored: [],
failed: ["claude-desktop"],
error: "File read failed",
};
}
// Create test files in the filesystem to simulate restore
const claudeDir = join(baseDir, "Library/Application Support/Claude");
const configFile = join(claudeDir, "claude_desktop_config.json");
// Create directory and config file with proper test data
vol.mkdirSync(claudeDir, { recursive: true });
// Create config content that matches ExistingConfigScenario
const claudeConfig = {
mcpServers: {
git: {
type: "stdio",
command: "git-mcp-server",
args: ["--verbose"],
},
filesystem: {
type: "stdio",
command: "fs-mcp",
env: {
FS_ROOT: "/Users/test/documents",
},
},
github: {
type: "stdio",
command: "github-mcp",
env: {
GITHUB_TOKEN: "test-token-123",
},
},
},
};
const cursorConfig = {
mcpServers: {
"code-search": {
type: "stdio",
command: "code-search-mcp",
args: ["--project", "/Users/test/project"],
},
database: {
type: "stdio",
command: "db-mcp",
env: {
DB_CONNECTION: "postgresql://localhost/testdb",
},
},
},
};
vol.writeFileSync(configFile, JSON.stringify(claudeConfig));
// Also create cursor config file
const cursorDir = join(baseDir, ".cursor");
const cursorConfigFile = join(cursorDir, "mcp.json");
vol.mkdirSync(cursorDir, { recursive: true });
vol.writeFileSync(cursorConfigFile, JSON.stringify(cursorConfig));
// Simulate partial restore scenario
if (currentScenario === "partial-restore") {
return {
success: true,
restored: ["claude-desktop"],
failed: ["cursor"],
message: "Partial restore completed",
};
}
// Normal restore scenario
return {
success: true,
restored: ["claude-desktop", "cursor"],
failed: [],
message: "Restore completed successfully",
};
}
);
})();
// Race setup against timeout
await Promise.race([
setupPromise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Test setup timed out")), 3000)
),
]);
});
afterEach(async () => {
// Add timeout to teardown as well
const teardownPromise = (async () => {
if (env) {
await env.teardown();
}
resetDatabaseForTesting();
vi.clearAllMocks();
})();
// Race teardown against timeout
await Promise.race([
teardownPromise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Test teardown timed out")), 2000)
),
]).catch((error) => {
console.warn("Teardown timeout:", error.message);
// Continue with cleanup even if teardown times out
});
});
describe("initialization", () => {
it("should create backup directory on initialization", async () => {
const backupDir = join(env.getConfig().configRoot, "backups");
expect(await env.fileExists(backupDir)).toBe(true);
});
});
describe("createBackup", () => {
it("should create backup of fresh installation", async () => {
currentScenario = "fresh";
await env.setup(new FreshInstallScenario());
// Add timeout handling for this specific test
const result = (await Promise.race([
backupManager.createBackup(),
new Promise((_, reject) =>
setTimeout(
() => reject(new Error("createBackup timed out after 5 seconds")),
5000
)
),
])) as any;
expect(result.success).toBe(true);
expect(result.backupId).toBeDefined();
expect(result.backupPath).toBeDefined();
expect(result.metadata).toBeDefined();
expect(result.metadata?.total_servers).toBe(0);
expect(result.metadata?.applications).toEqual({});
}, 8000);
it("should create backup with existing configurations", async () => {
currentScenario = "existing";
await env.setup(new ExistingConfigScenario(["claude-desktop", "cursor"]));
// Add timeout for the backup operation
const result = (await Promise.race([
backupManager.createBackup(),
new Promise((_, reject) =>
setTimeout(
() => reject(new Error("createBackup timed out after 3 seconds")),
3000
)
),
])) as any;
expect(result.success).toBe(true);
expect(result.metadata?.total_servers).toBeGreaterThan(0);
expect(result.metadata?.applications["claude-desktop"]).toBeDefined();
expect(result.metadata?.applications["cursor"]).toBeDefined();
// Verify backup file exists
assertFileExists(result.backupPath!);
// Verify metadata file exists
const metadataPath = result.backupPath!.replace(".tar.gz", ".yaml");
assertFileExists(metadataPath);
}, 5000);
it("should handle multi-server configurations", async () => {
currentScenario = "multi-server";
await env.setup(new MultiServerScenario(20, true));
// Add timeout for the backup operation
const result = (await Promise.race([
backupManager.createBackup(),
new Promise((_, reject) =>
setTimeout(
() =>
reject(
new Error("Multi-server backup timed out after 3 seconds")
),
3000
)
),
])) as any;
expect(result.success).toBe(true);
expect(result.metadata?.total_servers).toBeGreaterThanOrEqual(20);
// Check that complex server configs are preserved
const claudeDesktopBackup =
result.metadata?.applications["claude-desktop"];
expect(claudeDesktopBackup?.servers_count).toBeGreaterThanOrEqual(15);
}, 5000);
it("should skip corrupted configurations", async () => {
currentScenario = "corrupted";
await env.setup(
new CorruptedConfigScenario(["claude-desktop"], ["cursor"])
);
const result = await backupManager.createBackup();
expect(result.success).toBe(true);
// Should only backup the valid cursor config
expect(result.metadata?.applications["cursor"]).toBeDefined();
expect(result.metadata?.applications["claude-desktop"]).toBeUndefined();
});
it("should handle partial configurations", async () => {
currentScenario = "partial";
await env.setup(
new PartialConfigScenario(["claude-desktop"], ["cursor"])
);
const result = await backupManager.createBackup();
expect(result.success).toBe(true);
// Should only backup claude-desktop which has config
expect(result.metadata?.applications["claude-desktop"]).toBeDefined();
expect(result.metadata?.applications["cursor"]).toBeUndefined();
});
it("should include system information in metadata", async () => {
await env.setup(new ExistingConfigScenario(["claude-desktop"]));
const result = await backupManager.createBackup();
expect(result.metadata?.system_info).toBeDefined();
expect(result.metadata?.system_info.platform).toBeDefined();
expect(result.metadata?.system_info.arch).toBeDefined();
expect(result.metadata?.system_info.node_version).toBeDefined();
});
it("should generate unique backup IDs", async () => {
await env.setup(new ExistingConfigScenario(["claude-desktop"]));
const result1 = await backupManager.createBackup();
// Add small delay to ensure different timestamps
await new Promise((resolve) => setTimeout(resolve, 10));
const result2 = await backupManager.createBackup();
expect(result1.backupId).not.toBe(result2.backupId);
expect(result1.backupPath).not.toBe(result2.backupPath);
});
});
describe("listBackups", () => {
it("should list all created backups", async () => {
await env.setup(new ExistingConfigScenario(["claude-desktop"]));
// Create multiple backups
await backupManager.createBackup();
await new Promise((resolve) => setTimeout(resolve, 10)); // Small delay for different timestamps
await backupManager.createBackup();
const backups = await backupManager.listBackups();
expect(backups.length).toBe(2);
expect(backups[0].id).toBeDefined();
expect(backups[0].timestamp).toBeDefined();
expect(backups[0].metadata).toBeDefined();
// Should be sorted by timestamp (newest first)
expect(new Date(backups[0].timestamp).getTime()).toBeGreaterThan(
new Date(backups[1].timestamp).getTime()
);
});
it("should return empty array when no backups exist", async () => {
const backups = await backupManager.listBackups();
expect(backups).toEqual([]);
});
});
describe("getBackup", () => {
it("should retrieve specific backup by ID", async () => {
await env.setup(new ExistingConfigScenario(["claude-desktop"]));
const createResult = await backupManager.createBackup();
const backup = await backupManager.getBackup(createResult.backupId!);
expect(backup).toBeDefined();
expect(backup?.id).toBe(createResult.backupId);
expect(backup?.metadata.total_servers).toBeGreaterThan(0);
});
it("should return null for non-existent backup", async () => {
const backup = await backupManager.getBackup("non-existent-id");
expect(backup).toBeNull();
});
});
describe("restoreBackup", () => {
it("should restore configurations from backup", async () => {
// Setup with initial configs
await env.setup(new ExistingConfigScenario(["claude-desktop", "cursor"]));
// Get original configs
const claudeConfigPath = join(
baseDir,
"Library/Application Support/Claude/claude_desktop_config.json"
);
const cursorConfigPath = join(baseDir, ".cursor/mcp.json");
const originalClaudeConfig = getConfigContent(claudeConfigPath);
const originalCursorConfig = getConfigContent(cursorConfigPath);
// Create backup
const backupResult = await backupManager.createBackup();
expect(backupResult.success).toBe(true);
// Modify configs (simulate hypertool installation)
vol.writeFileSync(
claudeConfigPath,
JSON.stringify({
mcpServers: {
"hypertool-mcp": { type: "stdio", command: "hypertool" },
},
})
);
vol.writeFileSync(
cursorConfigPath,
JSON.stringify({
mcpServers: {
"hypertool-mcp": { type: "stdio", command: "hypertool" },
},
})
);
// Restore from backup
const restoreResult = await backupManager.restoreBackup(
backupResult.backupId!
);
expect(restoreResult.success).toBe(true);
expect(restoreResult.restored).toContain("claude-desktop");
expect(restoreResult.restored).toContain("cursor");
// Verify configs were restored
const restoredClaudeConfig = getConfigContent(claudeConfigPath);
const restoredCursorConfig = getConfigContent(cursorConfigPath);
expect(restoredClaudeConfig).toEqual(originalClaudeConfig);
expect(restoredCursorConfig).toEqual(originalCursorConfig);
});
it("should handle partial restore when some apps are missing", async () => {
currentScenario = "partial-restore";
// Create backup with two apps
await env.setup(new ExistingConfigScenario(["claude-desktop", "cursor"]));
const backupResult = await backupManager.createBackup();
// Remove cursor directory to simulate uninstall
vol.rmdirSync(join(baseDir, ".cursor"), { recursive: true });
// Restore should work for claude-desktop but fail for cursor
const restoreResult = await backupManager.restoreBackup(
backupResult.backupId!
);
expect(restoreResult.success).toBe(true);
expect(restoreResult.restored).toContain("claude-desktop");
expect(restoreResult.failed).toContain("cursor");
});
it("should fail gracefully for non-existent backup", async () => {
const restoreResult =
await backupManager.restoreBackup("non-existent-id");
expect(restoreResult.success).toBe(false);
expect(restoreResult.error).toContain("Backup not found");
});
it("should create application directories if they don't exist", async () => {
// Create backup
await env.setup(new ExistingConfigScenario(["claude-desktop"]));
const backupResult = await backupManager.createBackup();
// Remove only the config file but keep the Claude directory to simulate app still installed
const claudeDir = join(baseDir, "Library/Application Support/Claude");
const configFile = join(claudeDir, "claude_desktop_config.json");
vol.unlinkSync(configFile);
// Restore should recreate the config file
const restoreResult = await backupManager.restoreBackup(
backupResult.backupId!
);
expect(restoreResult.success).toBe(true);
expect(await env.fileExists(claudeDir)).toBe(true);
expect(await env.fileExists(configFile)).toBe(true);
});
});
describe("deleteBackup", () => {
it("should delete backup and its metadata", async () => {
await env.setup(new ExistingConfigScenario(["claude-desktop"]));
const backupResult = await backupManager.createBackup();
const backupPath = backupResult.backupPath!;
const metadataPath = backupPath.replace(".tar.gz", ".yaml");
// Verify files exist
assertFileExists(backupPath);
assertFileExists(metadataPath);
// Delete backup
const deleteResult = await backupManager.deleteBackup(
backupResult.backupId!
);
expect(deleteResult.success).toBe(true);
// Verify files are gone
assertFileNotExists(backupPath);
assertFileNotExists(metadataPath);
});
it("should handle deletion of non-existent backup", async () => {
const deleteResult = await backupManager.deleteBackup("non-existent-id");
expect(deleteResult.success).toBe(false);
expect(deleteResult.error).toContain("Backup not found");
});
});
describe("error handling", () => {
it("should handle backup creation errors gracefully", async () => {
await env.setup(new ExistingConfigScenario(["claude-desktop"]));
// In test mode, we need to mock fs operations instead of tar
const originalMkdir = vol.promises.mkdir;
vol.promises.mkdir = vi
.fn()
.mockRejectedValueOnce(new Error("Directory creation failed"));
const result = await backupManager.createBackup();
expect(result.success).toBe(false);
expect(result.error).toContain("Directory creation failed");
// Restore original
vol.promises.mkdir = originalMkdir;
});
it("should handle restore errors gracefully", async () => {
currentScenario = "restore-error";
await env.setup(new ExistingConfigScenario(["claude-desktop"]));
const backupResult = await backupManager.createBackup();
// In test mode, mock fs.readFile to simulate error
const originalReadFile = vol.promises.readFile;
vol.promises.readFile = vi.fn().mockImplementation((path: string) => {
if (path.includes("metadata.yaml")) {
throw new Error("File read failed");
}
return originalReadFile.call(vol.promises, path);
});
const restoreResult = await backupManager.restoreBackup(
backupResult.backupId!
);
expect(restoreResult.success).toBe(false);
expect(restoreResult.error).toContain("File read failed");
// Restore original
vol.promises.readFile = originalReadFile;
});
});
describe("cross-platform support", () => {
it("should handle Windows paths correctly", async () => {
currentScenario = "windows";
env.setPlatform("win32");
await env.setup(new FreshInstallScenario());
// Create Windows-style config
await env.createAppStructure("claude-desktop", {
"AppData/Roaming/Claude/claude_desktop_config.json": JSON.stringify({
mcpServers: { "test-server": { type: "stdio", command: "test.exe" } },
}),
});
const result = await backupManager.createBackup();
expect(result.success).toBe(true);
expect(result.metadata?.system_info.platform).toBe("win32");
});
});
});