Files
claude-task-master/packages/tm-core/src/config/services/runtime-state-manager.service.spec.ts
Ralph Khreish 0f3ab00f26 feat: create tm-core and apps/cli (#1093)
- add typescript
- add npm workspaces
2025-09-09 03:32:48 +02:00

273 lines
7.9 KiB
TypeScript

/**
* @fileoverview Unit tests for RuntimeStateManager service
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { promises as fs } from 'node:fs';
import { RuntimeStateManager } from './runtime-state-manager.service.js';
import { DEFAULT_CONFIG_VALUES } from '../../interfaces/configuration.interface.js';
vi.mock('node:fs', () => ({
promises: {
readFile: vi.fn(),
writeFile: vi.fn(),
mkdir: vi.fn(),
unlink: vi.fn()
}
}));
describe('RuntimeStateManager', () => {
let stateManager: RuntimeStateManager;
const testProjectRoot = '/test/project';
beforeEach(() => {
stateManager = new RuntimeStateManager(testProjectRoot);
vi.clearAllMocks();
// Clear environment variables
delete process.env.TASKMASTER_TAG;
});
afterEach(() => {
vi.restoreAllMocks();
delete process.env.TASKMASTER_TAG;
});
describe('loadState', () => {
it('should load state from file', async () => {
const mockState = {
activeTag: 'feature-branch',
lastUpdated: '2024-01-01T00:00:00.000Z',
metadata: { test: 'data' }
};
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockState));
const state = await stateManager.loadState();
expect(fs.readFile).toHaveBeenCalledWith(
'/test/project/.taskmaster/state.json',
'utf-8'
);
expect(state.activeTag).toBe('feature-branch');
expect(state.metadata).toEqual({ test: 'data' });
});
it('should override with environment variable if set', async () => {
const mockState = { activeTag: 'file-tag' };
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockState));
process.env.TASKMASTER_TAG = 'env-tag';
const state = await stateManager.loadState();
expect(state.activeTag).toBe('env-tag');
});
it('should use default state when file does not exist', async () => {
const error = new Error('File not found') as any;
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error);
const state = await stateManager.loadState();
expect(state.activeTag).toBe(DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG);
});
it('should use environment variable when file does not exist', async () => {
const error = new Error('File not found') as any;
error.code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error);
process.env.TASKMASTER_TAG = 'env-tag';
const state = await stateManager.loadState();
expect(state.activeTag).toBe('env-tag');
});
it('should handle file read errors gracefully', async () => {
vi.mocked(fs.readFile).mockRejectedValue(new Error('Permission denied'));
const state = await stateManager.loadState();
expect(state.activeTag).toBe(DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG);
});
it('should handle invalid JSON gracefully', async () => {
vi.mocked(fs.readFile).mockResolvedValue('invalid json');
// Mock console.warn to avoid noise in tests
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const state = await stateManager.loadState();
expect(state.activeTag).toBe(DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG);
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
});
describe('saveState', () => {
it('should save state to file with timestamp', async () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
// Set a specific state
await stateManager.setActiveTag('test-tag');
// Verify mkdir was called
expect(fs.mkdir).toHaveBeenCalledWith('/test/project/.taskmaster', {
recursive: true
});
// Verify writeFile was called with correct data
expect(fs.writeFile).toHaveBeenCalledWith(
'/test/project/.taskmaster/state.json',
expect.stringContaining('"activeTag":"test-tag"'),
'utf-8'
);
// Verify timestamp is included
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('"lastUpdated"'),
'utf-8'
);
});
it('should throw TaskMasterError on save failure', async () => {
vi.mocked(fs.mkdir).mockRejectedValue(new Error('Disk full'));
await expect(stateManager.saveState()).rejects.toThrow(
'Failed to save runtime state'
);
});
it('should format JSON with proper indentation', async () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
await stateManager.saveState();
const writeCall = vi.mocked(fs.writeFile).mock.calls[0];
const jsonContent = writeCall[1] as string;
// Check for 2-space indentation
expect(jsonContent).toMatch(/\n /);
});
});
describe('getActiveTag', () => {
it('should return current active tag', () => {
const tag = stateManager.getActiveTag();
expect(tag).toBe(DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG);
});
it('should return updated tag after setActiveTag', async () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
await stateManager.setActiveTag('new-tag');
expect(stateManager.getActiveTag()).toBe('new-tag');
});
});
describe('setActiveTag', () => {
it('should update active tag and save state', async () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
await stateManager.setActiveTag('feature-xyz');
expect(stateManager.getActiveTag()).toBe('feature-xyz');
expect(fs.writeFile).toHaveBeenCalled();
});
});
describe('getState', () => {
it('should return copy of current state', () => {
const state1 = stateManager.getState();
const state2 = stateManager.getState();
expect(state1).not.toBe(state2); // Different instances
expect(state1).toEqual(state2); // Same content
expect(state1.activeTag).toBe(DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG);
});
});
describe('updateMetadata', () => {
it('should update metadata and save state', async () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
await stateManager.updateMetadata({ key1: 'value1' });
const state = stateManager.getState();
expect(state.metadata).toEqual({ key1: 'value1' });
expect(fs.writeFile).toHaveBeenCalled();
});
it('should merge metadata with existing values', async () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
await stateManager.updateMetadata({ key1: 'value1' });
await stateManager.updateMetadata({ key2: 'value2' });
const state = stateManager.getState();
expect(state.metadata).toEqual({
key1: 'value1',
key2: 'value2'
});
});
it('should override existing metadata values', async () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
await stateManager.updateMetadata({ key1: 'value1' });
await stateManager.updateMetadata({ key1: 'value2' });
const state = stateManager.getState();
expect(state.metadata).toEqual({ key1: 'value2' });
});
});
describe('clearState', () => {
it('should delete state file and reset to defaults', async () => {
vi.mocked(fs.unlink).mockResolvedValue(undefined);
await stateManager.clearState();
expect(fs.unlink).toHaveBeenCalledWith(
'/test/project/.taskmaster/state.json'
);
expect(stateManager.getActiveTag()).toBe(
DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG
);
expect(stateManager.getState().metadata).toBeUndefined();
});
it('should ignore ENOENT errors when file does not exist', async () => {
const error = new Error('File not found') as any;
error.code = 'ENOENT';
vi.mocked(fs.unlink).mockRejectedValue(error);
await expect(stateManager.clearState()).resolves.not.toThrow();
expect(stateManager.getActiveTag()).toBe(
DEFAULT_CONFIG_VALUES.TAGS.DEFAULT_TAG
);
});
it('should throw other errors', async () => {
vi.mocked(fs.unlink).mockRejectedValue(new Error('Permission denied'));
await expect(stateManager.clearState()).rejects.toThrow(
'Permission denied'
);
});
});
});