199 lines
6.7 KiB
TypeScript
199 lines
6.7 KiB
TypeScript
import { Session } from '../src/session';
|
|
import { promises as fs } from 'fs';
|
|
import { join, dirname } from 'path';
|
|
import { tmpdir } from 'os';
|
|
|
|
describe('Session', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(async () => {
|
|
testDir = join(tmpdir(), `session-test-${Date.now()}`);
|
|
await fs.mkdir(testDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('constructor', () => {
|
|
test('sets state file path', () => {
|
|
const session = new Session(testDir);
|
|
expect(session['stateFile']).toBe(join(testDir, '.claude', 'session', 'state.json'));
|
|
});
|
|
});
|
|
|
|
describe('get/set', () => {
|
|
test('set and get scalar value', async () => {
|
|
const session = new Session(testDir);
|
|
await session.set('active_command', '/execute');
|
|
|
|
const value = await session.get('active_command');
|
|
expect(value).toBe('/execute');
|
|
});
|
|
|
|
test('get returns null for unset values', async () => {
|
|
const session = new Session(testDir);
|
|
const value = await session.get('active_skill');
|
|
expect(value).toBeNull();
|
|
});
|
|
|
|
test('set multiple values independently', async () => {
|
|
const session = new Session(testDir);
|
|
await session.set('active_command', '/execute');
|
|
await session.set('active_skill', 'executing-plans');
|
|
|
|
expect(await session.get('active_command')).toBe('/execute');
|
|
expect(await session.get('active_skill')).toBe('executing-plans');
|
|
});
|
|
});
|
|
|
|
describe('append/contains', () => {
|
|
test('append adds value to array', async () => {
|
|
const session = new Session(testDir);
|
|
await session.append('edited_files', 'main.ts');
|
|
await session.append('edited_files', 'lib.ts');
|
|
|
|
const files = await session.get('edited_files');
|
|
expect(files).toEqual(['main.ts', 'lib.ts']);
|
|
});
|
|
|
|
test('append deduplicates values', async () => {
|
|
const session = new Session(testDir);
|
|
await session.append('edited_files', 'main.ts');
|
|
await session.append('edited_files', 'lib.ts');
|
|
await session.append('edited_files', 'main.ts'); // Duplicate
|
|
|
|
const files = await session.get('edited_files');
|
|
expect(files).toEqual(['main.ts', 'lib.ts']);
|
|
});
|
|
|
|
test('contains returns true for existing value', async () => {
|
|
const session = new Session(testDir);
|
|
await session.append('file_extensions', 'ts');
|
|
await session.append('file_extensions', 'js');
|
|
|
|
expect(await session.contains('file_extensions', 'ts')).toBe(true);
|
|
expect(await session.contains('file_extensions', 'js')).toBe(true);
|
|
});
|
|
|
|
test('contains returns false for missing value', async () => {
|
|
const session = new Session(testDir);
|
|
await session.append('file_extensions', 'ts');
|
|
|
|
expect(await session.contains('file_extensions', 'rs')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('clear', () => {
|
|
test('removes state file', async () => {
|
|
const session = new Session(testDir);
|
|
await session.set('active_command', '/execute');
|
|
|
|
const stateFile = join(testDir, '.claude', 'session', 'state.json');
|
|
const exists = await fs
|
|
.access(stateFile)
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
expect(exists).toBe(true);
|
|
|
|
await session.clear();
|
|
|
|
const existsAfter = await fs
|
|
.access(stateFile)
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
expect(existsAfter).toBe(false);
|
|
});
|
|
|
|
test('is safe when file does not exist', async () => {
|
|
const session = new Session(testDir);
|
|
await expect(session.clear()).resolves.not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('persistence', () => {
|
|
test('state persists across Session instances', async () => {
|
|
const session1 = new Session(testDir);
|
|
await session1.set('active_command', '/plan');
|
|
await session1.append('edited_files', 'main.ts');
|
|
|
|
const session2 = new Session(testDir);
|
|
expect(await session2.get('active_command')).toBe('/plan');
|
|
expect(await session2.get('edited_files')).toEqual(['main.ts']);
|
|
});
|
|
});
|
|
|
|
describe('atomic writes', () => {
|
|
test('uses atomic rename', async () => {
|
|
const session = new Session(testDir);
|
|
await session.set('active_command', '/execute');
|
|
|
|
const stateFile = join(testDir, '.claude', 'session', 'state.json');
|
|
const tempFile = stateFile + '.tmp';
|
|
|
|
// Temp file should not exist after save completes
|
|
const tempExists = await fs
|
|
.access(tempFile)
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
expect(tempExists).toBe(false);
|
|
|
|
// State file should exist
|
|
const stateExists = await fs
|
|
.access(stateFile)
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
expect(stateExists).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('error scenarios', () => {
|
|
test('handles corrupted JSON gracefully', async () => {
|
|
const session = new Session(testDir);
|
|
const stateFile = join(testDir, '.claude', 'session', 'state.json');
|
|
|
|
// Create directory and write corrupted JSON
|
|
await fs.mkdir(dirname(stateFile), { recursive: true });
|
|
await fs.writeFile(stateFile, '{invalid json', 'utf-8');
|
|
|
|
// Should reinitialize state on corruption
|
|
const value = await session.get('active_command');
|
|
expect(value).toBeNull();
|
|
});
|
|
|
|
test('handles cross-process persistence', async () => {
|
|
// Simulate separate process invocations
|
|
const session1 = new Session(testDir);
|
|
await session1.set('active_command', '/execute');
|
|
await session1.append('edited_files', 'main.ts');
|
|
|
|
// Create new session instance (simulates new process)
|
|
const session2 = new Session(testDir);
|
|
expect(await session2.get('active_command')).toBe('/execute');
|
|
expect(await session2.get('edited_files')).toEqual(['main.ts']);
|
|
});
|
|
|
|
test('handles concurrent writes via atomic rename', async () => {
|
|
const session = new Session(testDir);
|
|
|
|
// Rapid concurrent writes (atomic rename prevents corruption)
|
|
// Note: Some writes may fail due to temp file conflicts, but state file
|
|
// should never be corrupted (that's what atomic rename protects against)
|
|
const results = await Promise.allSettled([
|
|
session.append('edited_files', 'file1.ts'),
|
|
session.append('edited_files', 'file2.ts'),
|
|
session.append('edited_files', 'file3.ts')
|
|
]);
|
|
|
|
// At least one operation should succeed
|
|
const successCount = results.filter((r) => r.status === 'fulfilled').length;
|
|
expect(successCount).toBeGreaterThan(0);
|
|
|
|
// State file should be valid (not corrupted)
|
|
const files = await session.get('edited_files');
|
|
expect(Array.isArray(files)).toBe(true);
|
|
expect(files.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
});
|