cmte
Version:
Design by Committee™ except it's just you and LLMs
365 lines (315 loc) • 17.2 kB
JavaScript
import { describe, test, expect, beforeEach, vi, afterEach } from 'vitest';
import { TemplateRenderer } from '../template-renderer.js';
import path from 'path';
import fs from 'fs';
import os from 'os';
import { FileCollectionManager } from '../../file-collection-manager.js';
import { OutputReferenceResolver } from '../output-reference-resolver.js';
// Mock FileCollectionManager
vi.mock('../../file-collection-manager.js');
// Mock OutputReferenceResolver
vi.mock('../output-reference-resolver.js');
describe('TemplateRenderer', () => {
let renderer;
let tempDir;
let mockResolver;
let mockFileCollectionManager;
const basePath = '/test/base';
beforeEach(async () => {
// Reset mocks before each test
vi.resetAllMocks();
// Mock OutputReferenceResolver
mockResolver = {
resolveReference: vi.fn().mockImplementation(async (ref, iterationContext = null) => {
console.log(`Mock resolveReference called with: ${ref}, iterationContext: ${JSON.stringify(iterationContext)}`);
mockResolver.currentIterationKey = iterationContext?.key || null; // Track the key
// Explicit check for the invalid reference test case
if (ref === 'invalid.task.output') {
console.log("Mock throwing error for invalid.task.output");
throw new Error(`Invalid output reference format: ${ref}`);
}
// Check for [this] reference
if (ref === '[this]') {
if (iterationContext && iterationContext.value !== undefined) {
console.log(`Mock resolving [this] to: ${iterationContext.value}`);
return iterationContext.value;
} else {
console.log("Mock throwing error for [this] outside iteration");
throw new Error('[this] reference used outside iteration context');
}
}
// Simulate resolving other references based on mockResolver.outputs
if (mockResolver.outputs && mockResolver.outputs[ref] !== undefined) {
console.log(`Mock resolving ${ref} to: ${mockResolver.outputs[ref]}`);
return mockResolver.outputs[ref];
}
// Fallback for unresolved references (could throw or return undefined)
console.log(`Mock unable to resolve reference: ${ref}`);
// throw new Error(`Variable or reference not found: ${ref}`); // Or return undefined
return undefined; // Let's return undefined for now to match previous logic potentially
}),
setIterationContext: vi.fn((key) => { mockResolver.currentIterationKey = key; }),
clearIterationContext: vi.fn(() => { mockResolver.currentIterationKey = undefined; }),
// Add mock functions for registration (they don't need complex logic for these tests)
registerOutput: vi.fn(),
registerIteratedOutput: vi.fn(),
outputs: {}, // Initialize outputs here
clearOutputs: vi.fn(() => { mockResolver.outputs = {}; }), // Mock clearOutputs behavior
currentIterationKey: undefined,
getCurrentIterationKey: vi.fn() // Add the missing mock function
};
// --- Mock FileCollectionManager Setup ---
const mockRegisterCollection = vi.fn();
const mockGetFiles = vi.fn();
const mockHasCollection = vi.fn();
const mockReadFile = vi.fn(); // Renamed from getFileContent in previous thought
const mockLoadAndRenderFiles = vi.fn(); // Keep this
mockFileCollectionManager = {
registerCollection: mockRegisterCollection,
getFiles: mockGetFiles,
hasCollection: mockHasCollection,
readFile: mockReadFile, // Use the new name
loadAndRenderFiles: mockLoadAndRenderFiles,
};
// Make the mock constructor return our mock instance
FileCollectionManager.mockImplementation(() => mockFileCollectionManager);
// Create a temporary directory for test files
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'template-renderer-test-'));
// --- Instantiate Renderer with Mocks ---
renderer = new TemplateRenderer(
{ globalVar: 'globalValue' }, // context object
basePath, // basePath string
mockResolver, // Pass the resolver mock
mockFileCollectionManager // Pass the manager mock
);
});
afterEach(async () => {
// Clean up temporary directory
await fs.promises.rm(tempDir, { recursive: true, force: true });
});
describe('context variables', () => {
test('renders basic context variables', async () => {
// Use the renderer from beforeEach, but override context if needed for the test
const specificContext = { name: 'test', value: 123 };
renderer = new TemplateRenderer( // Re-instantiate for specific context THIS TEST NEEDS
specificContext,
basePath,
mockResolver,
mockFileCollectionManager
);
// Test the specific context
expect(renderer.context).toEqual(specificContext); // Use specificContext for comparison
const result = await renderer.render('Hello {{name}}, value is {{value}}');
expect(result).toBe('Hello test, value is 123');
});
test('preserves unmatched variables', async () => {
// Mock resolver to return undefined for missing variable
mockResolver.resolveReference.mockImplementation((ref) => {
if (ref === 'missing') {
return undefined; // Simulate variable not found
}
return `[Resolved value for ${ref}]`;
});
const result = await renderer.render('Hello {{missing}}');
// Expect empty string for unresolved variables
expect(result).toBe('Hello ');
});
});
describe('output references', () => {
beforeEach(() => {
// Register outputs using the MOCK resolver
mockResolver.registerOutput('set1', 'task1', 'result1');
mockResolver.registerOutput('set2', 'task2', 'result2');
// Mock resolveReference behavior
mockResolver.resolveReference.mockImplementation((ref) => {
if (ref === 'task1.output') return 'result1';
if (ref === 'set2.task2.output') return 'result2';
if (ref === 'missing.output') throw new Error('Task output not found: missing');
return `[Unknown Reference: ${ref}]`;
});
});
test('renders basic task output references', async () => {
const result = await renderer.render('Output: {{task1.output}}');
expect(result).toBe('Output: result1');
expect(mockResolver.resolveReference).toHaveBeenCalledWith('task1.output');
});
test('renders set.task output references', async () => {
const result = await renderer.render('Output: {{set2.task2.output}}');
expect(result).toBe('Output: result2');
expect(mockResolver.resolveReference).toHaveBeenCalledWith('set2.task2.output');
});
test('handles missing output references', async () => {
const result = await renderer.render('Output: {{missing.output}}');
expect(result).toBe('Output: [Error: Task output not found: missing]');
expect(mockResolver.resolveReference).toHaveBeenCalledWith('missing.output');
});
});
describe('iteration references', () => {
beforeEach(() => {
// Register outputs using the MOCK resolver
mockResolver.registerIteratedOutput('set1', 'task1', 'item1', 'result1');
mockResolver.registerIteratedOutput('set1', 'task1', 'item2', 'result2');
mockResolver.registerIteratedOutput('set1', 'task1', 'item3', 'result3');
// Mock resolveReference behavior for iteration
// Use internal state (currentIterationKey) instead of relying on argument
mockResolver.resolveReference.mockImplementation(async (ref) => {
const currentKey = mockResolver.currentIterationKey; // Read internal state
// Add check for the invalid reference test case *here* too
if (ref === 'invalid.task.output') {
throw new Error(`Invalid output reference format: ${ref}`);
}
if (ref === 'set1.task1[item2].output') return 'result2';
if (ref === 'set1.task1[*].output') return ['result1', 'result2', 'result3']; // Assume resolver returns array for wildcard
// Check [this] using internal state
if (ref === 'set1.task1[this].output' && currentKey === 'item2') return 'result2';
if (ref === 'set1.task1[this].output' && currentKey === undefined) throw new Error('[this] reference used outside iteration context');
// Also handle plain '[this]' if needed by other tests in this block, using internal state
if (ref === '[this]' && currentKey !== undefined) {
// Mock logic to return a value based on the key
if (currentKey === 'item1') return 'result1';
if (currentKey === 'item2') return 'result2';
if (currentKey === 'item3') return 'result3';
return `value_for_${currentKey}`; // Fallback
}
if (ref === '[this]' && currentKey === undefined) {
throw new Error('[this] reference used outside iteration context');
}
return `[Unknown Iteration Reference: ${ref}]`;
});
});
test('renders specific iteration references', async () => {
renderer.referenceResolver.setIterationContext('item2'); // Set context before rendering
const result = await renderer.render('Output: {{set1.task1[item2].output}}');
expect(result).toBe('Output: result2');
// Expect resolveReference to be called with only the reference string
expect(mockResolver.resolveReference).toHaveBeenCalledWith('set1.task1[item2].output');
});
test('renders wildcard iteration references', async () => {
const result = await renderer.render('Outputs: {{set1.task1[*].output}}');
// Assuming the template engine joins arrays with commas by default
expect(result).toBe('Outputs: result1,result2,result3');
// Expect resolveReference to be called with only the reference string
expect(mockResolver.resolveReference).toHaveBeenCalledWith('set1.task1[*].output');
});
test('renders $this references in iteration context', async () => {
// Simulate entering iteration context (on the mock resolver)
mockResolver.setIterationContext('item2'); // Manually set context before render
// Render the template that uses [this]
const result = await renderer.render('Current: {{set1.task1[this].output}}');
expect(result).toBe('Current: result2');
// Expect resolveReference to be called *only* with the reference string
expect(mockResolver.resolveReference).toHaveBeenCalledWith('set1.task1[this].output');
});
test('handles invalid output references', async () => {
// Use a reference that looks like an output ref but is configured to throw in the mock
const result = await renderer.render('{{invalid.task.output}}');
// Expect the error message rendered by the template engine from the mock resolver
const expectedErrorMessage = 'Invalid output reference format: invalid.task.output';
expect(result).toBe(`[Error: ${expectedErrorMessage}]`);
expect(mockResolver.resolveReference).toHaveBeenCalledWith('invalid.task.output');
});
test('handles invalid file paths', async () => {
// This test becomes about handling errors from loadAndRenderFiles
const collectionError = new Error("ENOENT: no such file or directory..."); // Example error
mockFileCollectionManager.loadAndRenderFiles.mockRejectedValue(collectionError);
const result = await renderer.render('{{files.invalidCollection}}'); // Use new file syntax
expect(result).toBe(`[Error: ${collectionError.message}]`); // Expect rendered error
expect(mockFileCollectionManager.loadAndRenderFiles).toHaveBeenCalledWith('invalidCollection');
});
});
describe('file collections', () => {
beforeEach(() => {
// Reset mocks specifically for file collection tests
vi.resetAllMocks();
// Mock FileCollectionManager
mockFileCollectionManager = {
loadAndRenderFiles: vi.fn()
};
renderer = new TemplateRenderer({ someVar: 'context_value' }, '/base', mockResolver, mockFileCollectionManager);
});
test('renders collection without pattern', async () => {
mockFileCollectionManager.loadAndRenderFiles.mockResolvedValue('test1.js\\ntest2.js\\ntest.txt');
const result = await renderer.render('Files: {{files.all}}'); // Use new syntax
expect(result).toBe('Files: test1.js\\ntest2.js\\ntest.txt');
expect(mockFileCollectionManager.loadAndRenderFiles).toHaveBeenCalledWith('all');
});
test('handles missing collections', async () => {
const collectionError = new Error("File collection 'missing' not found.");
mockFileCollectionManager.loadAndRenderFiles.mockRejectedValue(collectionError);
const result = await renderer.render('Files: {{files.missing}}'); // Use new syntax
// Expect the error message, not the preserved tag
expect(result).toBe(`Files: [Error: ${collectionError.message}]`);
expect(mockFileCollectionManager.loadAndRenderFiles).toHaveBeenCalledWith('missing');
});
test('loads file content from collection', async () => {
mockFileCollectionManager.loadAndRenderFiles.mockResolvedValue('File content here');
const result = await renderer.render('Content: {{files.myFiles}}'); // Use new syntax
expect(result).toBe('Content: File content here');
expect(mockFileCollectionManager.loadAndRenderFiles).toHaveBeenCalledWith('myFiles');
});
});
describe('error handling', () => {
test('handles invalid file paths', async () => {
// This test becomes about handling errors from loadAndRenderFiles
const collectionError = new Error("ENOENT: no such file or directory..."); // Example error
mockFileCollectionManager.loadAndRenderFiles.mockRejectedValue(collectionError);
const result = await renderer.render('{{files.invalidCollection}}'); // Use new file syntax
expect(result).toBe(`[Error: ${collectionError.message}]`); // Expect rendered error
expect(mockFileCollectionManager.loadAndRenderFiles).toHaveBeenCalledWith('invalidCollection');
});
test('handles invalid collection references', async () => {
// Mock loadAndRenderFiles to throw when called with 'invalid'
const collectionError = new Error("File collection 'invalid' not found.");
mockFileCollectionManager.loadAndRenderFiles.mockImplementation(async (name) => {
if (name === 'invalid') {
throw collectionError;
}
return 'valid content'; // Default for other calls if any
});
const result = await renderer.render('{{files.invalid}}');
expect(result).toBe(`[Error: ${collectionError.message}]`);
expect(mockFileCollectionManager.loadAndRenderFiles).toHaveBeenCalledWith('invalid');
});
test('handles invalid output references', async () => {
// Use a reference that looks like an output ref but is configured to throw in the mock
const result = await renderer.render('{{invalid.task.output}}');
// Expect the error message rendered by the template engine from the mock resolver
const expectedErrorMessage = 'Invalid output reference format: invalid.task.output';
expect(result).toBe(`[Error: ${expectedErrorMessage}]`);
expect(mockResolver.resolveReference).toHaveBeenCalledWith('invalid.task.output');
});
});
describe('cleanup', () => {
test('clear removes all outputs and iteration context', async () => {
// Setup: Add some output and set iteration context
mockResolver.outputs['set1.task1.output'] = 'some_output';
mockResolver.setIterationContext('some_key');
// Action: Render something (to ensure resolver is used), then clear
await renderer.render('{{someVar}}'); // Render something simple
// Direct calls to resolver mocks for cleanup simulation
mockResolver.clearOutputs();
mockResolver.clearIterationContext();
// Assertions
expect(mockResolver.clearOutputs).toHaveBeenCalledTimes(1);
expect(mockResolver.clearIterationContext).toHaveBeenCalledTimes(1);
// Re-test resolution after clear - it should fail or return undefined
try {
mockResolver.resolveReference('set1.task1.output');
// If it doesn't throw, the output wasn't cleared. Fail the test.
// throw new Error('Output was not cleared'); // Or use expect.fail()
} catch (e) {
// Expect it to throw because the output should be gone
// This depends on how resolveReference behaves when output is missing.
// Assuming it throws an error similar to invalid format or not found.
expect(e.message).toContain('Invalid output reference format'); // Adjust as needed
}
// Assert that iteration context is cleared (assuming [this] would fail)
try {
mockResolver.resolveReference('[this]');
// throw new Error('Iteration context was not cleared');
} catch (e) {
expect(e.message).toContain('[this] reference used outside iteration context');
}
});
});
});