submitContentMethod.test.tsโข12.1 kB
/**
* Unit tests for submitContent method improvements
* Tests parallel search, error handling, and logging enhancements
*/
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
describe('submitContent method improvements', () => {
describe('Parallel Search Implementation', () => {
it('should demonstrate parallel search pattern', async () => {
// This test validates the parallel search pattern used in submitContent
// NOTE: Using example types here - the actual code uses Object.values(ElementType)
// which dynamically includes ALL element types, whether there are 6, 10, or 100
const elementTypes = ['personas', 'skills', 'templates', 'agents', 'memories', 'ensembles'];
const searchResults: { type: string; startTime: number; endTime: number }[] = [];
// Simulate parallel searches
const searchPromises = elementTypes.map(async (type) => {
const startTime = Date.now();
// Simulate async file search with varying delays
await new Promise(resolve => setTimeout(resolve, Math.random() * 100));
const endTime = Date.now();
searchResults.push({ type, startTime, endTime });
// Return result for 'skills' to simulate finding a match
return type === 'skills' ? { type, file: `/path/to/${type}/sample.md` } : null;
});
// Wait for all searches using Promise.allSettled
const results = await Promise.allSettled(searchPromises);
// Verify all searches completed (works with any number of element types)
expect(results).toHaveLength(elementTypes.length);
expect(results.every(r => r.status === 'fulfilled')).toBe(true);
// Verify parallel execution - searches should overlap in time
const sortedByStart = [...searchResults].sort((a, b) => a.startTime - b.startTime);
const firstEnd = sortedByStart[0].endTime;
const lastStart = sortedByStart[sortedByStart.length - 1].startTime;
// In parallel execution, the last search should start before the first one ends
expect(lastStart).toBeLessThanOrEqual(firstEnd + 10); // Allow 10ms tolerance
// Find the match
const match = results.find(r => r.status === 'fulfilled' && r.value);
expect(match).toBeDefined();
expect((match as any).value.type).toBe('skills');
});
it('should handle any number of element types dynamically', async () => {
// Test with different numbers of element types to prove no hardcoded assumptions
const testCases = [
['personas', 'skills'], // 2 types
['personas', 'skills', 'templates', 'agents'], // 4 types
['personas', 'skills', 'templates', 'agents', 'memories', 'ensembles'], // 6 types
// Could have 20 types:
Array.from({ length: 20 }, (_, i) => `type${i}`) // 20 types
];
for (const types of testCases) {
const searchPromises = types.map(async (type) => {
await new Promise(resolve => setTimeout(resolve, 5));
return type === types[0] ? { type, file: `/path/${type}/sample.md` } : null;
});
const results = await Promise.allSettled(searchPromises);
// Works correctly regardless of the number of types
expect(results).toHaveLength(types.length);
expect(results.every(r => r.status === 'fulfilled')).toBe(true);
// Finds the match regardless of how many types exist
const match = results.find(r =>
r.status === 'fulfilled' && r.value && (r.value as any).file
);
expect(match).toBeDefined();
}
});
it('should handle mixed success and failure in parallel searches', async () => {
const searchPromises = [
Promise.resolve({ type: 'personas', file: null }),
Promise.reject(new Error('Permission denied')),
Promise.resolve({ type: 'templates', file: '/path/to/template.md' }),
Promise.resolve({ type: 'agents', file: null })
];
const results = await Promise.allSettled(searchPromises);
// Should handle both successes and failures
expect(results).toHaveLength(4);
expect(results[0].status).toBe('fulfilled');
expect(results[1].status).toBe('rejected');
expect(results[2].status).toBe('fulfilled');
expect(results[3].status).toBe('fulfilled');
// Should still find the successful match
const match = results.find(r =>
r.status === 'fulfilled' && r.value && (r.value as any).file
);
expect(match).toBeDefined();
expect((match as any).value.file).toBe('/path/to/template.md');
});
});
describe('Error Handling Patterns', () => {
it('should distinguish between expected and unexpected errors', () => {
const errors = [
{ code: 'ENOENT', message: 'No such file or directory' },
{ code: 'ENOTDIR', message: 'Not a directory' },
{ code: 'EACCES', message: 'Permission denied' },
{ code: 'EIO', message: 'I/O error' },
{ code: undefined, message: 'Unknown error' }
];
const categorized = errors.map(error => {
const isExpected = error.code === 'ENOENT' || error.code === 'ENOTDIR';
return {
...error,
logLevel: isExpected ? 'debug' : 'warn'
};
});
// ENOENT and ENOTDIR should be debug level
expect(categorized[0].logLevel).toBe('debug');
expect(categorized[1].logLevel).toBe('debug');
// Permission and I/O errors should be warnings
expect(categorized[2].logLevel).toBe('warn');
expect(categorized[3].logLevel).toBe('warn');
// Unknown errors should be warnings
expect(categorized[4].logLevel).toBe('warn');
});
it('should format error logs with appropriate context', () => {
const error = {
code: 'EACCES',
message: 'Permission denied',
path: '/home/user/.dollhouse/portfolio/agents'
};
const logContext = {
contentIdentifier: 'test-agent',
type: 'agents',
error: error.message,
code: error.code
};
// Verify all required context is included
expect(logContext).toHaveProperty('contentIdentifier');
expect(logContext).toHaveProperty('type');
expect(logContext).toHaveProperty('error');
expect(logContext).toHaveProperty('code');
expect(logContext.code).toBe('EACCES');
});
});
describe('Logging Enhancements', () => {
it('should use appropriate log levels', () => {
const scenarios = [
{ event: 'content_found', level: 'debug' },
{ event: 'content_not_found', level: 'info' },
{ event: 'directory_missing', level: 'debug' },
{ event: 'permission_error', level: 'warn' },
{ event: 'search_started', level: 'debug' }
];
// Verify each scenario uses the correct log level
expect(scenarios[0].level).toBe('debug'); // Found content
expect(scenarios[1].level).toBe('info'); // Not found (user should know)
expect(scenarios[2].level).toBe('debug'); // Expected missing dir
expect(scenarios[3].level).toBe('warn'); // Unexpected error
expect(scenarios[4].level).toBe('debug'); // Internal operation
});
it('should include searched types when content not found', () => {
const elementTypes = ['personas', 'skills', 'templates', 'agents', 'memories', 'ensembles'];
const notFoundLogData = {
contentIdentifier: 'test-content',
searchedTypes: elementTypes
};
// Verify the log data includes all searched types
expect(notFoundLogData.searchedTypes).toEqual(elementTypes);
expect(notFoundLogData.searchedTypes).toHaveLength(6);
expect(notFoundLogData.contentIdentifier).toBe('test-content');
});
});
describe('Performance Characteristics', () => {
it('should complete faster with parallel search', async () => {
const SEARCH_DELAY = 50; // ms per search
const ELEMENT_COUNT = 6; // Could be any number - 6, 10, 20, etc.
// Sequential search time
const sequentialStart = Date.now();
for (let i = 0; i < ELEMENT_COUNT; i++) {
await new Promise(resolve => setTimeout(resolve, SEARCH_DELAY));
}
const sequentialTime = Date.now() - sequentialStart;
// Parallel search time
const parallelStart = Date.now();
const promises = new Array(ELEMENT_COUNT).fill(0).map(() =>
new Promise(resolve => setTimeout(resolve, SEARCH_DELAY))
);
await Promise.allSettled(promises);
const parallelTime = Date.now() - parallelStart;
// Parallel should be significantly faster
expect(parallelTime).toBeLessThan(sequentialTime);
// Parallel should be much faster than sequential (at least 2x faster)
// This is a relative comparison, not absolute timing
expect(parallelTime).toBeLessThan(sequentialTime / 2);
// Sequential should take roughly the sum of all searches
expect(sequentialTime).toBeGreaterThanOrEqual(SEARCH_DELAY * ELEMENT_COUNT * 0.9); // 90% minimum
});
it('should stop searching after first match', async () => {
let searchCount = 0;
const searchPromises = ['personas', 'skills', 'templates'].map(async (type) => {
searchCount++;
await new Promise(resolve => setTimeout(resolve, 10));
return type === 'skills' ? { type, file: '/path/to/skill.md' } : null;
});
const results = await Promise.allSettled(searchPromises);
// All searches run in parallel
expect(searchCount).toBe(3);
// But we only use the first match
let matchFound = false;
for (const result of results) {
if (result.status === 'fulfilled' && result.value && (result.value as any).file) {
expect((result.value as any).type).toBe('skills');
matchFound = true;
break; // Stop at first match
}
}
expect(matchFound).toBe(true);
});
});
describe('Edge Cases', () => {
it('should handle all searches failing', async () => {
const searchPromises = [
Promise.reject(new Error('Error 1')),
Promise.reject(new Error('Error 2')),
Promise.reject(new Error('Error 3'))
];
const results = await Promise.allSettled(searchPromises);
// All should be rejected but handled gracefully
expect(results.every(r => r.status === 'rejected')).toBe(true);
// Should NOT fall back to default - this was the critical fix
// The system should now return an error or ask user to specify type
let selectedType = undefined;
for (const result of results) {
if (result.status === 'fulfilled' && result.value) {
selectedType = (result.value as any).type;
break;
}
}
// No match found, should NOT default to any type
expect(selectedType).toBeUndefined();
// The fix ensures we don't default to personas
// Instead, the system will return an error message asking for --type parameter
});
it('should handle empty search results', async () => {
const searchPromises = [
Promise.resolve(null),
Promise.resolve(null),
Promise.resolve(null)
];
const results = await Promise.allSettled(searchPromises);
// All successful but no matches
expect(results.every(r => r.status === 'fulfilled')).toBe(true);
expect(results.every(r => (r as any).value === null)).toBe(true);
// Should NOT fall back to default - this was the critical fix
// The system will return an error message listing available types
const match = results.find(r =>
r.status === 'fulfilled' && r.value && (r.value as any).file
);
expect(match).toBeUndefined();
});
});
});