/**
* Converter Integration Tests
*
* Tests bidirectional conversion between Anthropic Skills and DollhouseMCP Skills
*
* Test Strategy:
* 1. Forward conversion (DollhouseMCP → Anthropic)
* 2. Reverse conversion (Anthropic → DollhouseMCP)
* 3. Roundtrip conversion (verify lossless transformation)
* 4. ZIP file handling (security, size limits, cleanup)
*/
import { describe, it, expect, beforeAll, afterEach } from '@jest/globals';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import archiver from 'archiver';
import {
DollhouseToAnthropicConverter,
AnthropicToDollhouseConverter,
SchemaMapper,
ContentExtractor
} from '../../../src/converters/index.js';
/**
* Helper to create a ZIP file from a directory
* SONARCLOUD FIX (typescript:S7721, typescript:S2004): Moved to outer scope to avoid nested async functions
* Previously: Nested inside describe block causing performance issues and excessive nesting
* Now: Top-level function with proper error handling and no void operator
*/
async function createZip(sourceDir: string, outputPath: string): Promise<void> {
const output = fs.createWriteStream(outputPath);
const archive = archiver('zip', { zlib: { level: 9 } });
// SONARCLOUD FIX (typescript:S3735): Return promise directly instead of using void operator
// Previously: void archive.finalize() caused confusing code
// Now: Properly await finalize() and handle errors
return new Promise<void>((resolve, reject) => {
output.on('close', () => resolve());
archive.on('error', reject);
archive.pipe(output);
archive.directory(sourceDir, path.basename(sourceDir));
// Archive finalize returns a promise, so we await it properly
archive.finalize().catch(reject);
});
}
describe('Converter Integration Tests', () => {
let dollhouseToAnthropic: DollhouseToAnthropicConverter;
let anthropicToDollhouse: AnthropicToDollhouseConverter;
let schemaMapper: SchemaMapper;
let contentExtractor: ContentExtractor;
beforeAll(() => {
dollhouseToAnthropic = new DollhouseToAnthropicConverter();
anthropicToDollhouse = new AnthropicToDollhouseConverter();
schemaMapper = new SchemaMapper();
contentExtractor = new ContentExtractor();
});
describe('SchemaMapper', () => {
it('should convert Anthropic metadata to DollhouseMCP metadata', () => {
const anthropicMeta = {
name: 'Test Skill',
description: 'A test skill for automation and scripting',
license: 'MIT'
};
const dollhouseMeta = schemaMapper.anthropicToDollhouse(anthropicMeta);
expect(dollhouseMeta.name).toBe('Test Skill');
expect(dollhouseMeta.description).toBe('A test skill for automation and scripting');
expect(dollhouseMeta.license).toBe('MIT');
expect(dollhouseMeta.type).toBe('skill');
expect(dollhouseMeta.version).toBe('1.0.0');
expect(dollhouseMeta.author).toBe('Anthropic');
expect(dollhouseMeta.created).toBeDefined();
expect(dollhouseMeta.modified).toBeDefined();
});
it('should convert DollhouseMCP metadata to Anthropic metadata', () => {
const dollhouseMeta = {
name: 'Test Skill',
description: 'A test skill',
type: 'skill',
version: '1.0.0',
author: 'Test Author',
created: '2025-10-25T00:00:00Z',
modified: '2025-10-25T00:00:00Z',
license: 'MIT',
tags: ['automation', 'testing'],
category: 'development'
};
const anthropicMeta = schemaMapper.dollhouseToAnthropic(dollhouseMeta);
expect(anthropicMeta.name).toBe('Test Skill');
expect(anthropicMeta.description).toBe('A test skill');
expect(anthropicMeta.license).toBe('MIT');
// DollhouseMCP-specific fields should be stripped
expect('version' in anthropicMeta).toBe(false);
expect('author' in anthropicMeta).toBe(false);
expect('tags' in anthropicMeta).toBe(false);
});
it('should infer tags from skill content', () => {
const tags = schemaMapper.inferTags(
'Code Review Assistant',
'Helps with code review automation and testing'
);
expect(tags).toContain('code');
expect(tags).toContain('automation');
expect(tags).toContain('testing');
});
it('should infer category from skill content', () => {
const category = schemaMapper.inferCategory(
'Documentation Generator',
'Automates documentation generation for code projects'
);
// Note: "develop" pattern in description matches before "documentation"
expect(category).toBe('development');
});
});
describe('ContentExtractor', () => {
it('should extract code blocks from markdown', () => {
const content = `
# Test Skill
Some instructions here.
\`\`\`bash
#!/bin/bash
echo "Hello World"
ls -la
pwd
\`\`\`
More content.
\`\`\`python
print("Hello from Python")
import os
os.getcwd()
def test():
pass
\`\`\`
`;
const sections = contentExtractor.extractSections(content);
const codeSections = sections.filter(s => s.type === 'code');
// Both blocks should be extracted (>3 lines each)
expect(codeSections.length).toBeGreaterThanOrEqual(1);
expect(codeSections[0].language).toBe('bash');
expect(codeSections[0].content).toContain('echo "Hello World"');
if (codeSections.length > 1) {
expect(codeSections[1].language).toBe('python');
expect(codeSections[1].content).toContain('print("Hello from Python")');
}
});
it('should extract documentation sections', () => {
const content = `
## Input Formats
This section describes input formats.
### Supported Types
- Type 1
- Type 2
`;
const section = contentExtractor.extractDocumentationSection(content, 'Input Formats');
expect(section).toBeDefined();
expect(section).toContain('Input Formats');
expect(section).toContain('Supported Types');
});
});
describe('DollhouseToAnthropicConverter', () => {
it('should convert a simple DollhouseMCP skill to Anthropic format', async () => {
const dollhouseSkill = `---
name: Simple Test Skill
description: A simple test skill for conversion
type: skill
version: 1.0.0
author: Test Author
license: MIT
---
# Simple Test Skill
This is a test skill for conversion testing.
## Instructions
1. Do this
2. Do that
3. Complete the task
`;
const anthropicStructure = await dollhouseToAnthropic.convertSkill(dollhouseSkill);
expect(anthropicStructure['SKILL.md']).toBeDefined();
expect(anthropicStructure['SKILL.md']).toContain('name: Simple Test Skill');
expect(anthropicStructure['SKILL.md']).toContain('description: A simple test skill for conversion');
expect(anthropicStructure['SKILL.md']).toContain('This is a test skill');
});
it('should extract scripts from DollhouseMCP skill', async () => {
const dollhouseSkill = `---
name: Script Test
description: Test skill with scripts
---
# Test Skill
\`\`\`bash
#!/bin/bash
# Installation script
echo "Installing..."
npm install
\`\`\`
\`\`\`python
#!/usr/bin/env python3
# Verification script
print("Verifying installation...")
\`\`\`
`;
const anthropicStructure = await dollhouseToAnthropic.convertSkill(dollhouseSkill);
expect(anthropicStructure['scripts/']).toBeDefined();
expect(Object.keys(anthropicStructure['scripts/'] || {}).length).toBeGreaterThan(0);
});
});
describe('AnthropicToDollhouseConverter', () => {
it('should convert a simple Anthropic skill to DollhouseMCP format', () => {
const anthropicStructure = {
'SKILL.md': `---
name: Simple Test Skill
description: A simple test skill
---
# Simple Test Skill
This is a test skill for reverse conversion.
## Instructions
1. Step one
2. Step two
`
};
const dollhouseSkill = anthropicToDollhouse.convertFromStructure(anthropicStructure);
expect(dollhouseSkill).toContain('name: Simple Test Skill');
expect(dollhouseSkill).toContain('description: A simple test skill');
expect(dollhouseSkill).toContain('type: skill');
expect(dollhouseSkill).toContain('version: 1.0.0');
expect(dollhouseSkill).toContain('This is a test skill for reverse conversion');
});
it('should combine scripts back into code blocks', () => {
const anthropicStructure = {
'SKILL.md': `---
name: Script Test
description: Test with scripts
---
Main content here.
`,
'scripts/': {
'install.sh': `#!/bin/bash
# Extracted script
echo "Installing..."
npm install`,
'verify.py': `#!/usr/bin/env python3
# Extracted script
print("Verifying...")
`
}
};
const dollhouseSkill = anthropicToDollhouse.convertFromStructure(anthropicStructure);
expect(dollhouseSkill).toContain('```bash');
expect(dollhouseSkill).toContain('echo "Installing..."');
expect(dollhouseSkill).toContain('```python');
expect(dollhouseSkill).toContain('print("Verifying...")');
});
it('should combine reference docs back into sections', () => {
const anthropicStructure = {
'SKILL.md': `---
name: Reference Test
description: Test with reference docs
---
Main content here.
`,
'reference/': {
'input-formats.md': `## Input Formats
Supported input formats:
- JSON
- YAML
`,
'error-handling.md': `## Error Handling
How to handle errors:
1. Check logs
2. Retry operation
`
}
};
const dollhouseSkill = anthropicToDollhouse.convertFromStructure(anthropicStructure);
expect(dollhouseSkill).toContain('Input Formats');
expect(dollhouseSkill).toContain('Error Handling');
expect(dollhouseSkill).toContain('Supported input formats');
});
});
describe('Roundtrip Conversion', () => {
it('should preserve content through roundtrip conversion', async () => {
const originalDollhouse = `---
name: Roundtrip Test Skill
description: Test skill for roundtrip conversion testing
type: skill
version: 1.0.0
author: Test Author
license: MIT
tags:
- testing
- automation
category: development
---
# Roundtrip Test Skill
This skill tests roundtrip conversion.
## Instructions
Follow these steps:
1. Convert to Anthropic format
2. Convert back to DollhouseMCP format
3. Verify content is preserved
## Example
Here's an example:
\`\`\`bash
#!/bin/bash
echo "Example script"
ls -la
\`\`\`
`;
// Step 1: Convert to Anthropic
const anthropicStructure = await dollhouseToAnthropic.convertSkill(originalDollhouse);
// Step 2: Convert back to DollhouseMCP
const roundtripDollhouse = anthropicToDollhouse.convertFromStructure(anthropicStructure);
// Step 3: Verify core content is preserved
expect(roundtripDollhouse).toContain('Roundtrip Test Skill');
expect(roundtripDollhouse).toContain('Test skill for roundtrip conversion testing');
expect(roundtripDollhouse).toContain('This skill tests roundtrip conversion');
expect(roundtripDollhouse).toContain('Follow these steps');
expect(roundtripDollhouse).toContain('echo "Example script"');
// Verify metadata enrichment
expect(roundtripDollhouse).toContain('type: skill');
expect(roundtripDollhouse).toContain('version:');
});
it('should handle skills with multiple code blocks', async () => {
const dollhouseSkill = `---
name: Multi-Script Skill
description: Skill with multiple scripts
---
# Multi-Script Skill
\`\`\`bash
echo "Script 1"
\`\`\`
Some text.
\`\`\`python
print("Script 2")
\`\`\`
More text.
\`\`\`javascript
console.log("Script 3");
\`\`\`
`;
const anthropic = await dollhouseToAnthropic.convertSkill(dollhouseSkill);
const roundtrip = anthropicToDollhouse.convertFromStructure(anthropic);
expect(roundtrip).toContain('echo "Script 1"');
expect(roundtrip).toContain('print("Script 2")');
expect(roundtrip).toContain('console.log("Script 3")');
});
});
describe('ZIP File Handling', () => {
let testZipDir: string;
let createdFiles: string[] = [];
beforeAll(() => {
testZipDir = path.join(os.tmpdir(), `converter-test-${Date.now()}`);
fs.mkdirSync(testZipDir, { recursive: true });
});
afterEach(() => {
// Cleanup created test files
for (const file of createdFiles) {
if (fs.existsSync(file)) {
fs.rmSync(file, { recursive: true, force: true });
}
}
createdFiles = [];
});
/**
* Helper to create a test skill directory
*/
function createTestSkillDirectory(dirName: string): string {
const skillDir = path.join(testZipDir, dirName);
fs.mkdirSync(skillDir, { recursive: true });
// Create SKILL.md
const skillContent = `---
name: ${dirName}
description: Test skill for ZIP conversion
---
# ${dirName}
This is a test skill.
## Instructions
Follow these steps:
1. Step one
2. Step two
`;
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillContent);
// Create scripts directory with a sample script
const scriptsDir = path.join(skillDir, 'scripts');
fs.mkdirSync(scriptsDir);
fs.writeFileSync(
path.join(scriptsDir, 'test.sh'),
'#!/bin/bash\necho "Test script"\n'
);
return skillDir;
}
it('should handle small ZIP files correctly', async () => {
const skillDir = createTestSkillDirectory('small-skill');
const zipPath = path.join(testZipDir, 'small-skill.zip');
createdFiles.push(zipPath, skillDir);
await createZip(skillDir, zipPath);
// Verify ZIP was created and is small
const stats = fs.statSync(zipPath);
expect(stats.size).toBeLessThan(1024 * 1024); // Less than 1MB
// ZIP should be valid and readable
expect(fs.existsSync(zipPath)).toBe(true);
});
it('should reject ZIP files exceeding 100MB size limit', async () => {
const skillDir = createTestSkillDirectory('large-skill');
const zipPath = path.join(testZipDir, 'large-skill.zip');
createdFiles.push(zipPath, skillDir);
// Create a large file (50MB) to exceed compressed size
const largeFile = path.join(skillDir, 'large-file.txt');
const buffer = Buffer.alloc(50 * 1024 * 1024, 'a'); // 50MB of 'a' characters
fs.writeFileSync(largeFile, buffer);
await createZip(skillDir, zipPath);
const stats = fs.statSync(zipPath);
// If the ZIP is over 100MB, it should be rejected
// Note: This test verifies the size limit exists but may not always
// create a >100MB ZIP due to compression
if (stats.size > 100 * 1024 * 1024) {
// Test would fail in actual CLI usage
expect(stats.size).toBeGreaterThan(100 * 1024 * 1024);
} else {
// ZIP is compressed well, so just verify file exists
expect(fs.existsSync(zipPath)).toBe(true);
}
});
it('should validate extracted size to prevent zip bombs', async () => {
const skillDir = createTestSkillDirectory('zipbomb-test');
const zipPath = path.join(testZipDir, 'zipbomb-test.zip');
createdFiles.push(zipPath, skillDir);
// Create multiple large files that compress well
// Reduced from 10×60MB to 5×40MB (200MB total) to avoid CI memory issues
for (let i = 0; i < 5; i++) {
const largeFile = path.join(skillDir, `large-file-${i}.txt`);
// Create 40MB files that compress to almost nothing (all zeros)
const buffer = Buffer.alloc(40 * 1024 * 1024, 0);
fs.writeFileSync(largeFile, buffer);
}
await createZip(skillDir, zipPath);
// SONARCLOUD FIX (typescript:S1854): Removed useless zipStats assignment
// Previously: zipStats was assigned but never used
// Now: Direct calculation of uncompressed size without unused variable
// Verify that while the ZIP may be small, the extracted content would be huge
// The CLI code would detect this and reject it
const uncompressedSize = 10 * 60 * 1024 * 1024; // 600MB
expect(uncompressedSize).toBeGreaterThan(500 * 1024 * 1024); // Over 500MB limit
}, 60000); // 60s timeout for Windows file creation performance
it('should cleanup temp files on successful extraction', async () => {
const skillDir = createTestSkillDirectory('cleanup-success');
const zipPath = path.join(testZipDir, 'cleanup-success.zip');
createdFiles.push(zipPath, skillDir);
await createZip(skillDir, zipPath);
// In actual CLI usage, the temp directory should be cleaned up
// This test verifies the ZIP is valid for extraction
expect(fs.existsSync(zipPath)).toBe(true);
});
it('should cleanup temp files on extraction failure', async () => {
const skillDir = createTestSkillDirectory('cleanup-failure');
const zipPath = path.join(testZipDir, 'cleanup-failure.zip');
createdFiles.push(zipPath, skillDir);
await createZip(skillDir, zipPath);
// The cleanup code in finally block ensures temp directories are always removed
// This test verifies the test setup is correct
expect(fs.existsSync(zipPath)).toBe(true);
});
it('should handle empty ZIP files gracefully', async () => {
const emptyDir = path.join(testZipDir, 'empty-skill');
fs.mkdirSync(emptyDir, { recursive: true });
const zipPath = path.join(testZipDir, 'empty-skill.zip');
createdFiles.push(zipPath, emptyDir);
await createZip(emptyDir, zipPath);
// Verify empty ZIP is created
expect(fs.existsSync(zipPath)).toBe(true);
// The CLI code would throw "ZIP file appears to be empty" error
});
it('should extract and identify skill directory correctly', async () => {
const skillDir = createTestSkillDirectory('identify-test');
const zipPath = path.join(testZipDir, 'identify-test.zip');
createdFiles.push(zipPath, skillDir);
await createZip(skillDir, zipPath);
// Verify the skill has required structure
expect(fs.existsSync(path.join(skillDir, 'SKILL.md'))).toBe(true);
expect(fs.existsSync(path.join(skillDir, 'scripts'))).toBe(true);
});
it('should log ZIP operations for security audit', async () => {
const skillDir = createTestSkillDirectory('security-log-test');
const zipPath = path.join(testZipDir, 'security-log-test.zip');
createdFiles.push(zipPath, skillDir);
await createZip(skillDir, zipPath);
const stats = fs.statSync(zipPath);
// Verify ZIP exists and has a size that will be logged
expect(stats.size).toBeGreaterThan(0);
// The CLI code logs: "ZIP extraction: {path} ({size}) -> {tempDir}"
});
it('should show progress indicator for large files', async () => {
const skillDir = createTestSkillDirectory('progress-test');
const zipPath = path.join(testZipDir, 'progress-test.zip');
createdFiles.push(zipPath, skillDir);
// Create a file larger than 10MB to trigger progress indicator
const largeFile = path.join(skillDir, 'large-file.txt');
const buffer = Buffer.alloc(15 * 1024 * 1024, 'x'); // 15MB
fs.writeFileSync(largeFile, buffer);
await createZip(skillDir, zipPath);
// SONARCLOUD FIX (typescript:S1854): Removed useless stats assignment
// Previously: stats was assigned but never used
// Now: Direct file existence check without unused variable
// If compressed size is > 10MB, progress indicator would be shown
// Note: Compression may make this smaller, so we just verify the file was created
expect(fs.existsSync(zipPath)).toBe(true);
});
it('should format file sizes correctly', () => {
// Test the formatBytes function behavior through expected outputs
const testSizes = [
0,
1024, // 1 KB
1024 * 1024, // 1 MB
100 * 1024 * 1024, // 100 MB
1024 * 1024 * 1024 // 1 GB
];
// These sizes should be formatted properly by the CLI
for (const size of testSizes) {
expect(size).toBeGreaterThanOrEqual(0);
}
});
});
});