mcp-continuity-tool
Version:
A tool for maintaining context and continuity in MCP-based development across multiple chat sessions
293 lines (236 loc) • 15.6 kB
JavaScript
const { loadProjectState, saveProjectState, updateProjectState, generateContinuityPrompt } = require('../scripts/state_manager');
const fs = require('fs');
const path = require('path');
jest.mock('fs'); // Mock the fs module
describe('State Manager', () => {
const mockDefaultProjectPath = 'project-status.json';
const mockCustomProjectPath = 'custom/path/project-status.json';
let resolvedDefaultPath;
let resolvedCustomPath;
// Helper to create a default state structure
const getDefaultState = () => ({
projectInfo: { name: "Project", repository: "", lastUpdated: expect.any(String) },
development: { currentFile: "", currentComponent: "", inProgress: { type: "feature", description: "", remainingTasks: [] } },
components: { completed: [], inProgress: [], pending: [] },
context: { lastThought: "", nextSteps: [], dependencies: [] },
mcpTools: { lastUsed: { repl: null, artifacts: [], searchResults: [] }, cacheFiles: [], tempStorage: [] }
});
beforeEach(() => {
// Reset mocks before each test
fs.readFileSync.mockReset();
fs.writeFileSync.mockReset();
// Resolve paths based on CWD for consistent testing
resolvedDefaultPath = path.resolve(mockDefaultProjectPath);
resolvedCustomPath = path.resolve(mockCustomProjectPath);
// Default mock implementation for readFileSync
fs.readFileSync.mockImplementation((filePath) => {
// console.log(`Mock fs.readFileSync called with: ${filePath}`); // For debugging tests
// Allow specific tests to override this with mockReturnValueOnce
const err = new Error(`ENOENT: no such file or directory, open '${filePath}'`);
err.code = 'ENOENT';
throw err;
});
});
describe('loadProjectState', () => {
it('should load and parse a JSON file if it exists (default path)', () => {
const mockState = { projectInfo: { name: 'Test Project Loaded' } };
fs.readFileSync.mockReturnValueOnce(JSON.stringify(mockState));
const state = loadProjectState(); // Test with default path
expect(fs.readFileSync).toHaveBeenCalledWith(resolvedDefaultPath, { encoding: 'utf8' });
expect(state).toEqual(mockState);
});
it('should load and parse a JSON file if it exists (custom path)', () => {
const mockState = { projectInfo: { name: 'Test Project Custom Path' } };
fs.readFileSync.mockReturnValueOnce(JSON.stringify(mockState));
const state = loadProjectState(mockCustomProjectPath);
expect(fs.readFileSync).toHaveBeenCalledWith(resolvedCustomPath, { encoding: 'utf8' });
expect(state).toEqual(mockState);
});
it('should return a default state if the file does not exist', () => {
// fs.readFileSync will throw ENOENT by default from beforeEach
const state = loadProjectState(mockCustomProjectPath);
expect(fs.readFileSync).toHaveBeenCalledWith(resolvedCustomPath, { encoding: 'utf8' });
const defaultState = getDefaultState();
// lastUpdated is dynamic, so compare structure and key elements
expect(state).toMatchObject({
...defaultState,
projectInfo: { ...defaultState.projectInfo, name: 'Project' } // Ensure default name
});
expect(new Date(state.projectInfo.lastUpdated).toISOString()).toEqual(state.projectInfo.lastUpdated); // Validate date format
});
it('should return a default state if JSON.parse throws an error', () => {
fs.readFileSync.mockReturnValueOnce('this is not json');
const state = loadProjectState(mockCustomProjectPath);
expect(fs.readFileSync).toHaveBeenCalledWith(resolvedCustomPath, { encoding: 'utf8' });
const defaultState = getDefaultState();
expect(state).toMatchObject({
...defaultState,
projectInfo: { ...defaultState.projectInfo, name: 'Project' }
});
});
});
describe('saveProjectState', () => {
it('should stringify the state, add lastUpdated, and call fs.writeFileSync (default path)', () => {
const stateToSave = { projectInfo: { name: 'Test Save Default' } };
const result = saveProjectState(stateToSave); // Default path
expect(fs.writeFileSync).toHaveBeenCalledTimes(1);
const callArgs = fs.writeFileSync.mock.calls[0];
expect(callArgs[0]).toBe(resolvedDefaultPath);
const writtenData = JSON.parse(callArgs[1]);
expect(writtenData.projectInfo.name).toBe('Test Save Default');
expect(writtenData.projectInfo).toHaveProperty('lastUpdated');
expect(new Date(writtenData.projectInfo.lastUpdated).toISOString()).toBe(writtenData.projectInfo.lastUpdated);
expect(result.success).toBe(true);
expect(result.message).toBe('Estado do projeto salvo com sucesso!');
});
it('should stringify the state and call fs.writeFileSync (custom path)', () => {
const stateToSave = { projectInfo: { name: 'Test Save Custom' } };
saveProjectState(stateToSave, mockCustomProjectPath);
expect(fs.writeFileSync).toHaveBeenCalledTimes(1);
expect(fs.writeFileSync.mock.calls[0][0]).toBe(resolvedCustomPath);
// Content check is similar to above, can be less specific if path is main focus
expect(fs.writeFileSync.mock.calls[0][1]).toContain('"name": "Test Save Custom"');
});
it('should return success: false if fs.writeFileSync throws an error', () => {
const stateToSave = { projectInfo: { name: 'Test Save Fail' } };
const writeError = new Error('Disk full');
fs.writeFileSync.mockImplementationOnce(() => {
throw writeError;
});
const result = saveProjectState(stateToSave, mockCustomProjectPath);
expect(fs.writeFileSync).toHaveBeenCalledWith(resolvedCustomPath, expect.any(String));
expect(result.success).toBe(false);
expect(result.error).toBe(writeError.message);
});
});
describe('updateProjectState', () => {
const initialMockState = {
projectInfo: { name: 'Initial Project', lastUpdated: new Date().toISOString() },
development: { currentFile: 'initial.js' },
context: { lastThought: 'Initial thought' }
};
it('should merge updates into an existing state and save', () => {
fs.readFileSync.mockReturnValueOnce(JSON.stringify(initialMockState)); // For loadProjectState
const updates = { development: { currentFile: 'updated.js' }, context: { lastThought: 'Updated thought' } };
const result = updateProjectState(updates, mockCustomProjectPath);
expect(fs.readFileSync).toHaveBeenCalledWith(resolvedCustomPath, { encoding: 'utf8' });
expect(fs.writeFileSync).toHaveBeenCalledTimes(1);
const savedData = JSON.parse(fs.writeFileSync.mock.calls[0][1]);
expect(savedData.projectInfo.name).toBe('Initial Project'); // From initial state
expect(savedData.development.currentFile).toBe('updated.js'); // Updated
expect(savedData.context.lastThought).toBe('Updated thought'); // Updated
expect(savedData.projectInfo).toHaveProperty('lastUpdated');
// Ensure lastUpdated was updated by saveProjectState
expect(new Date(savedData.projectInfo.lastUpdated).toISOString()).not.toBe(initialMockState.projectInfo.lastUpdated);
expect(result.projectInfo.name).toBe('Initial Project');
expect(result.development.currentFile).toBe('updated.js');
});
it('should use default state if no prior state file exists and save', () => {
// fs.readFileSync will throw ENOENT by default
const updates = { context: { lastThought: 'First thought on new project' } };
const result = updateProjectState(updates, mockCustomProjectPath);
expect(fs.readFileSync).toHaveBeenCalledWith(resolvedCustomPath, { encoding: 'utf8' }); // Attempted read
expect(fs.writeFileSync).toHaveBeenCalledTimes(1); // Save was called
const savedData = JSON.parse(fs.writeFileSync.mock.calls[0][1]);
expect(savedData.projectInfo.name).toBe('Project'); // Default name
expect(savedData.context.lastThought).toBe('First thought on new project'); // Updated
expect(result.projectInfo.name).toBe('Project');
expect(result.context.lastThought).toBe('First thought on new project');
});
it('should perform deep merging correctly', () => {
const deepInitialState = {
projectInfo: { name: 'Deep Project' },
development: { inProgress: { type: 'task', description: 'Old task', remainingTasks: ['a', 'b'] } }
};
fs.readFileSync.mockReturnValueOnce(JSON.stringify(deepInitialState));
const updates = { development: { inProgress: { description: 'New task description' } } };
const result = updateProjectState(updates, mockCustomProjectPath);
const savedData = JSON.parse(fs.writeFileSync.mock.calls[0][1]);
expect(savedData.development.inProgress.type).toBe('task'); // Preserved from initial
expect(savedData.development.inProgress.description).toBe('New task description'); // Updated
expect(savedData.development.inProgress.remainingTasks).toEqual(['a', 'b']); // Preserved array
expect(result.development.inProgress.description).toBe('New task description');
});
it('should handle error during loadProjectState gracefully and return error object', () => {
fs.readFileSync.mockImplementationOnce(() => { throw new Error('Simulated read error during update'); });
const updates = { context: { lastThought: 'Update attempt' } };
const result = updateProjectState(updates, mockCustomProjectPath);
expect(result.success).toBe(false);
expect(result.error).toContain('Simulated read error during update');
expect(result.details).toBe('Failed during update operation'); // Specific message from updateProjectState's catch
expect(fs.writeFileSync).not.toHaveBeenCalled(); // Save should not be called
});
it('should handle error during saveProjectState gracefully and return error object', () => {
fs.readFileSync.mockReturnValueOnce(JSON.stringify(initialMockState)); // Load succeeds
fs.writeFileSync.mockImplementationOnce(() => { throw new Error('Simulated write error during update'); }); // Save fails
const updates = { context: { lastThought: 'Update attempt' } };
const result = updateProjectState(updates, mockCustomProjectPath);
expect(result.success).toBe(false);
expect(result.error).toContain('Simulated write error during update');
expect(result.details).toBe('Failed during update operation');
expect(fs.writeFileSync).toHaveBeenCalledTimes(1); // Attempted write
});
});
describe('generateContinuityPrompt', () => {
const baseState = {
projectInfo: { name: 'Prompt Test', repository: 'user/repo.git' },
development: {
currentFile: 'src/main.js',
inProgress: { type: 'feature', description: 'Working on feature X' }
},
context: { lastThought: 'Need to refactor module Y' }
};
it('should generate a prompt with typical state information', () => {
const prompt = generateContinuityPrompt(baseState);
expect(prompt).toContain('Working on: user/repo.git');
expect(prompt).toContain('Context: Need to refactor module Y');
expect(prompt).toContain('"name": "Prompt Test"');
expect(prompt).toContain('"currentTask": "Working on feature X"');
expect(prompt).toContain('"lastState": "Need to refactor module Y"');
expect(prompt).toContain('"currentFile": "src/main.js"');
expect(prompt).toContain('"inProgress": "feature: Working on feature X"');
});
it('should use development.inProgress.description for context if context.lastThought is missing', () => {
const state = { ...baseState, context: { ...baseState.context, lastThought: null } };
const prompt = generateContinuityPrompt(state);
expect(prompt).toContain('Context: Working on feature X');
expect(prompt).toContain('"lastState": null');
});
it('should use [CONTEXTO_ATUAL] if both context.lastThought and development.inProgress.description are missing', () => {
const state = {
...baseState,
context: { ...baseState.context, lastThought: null },
development: { ...baseState.development, inProgress: { ...baseState.development.inProgress, description: null } }
};
const prompt = generateContinuityPrompt(state);
expect(prompt).toContain('Context: [CONTEXTO_ATUAL]');
expect(prompt).toContain('"currentTask": null');
expect(prompt).toContain('"inProgress": "feature: null"'); // Type is still there
});
it('should handle missing development.inProgress gracefully', () => {
const state = {
projectInfo: { name: 'Prompt Test', repository: 'user/repo.git' },
development: { currentFile: 'src/main.js', inProgress: null }, // inProgress is null
context: { lastThought: 'A thought' }
};
const prompt = generateContinuityPrompt(state);
expect(prompt).toContain('Context: A thought');
expect(prompt).toContain('"currentTask": null');
expect(prompt).toContain('"inProgress": null');
});
it('should use [REPOSITÓRIO] if projectInfo.repository is missing', () => {
const state = { ...baseState, projectInfo: { ...baseState.projectInfo, repository: null } };
const prompt = generateContinuityPrompt(state);
expect(prompt).toContain('Working on: [REPOSITÓRIO]');
});
it('should return "Estado do projeto não disponível..." if projectInfo is missing', () => {
const state = { ...baseState, projectInfo: null };
const prompt = generateContinuityPrompt(state);
expect(prompt).toBe('Estado do projeto não disponível. Carregue o estado primeiro.');
});
it('should return "Estado do projeto não disponível..." if state is null or undefined', () => {
expect(generateContinuityPrompt(null)).toBe('Estado do projeto não disponível. Carregue o estado primeiro.');
expect(generateContinuityPrompt(undefined)).toBe('Estado do projeto não disponível. Carregue o estado primeiro.');
});
});
});