mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-29 22:02:02 +00:00
* feat: refactor Claude API Profiles to Claude Compatible Providers
- Rename ClaudeApiProfile to ClaudeCompatibleProvider with models[] array
- Each ProviderModel has mapsToClaudeModel field for Claude tier mapping
- Add providerType field for provider-specific icons (glm, minimax, openrouter)
- Add thinking level support for provider models in phase selectors
- Show all mapped Claude models per provider model (e.g., "Maps to Haiku, Sonnet, Opus")
- Add Bulk Replace feature to switch all phases to a provider at once
- Hide Bulk Replace button when no providers are enabled
- Fix project-level phaseModelOverrides not persisting after refresh
- Fix deleting last provider not persisting (remove empty array guard)
- Add getProviderByModelId() helper for all SDK routes
- Update all routes to pass provider config for provider models
- Update terminology from "profiles" to "providers" throughout UI
- Update documentation to reflect new provider system
* fix: atomic writer race condition and bulk replace reset to defaults
1. AtomicWriter Race Condition Fix (libs/utils/src/atomic-writer.ts):
- Changed temp file naming from Date.now() to Date.now() + random hex
- Uses crypto.randomBytes(4).toString('hex') for uniqueness
- Prevents ENOENT errors when multiple concurrent writes happen
within the same millisecond
2. Bulk Replace "Anthropic Direct" Reset (both dialogs):
- When selecting "Anthropic Direct", now uses DEFAULT_PHASE_MODELS
- Properly resets thinking levels and other settings to defaults
- Added thinkingLevel to the change detection comparison
- Affects both global and project-level bulk replace dialogs
* fix: update tests for new model resolver passthrough behavior
1. model-resolver tests:
- Unknown models now pass through unchanged (provider model support)
- Removed expectations for warnings on unknown models
- Updated case sensitivity and edge case tests accordingly
- Added tests for provider-like model names (GLM-4.7, MiniMax-M2.1)
2. atomic-writer tests:
- Updated regex to match new temp file format with random suffix
- Format changed from .tmp.{timestamp} to .tmp.{timestamp}.{hex}
* refactor: simplify getPhaseModelWithOverrides calls per code review
Address code review feedback on PR #629:
- Make settingsService parameter optional in getPhaseModelWithOverrides
- Function now handles undefined settingsService gracefully by returning defaults
- Remove redundant ternary checks in 4 call sites:
- apps/server/src/routes/context/routes/describe-file.ts
- apps/server/src/routes/context/routes/describe-image.ts
- apps/server/src/routes/worktree/routes/generate-commit-message.ts
- apps/server/src/services/auto-mode-service.ts
- Remove unused DEFAULT_PHASE_MODELS imports where applicable
* test: fix server tests for provider model passthrough behavior
- Update model-resolver.test.ts to expect unknown models to pass through
unchanged (supports ClaudeCompatibleProvider models like GLM-4.7)
- Remove warning expectations for unknown models (valid for providers)
- Add missing getCredentials and getGlobalSettings mocks to
ideation-service.test.ts for settingsService
* fix: address code review feedback for model providers
- Honor thinkingLevel in generate-commit-message.ts
- Pass claudeCompatibleProvider in ideation-service.ts for provider models
- Resolve provider configuration for model overrides in generate-suggestions.ts
- Update "Active Profile" to "Active Provider" label in project-claude-section
- Use substring instead of deprecated substr in api-profiles-section
- Preserve provider enabled state when editing in api-profiles-section
* fix: address CodeRabbit review issues for Claude Compatible Providers
- Fix TypeScript TS2339 error in generate-suggestions.ts where
settingsService was narrowed to 'never' type in else branch
- Use DEFAULT_PHASE_MODELS per-phase defaults instead of hardcoded
'sonnet' in settings-helpers.ts
- Remove duplicate eventHooks key in use-settings-migration.ts
- Add claudeCompatibleProviders to localStorage migration parsing
and merging functions
- Handle canonical claude-* model IDs (claude-haiku, claude-sonnet,
claude-opus) in project-models-section display names
This resolves the CI build failures and addresses code review feedback.
* fix: skip broken list-view-priority E2E test and add Priority column label
- Skip list-view-priority.spec.ts with TODO explaining the infrastructure
issue: setupRealProject only sets localStorage but server settings
take precedence with localStorageMigrated: true
- Add 'Priority' label to list-header.tsx for the priority column
(was empty string, now shows proper header text)
- Increase column width to accommodate the label
The E2E test issue is that tests create features in a temp directory,
but the server loads from the E2E Test Project fixture path set in
setup-e2e-fixtures.mjs. Needs infrastructure fix to properly switch
projects or create features through UI instead of on disk.
711 lines
26 KiB
TypeScript
711 lines
26 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi, type MockInstance } from 'vitest';
|
|
import fs from 'fs/promises';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
import { secureFs } from '@automaker/platform';
|
|
import {
|
|
atomicWriteJson,
|
|
readJsonFile,
|
|
updateJsonAtomically,
|
|
readJsonWithRecovery,
|
|
} from '../src/atomic-writer';
|
|
|
|
// Mock secureFs
|
|
vi.mock('@automaker/platform', () => ({
|
|
secureFs: {
|
|
writeFile: vi.fn(),
|
|
readFile: vi.fn(),
|
|
rename: vi.fn(),
|
|
unlink: vi.fn(),
|
|
readdir: vi.fn(),
|
|
copyFile: vi.fn(),
|
|
access: vi.fn(),
|
|
lstat: vi.fn(),
|
|
mkdir: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
// Mock logger to suppress output during tests
|
|
vi.mock('../src/logger.js', () => ({
|
|
createLogger: () => ({
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
describe('atomic-writer.ts', () => {
|
|
let tempDir: string;
|
|
|
|
beforeEach(async () => {
|
|
// Create a temporary directory for integration tests
|
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'atomic-writer-test-'));
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
// Clean up temporary directory
|
|
try {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
});
|
|
|
|
describe('atomicWriteJson', () => {
|
|
it('should write JSON data atomically', async () => {
|
|
const filePath = path.join(tempDir, 'test.json');
|
|
const data = { key: 'value', number: 42 };
|
|
|
|
(secureFs.writeFile as unknown as MockInstance).mockResolvedValue(undefined);
|
|
(secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined);
|
|
|
|
await atomicWriteJson(filePath, data);
|
|
|
|
// Verify writeFile was called with temp file path and JSON content
|
|
// Format: .tmp.{timestamp}.{random-hex}
|
|
expect(secureFs.writeFile).toHaveBeenCalledTimes(1);
|
|
const writeCall = (secureFs.writeFile as unknown as MockInstance).mock.calls[0];
|
|
expect(writeCall[0]).toMatch(/\.tmp\.\d+\.[a-f0-9]+$/);
|
|
expect(writeCall[1]).toBe(JSON.stringify(data, null, 2));
|
|
expect(writeCall[2]).toBe('utf-8');
|
|
|
|
// Verify rename was called with temp -> target
|
|
expect(secureFs.rename).toHaveBeenCalledTimes(1);
|
|
const renameCall = (secureFs.rename as unknown as MockInstance).mock.calls[0];
|
|
expect(renameCall[0]).toMatch(/\.tmp\.\d+\.[a-f0-9]+$/);
|
|
expect(renameCall[1]).toBe(path.resolve(filePath));
|
|
});
|
|
|
|
it('should use custom indentation', async () => {
|
|
const filePath = path.join(tempDir, 'test.json');
|
|
const data = { key: 'value' };
|
|
|
|
(secureFs.writeFile as unknown as MockInstance).mockResolvedValue(undefined);
|
|
(secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined);
|
|
|
|
await atomicWriteJson(filePath, data, { indent: 4 });
|
|
|
|
const writeCall = (secureFs.writeFile as unknown as MockInstance).mock.calls[0];
|
|
expect(writeCall[1]).toBe(JSON.stringify(data, null, 4));
|
|
});
|
|
|
|
it('should clean up temp file on write failure', async () => {
|
|
const filePath = path.join(tempDir, 'test.json');
|
|
const data = { key: 'value' };
|
|
|
|
const writeError = new Error('Write failed');
|
|
(secureFs.writeFile as unknown as MockInstance).mockRejectedValue(writeError);
|
|
(secureFs.unlink as unknown as MockInstance).mockResolvedValue(undefined);
|
|
|
|
await expect(atomicWriteJson(filePath, data)).rejects.toThrow('Write failed');
|
|
|
|
expect(secureFs.unlink).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should clean up temp file on rename failure', async () => {
|
|
const filePath = path.join(tempDir, 'test.json');
|
|
const data = { key: 'value' };
|
|
|
|
const renameError = new Error('Rename failed');
|
|
(secureFs.writeFile as unknown as MockInstance).mockResolvedValue(undefined);
|
|
(secureFs.rename as unknown as MockInstance).mockRejectedValue(renameError);
|
|
(secureFs.unlink as unknown as MockInstance).mockResolvedValue(undefined);
|
|
|
|
await expect(atomicWriteJson(filePath, data)).rejects.toThrow('Rename failed');
|
|
|
|
expect(secureFs.unlink).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should ignore cleanup errors', async () => {
|
|
const filePath = path.join(tempDir, 'test.json');
|
|
const data = { key: 'value' };
|
|
|
|
const writeError = new Error('Write failed');
|
|
const unlinkError = new Error('Unlink failed');
|
|
(secureFs.writeFile as unknown as MockInstance).mockRejectedValue(writeError);
|
|
(secureFs.unlink as unknown as MockInstance).mockRejectedValue(unlinkError);
|
|
|
|
// Should still throw the original error, not the cleanup error
|
|
await expect(atomicWriteJson(filePath, data)).rejects.toThrow('Write failed');
|
|
});
|
|
|
|
it('should resolve relative paths', async () => {
|
|
const relativePath = 'test.json';
|
|
const data = { key: 'value' };
|
|
|
|
(secureFs.writeFile as unknown as MockInstance).mockResolvedValue(undefined);
|
|
(secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined);
|
|
|
|
await atomicWriteJson(relativePath, data);
|
|
|
|
const renameCall = (secureFs.rename as unknown as MockInstance).mock.calls[0];
|
|
expect(renameCall[1]).toBe(path.resolve(relativePath));
|
|
});
|
|
|
|
it('should handle arrays as data', async () => {
|
|
const filePath = path.join(tempDir, 'array.json');
|
|
const data = [1, 2, 3, { nested: 'value' }];
|
|
|
|
(secureFs.writeFile as unknown as MockInstance).mockResolvedValue(undefined);
|
|
(secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined);
|
|
|
|
await atomicWriteJson(filePath, data);
|
|
|
|
const writeCall = (secureFs.writeFile as unknown as MockInstance).mock.calls[0];
|
|
expect(writeCall[1]).toBe(JSON.stringify(data, null, 2));
|
|
});
|
|
|
|
it('should handle null and primitive values', async () => {
|
|
const filePath = path.join(tempDir, 'primitive.json');
|
|
|
|
(secureFs.writeFile as unknown as MockInstance).mockResolvedValue(undefined);
|
|
(secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined);
|
|
|
|
await atomicWriteJson(filePath, null);
|
|
expect((secureFs.writeFile as unknown as MockInstance).mock.calls[0][1]).toBe('null');
|
|
|
|
await atomicWriteJson(filePath, 'string');
|
|
expect((secureFs.writeFile as unknown as MockInstance).mock.calls[1][1]).toBe('"string"');
|
|
|
|
await atomicWriteJson(filePath, 123);
|
|
expect((secureFs.writeFile as unknown as MockInstance).mock.calls[2][1]).toBe('123');
|
|
});
|
|
|
|
it('should create directories when createDirs is true', async () => {
|
|
const filePath = path.join(tempDir, 'nested', 'deep', 'test.json');
|
|
const data = { key: 'value' };
|
|
|
|
(secureFs.writeFile as unknown as MockInstance).mockResolvedValue(undefined);
|
|
(secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined);
|
|
// Mock lstat to indicate directory already exists
|
|
(secureFs.lstat as unknown as MockInstance).mockResolvedValue({
|
|
isDirectory: () => true,
|
|
isSymbolicLink: () => false,
|
|
});
|
|
|
|
await atomicWriteJson(filePath, data, { createDirs: true });
|
|
|
|
expect(secureFs.writeFile).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('readJsonFile', () => {
|
|
it('should read and parse JSON file', async () => {
|
|
const filePath = path.join(tempDir, 'read.json');
|
|
const data = { key: 'value', count: 5 };
|
|
|
|
(secureFs.readFile as unknown as MockInstance).mockResolvedValue(JSON.stringify(data));
|
|
|
|
const result = await readJsonFile(filePath, {});
|
|
|
|
expect(result).toEqual(data);
|
|
expect(secureFs.readFile).toHaveBeenCalledWith(path.resolve(filePath), 'utf-8');
|
|
});
|
|
|
|
it('should return default value when file does not exist', async () => {
|
|
const filePath = path.join(tempDir, 'nonexistent.json');
|
|
const defaultValue = { default: true };
|
|
|
|
const enoentError = new Error('File not found') as NodeJS.ErrnoException;
|
|
enoentError.code = 'ENOENT';
|
|
(secureFs.readFile as unknown as MockInstance).mockRejectedValue(enoentError);
|
|
|
|
const result = await readJsonFile(filePath, defaultValue);
|
|
|
|
expect(result).toEqual(defaultValue);
|
|
});
|
|
|
|
it('should return default value when JSON is invalid', async () => {
|
|
const filePath = path.join(tempDir, 'invalid.json');
|
|
const defaultValue = { default: true };
|
|
|
|
(secureFs.readFile as unknown as MockInstance).mockResolvedValue('not valid json');
|
|
|
|
const result = await readJsonFile(filePath, defaultValue);
|
|
|
|
expect(result).toEqual(defaultValue);
|
|
});
|
|
|
|
it('should return default value for other read errors', async () => {
|
|
const filePath = path.join(tempDir, 'error.json');
|
|
const defaultValue = { default: true };
|
|
|
|
const accessError = new Error('Access denied') as NodeJS.ErrnoException;
|
|
accessError.code = 'EACCES';
|
|
(secureFs.readFile as unknown as MockInstance).mockRejectedValue(accessError);
|
|
|
|
const result = await readJsonFile(filePath, defaultValue);
|
|
|
|
expect(result).toEqual(defaultValue);
|
|
});
|
|
|
|
it('should handle empty object as default', async () => {
|
|
const filePath = path.join(tempDir, 'nonexistent.json');
|
|
|
|
const enoentError = new Error('File not found') as NodeJS.ErrnoException;
|
|
enoentError.code = 'ENOENT';
|
|
(secureFs.readFile as unknown as MockInstance).mockRejectedValue(enoentError);
|
|
|
|
const result = await readJsonFile<Record<string, unknown>>(filePath, {});
|
|
|
|
expect(result).toEqual({});
|
|
});
|
|
|
|
it('should handle array as default', async () => {
|
|
const filePath = path.join(tempDir, 'nonexistent.json');
|
|
|
|
const enoentError = new Error('File not found') as NodeJS.ErrnoException;
|
|
enoentError.code = 'ENOENT';
|
|
(secureFs.readFile as unknown as MockInstance).mockRejectedValue(enoentError);
|
|
|
|
const result = await readJsonFile<string[]>(filePath, []);
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('should parse nested objects correctly', async () => {
|
|
const filePath = path.join(tempDir, 'nested.json');
|
|
const data = {
|
|
level1: {
|
|
level2: {
|
|
value: 'deep',
|
|
array: [1, 2, { nested: true }],
|
|
},
|
|
},
|
|
};
|
|
|
|
(secureFs.readFile as unknown as MockInstance).mockResolvedValue(JSON.stringify(data));
|
|
|
|
const result = await readJsonFile(filePath, {});
|
|
|
|
expect(result).toEqual(data);
|
|
});
|
|
});
|
|
|
|
describe('updateJsonAtomically', () => {
|
|
it('should read, update, and write file atomically', async () => {
|
|
const filePath = path.join(tempDir, 'update.json');
|
|
const initialData = { count: 5 };
|
|
const defaultValue = { count: 0 };
|
|
|
|
(secureFs.readFile as unknown as MockInstance).mockResolvedValue(JSON.stringify(initialData));
|
|
(secureFs.writeFile as unknown as MockInstance).mockResolvedValue(undefined);
|
|
(secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined);
|
|
|
|
await updateJsonAtomically(filePath, defaultValue, (data) => ({
|
|
...data,
|
|
count: data.count + 1,
|
|
}));
|
|
|
|
// Verify the write was called with updated data
|
|
const writeCall = (secureFs.writeFile as unknown as MockInstance).mock.calls[0];
|
|
const writtenData = JSON.parse(writeCall[1]);
|
|
expect(writtenData.count).toBe(6);
|
|
});
|
|
|
|
it('should use default value when file does not exist', async () => {
|
|
const filePath = path.join(tempDir, 'new.json');
|
|
const defaultValue = { count: 0 };
|
|
|
|
const enoentError = new Error('File not found') as NodeJS.ErrnoException;
|
|
enoentError.code = 'ENOENT';
|
|
(secureFs.readFile as unknown as MockInstance).mockRejectedValue(enoentError);
|
|
(secureFs.writeFile as unknown as MockInstance).mockResolvedValue(undefined);
|
|
(secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined);
|
|
|
|
await updateJsonAtomically(filePath, defaultValue, (data) => ({
|
|
...data,
|
|
count: data.count + 1,
|
|
}));
|
|
|
|
const writeCall = (secureFs.writeFile as unknown as MockInstance).mock.calls[0];
|
|
const writtenData = JSON.parse(writeCall[1]);
|
|
expect(writtenData.count).toBe(1);
|
|
});
|
|
|
|
it('should support async updater function', async () => {
|
|
const filePath = path.join(tempDir, 'async.json');
|
|
const initialData = { value: 'initial' };
|
|
|
|
(secureFs.readFile as unknown as MockInstance).mockResolvedValue(JSON.stringify(initialData));
|
|
(secureFs.writeFile as unknown as MockInstance).mockResolvedValue(undefined);
|
|
(secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined);
|
|
|
|
await updateJsonAtomically(filePath, {}, async (data) => {
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
return { ...data, value: 'updated' };
|
|
});
|
|
|
|
const writeCall = (secureFs.writeFile as unknown as MockInstance).mock.calls[0];
|
|
const writtenData = JSON.parse(writeCall[1]);
|
|
expect(writtenData.value).toBe('updated');
|
|
});
|
|
|
|
it('should pass through options to atomicWriteJson', async () => {
|
|
const filePath = path.join(tempDir, 'options.json');
|
|
|
|
const enoentError = new Error('File not found') as NodeJS.ErrnoException;
|
|
enoentError.code = 'ENOENT';
|
|
(secureFs.readFile as unknown as MockInstance).mockRejectedValue(enoentError);
|
|
(secureFs.writeFile as unknown as MockInstance).mockResolvedValue(undefined);
|
|
(secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined);
|
|
|
|
await updateJsonAtomically(filePath, { key: 'value' }, (d) => d, { indent: 4 });
|
|
|
|
const writeCall = (secureFs.writeFile as unknown as MockInstance).mock.calls[0];
|
|
expect(writeCall[1]).toBe(JSON.stringify({ key: 'value' }, null, 4));
|
|
});
|
|
});
|
|
|
|
describe('readJsonWithRecovery', () => {
|
|
it('should return main file data when available', async () => {
|
|
const filePath = path.join(tempDir, 'main.json');
|
|
const data = { main: true };
|
|
|
|
(secureFs.readFile as unknown as MockInstance).mockResolvedValue(JSON.stringify(data));
|
|
|
|
const result = await readJsonWithRecovery(filePath, {});
|
|
|
|
expect(result.data).toEqual(data);
|
|
expect(result.recovered).toBe(false);
|
|
expect(result.source).toBe('main');
|
|
expect(result.error).toBeUndefined();
|
|
});
|
|
|
|
it('should recover from temp file when main file is missing', async () => {
|
|
const filePath = path.join(tempDir, 'data.json');
|
|
const tempData = { fromTemp: true };
|
|
const fileName = path.basename(filePath);
|
|
|
|
const enoentError = new Error('File not found') as NodeJS.ErrnoException;
|
|
enoentError.code = 'ENOENT';
|
|
|
|
(secureFs.readFile as unknown as MockInstance)
|
|
.mockRejectedValueOnce(enoentError) // Main file
|
|
.mockResolvedValueOnce(JSON.stringify(tempData)); // Temp file
|
|
|
|
(secureFs.readdir as unknown as MockInstance).mockResolvedValue([
|
|
`${fileName}.tmp.1234567890`,
|
|
'other-file.json',
|
|
]);
|
|
|
|
(secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined);
|
|
|
|
const result = await readJsonWithRecovery(filePath, {});
|
|
|
|
expect(result.data).toEqual(tempData);
|
|
expect(result.recovered).toBe(true);
|
|
expect(result.source).toBe('temp');
|
|
expect(result.error).toBe('File does not exist');
|
|
});
|
|
|
|
it('should recover from backup file when main and temp are unavailable', async () => {
|
|
const filePath = path.join(tempDir, 'data.json');
|
|
const backupData = { fromBackup: true };
|
|
|
|
const enoentError = new Error('File not found') as NodeJS.ErrnoException;
|
|
enoentError.code = 'ENOENT';
|
|
|
|
(secureFs.readFile as unknown as MockInstance)
|
|
.mockRejectedValueOnce(enoentError) // Main file
|
|
.mockRejectedValueOnce(enoentError) // backup1
|
|
.mockResolvedValueOnce(JSON.stringify(backupData)); // backup2
|
|
|
|
(secureFs.readdir as unknown as MockInstance).mockResolvedValue([]); // No temp files
|
|
|
|
(secureFs.copyFile as unknown as MockInstance).mockResolvedValue(undefined);
|
|
|
|
const result = await readJsonWithRecovery(filePath, {});
|
|
|
|
expect(result.data).toEqual(backupData);
|
|
expect(result.recovered).toBe(true);
|
|
expect(result.source).toBe('backup');
|
|
});
|
|
|
|
it('should return default when all recovery attempts fail', async () => {
|
|
const filePath = path.join(tempDir, 'data.json');
|
|
const defaultValue = { default: true };
|
|
|
|
const enoentError = new Error('File not found') as NodeJS.ErrnoException;
|
|
enoentError.code = 'ENOENT';
|
|
|
|
(secureFs.readFile as unknown as MockInstance).mockRejectedValue(enoentError);
|
|
(secureFs.readdir as unknown as MockInstance).mockResolvedValue([]);
|
|
|
|
const result = await readJsonWithRecovery(filePath, defaultValue);
|
|
|
|
expect(result.data).toEqual(defaultValue);
|
|
expect(result.recovered).toBe(true);
|
|
expect(result.source).toBe('default');
|
|
expect(result.error).toBe('File does not exist');
|
|
});
|
|
|
|
it('should try multiple temp files in order', async () => {
|
|
const filePath = path.join(tempDir, 'data.json');
|
|
const fileName = path.basename(filePath);
|
|
const validTempData = { valid: true };
|
|
|
|
const enoentError = new Error('File not found') as NodeJS.ErrnoException;
|
|
enoentError.code = 'ENOENT';
|
|
|
|
(secureFs.readFile as unknown as MockInstance)
|
|
.mockRejectedValueOnce(enoentError) // Main file
|
|
.mockResolvedValueOnce('invalid json') // First temp file (invalid)
|
|
.mockResolvedValueOnce(JSON.stringify(validTempData)); // Second temp file
|
|
|
|
(secureFs.readdir as unknown as MockInstance).mockResolvedValue([
|
|
`${fileName}.tmp.9999999999`, // Most recent
|
|
`${fileName}.tmp.1111111111`, // Older
|
|
]);
|
|
|
|
(secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined);
|
|
|
|
const result = await readJsonWithRecovery(filePath, {});
|
|
|
|
expect(result.data).toEqual(validTempData);
|
|
expect(result.source).toBe('temp');
|
|
});
|
|
|
|
it('should try multiple backup files in order', async () => {
|
|
const filePath = path.join(tempDir, 'data.json');
|
|
const backupData = { backup2: true };
|
|
|
|
const enoentError = new Error('File not found') as NodeJS.ErrnoException;
|
|
enoentError.code = 'ENOENT';
|
|
|
|
(secureFs.readFile as unknown as MockInstance)
|
|
.mockRejectedValueOnce(enoentError) // Main file
|
|
.mockRejectedValueOnce(enoentError) // .bak1
|
|
.mockResolvedValueOnce(JSON.stringify(backupData)); // .bak2
|
|
|
|
(secureFs.readdir as unknown as MockInstance).mockResolvedValue([]);
|
|
|
|
(secureFs.copyFile as unknown as MockInstance).mockResolvedValue(undefined);
|
|
|
|
const result = await readJsonWithRecovery(filePath, {});
|
|
|
|
expect(result.data).toEqual(backupData);
|
|
expect(result.source).toBe('backup');
|
|
|
|
// Verify it tried .bak1 first
|
|
expect(secureFs.readFile).toHaveBeenNthCalledWith(
|
|
2,
|
|
`${path.resolve(filePath)}.bak1`,
|
|
'utf-8'
|
|
);
|
|
});
|
|
|
|
it('should respect maxBackups option', async () => {
|
|
const filePath = path.join(tempDir, 'data.json');
|
|
const defaultValue = { default: true };
|
|
|
|
const enoentError = new Error('File not found') as NodeJS.ErrnoException;
|
|
enoentError.code = 'ENOENT';
|
|
|
|
(secureFs.readFile as unknown as MockInstance).mockRejectedValue(enoentError);
|
|
(secureFs.readdir as unknown as MockInstance).mockResolvedValue([]);
|
|
|
|
const result = await readJsonWithRecovery(filePath, defaultValue, { maxBackups: 1 });
|
|
|
|
expect(result.source).toBe('default');
|
|
// Should only have tried main + 1 backup
|
|
expect(secureFs.readFile).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('should not auto-restore when autoRestore is false', async () => {
|
|
const filePath = path.join(tempDir, 'data.json');
|
|
const fileName = path.basename(filePath);
|
|
const tempData = { fromTemp: true };
|
|
|
|
const enoentError = new Error('File not found') as NodeJS.ErrnoException;
|
|
enoentError.code = 'ENOENT';
|
|
|
|
(secureFs.readFile as unknown as MockInstance)
|
|
.mockRejectedValueOnce(enoentError)
|
|
.mockResolvedValueOnce(JSON.stringify(tempData));
|
|
|
|
(secureFs.readdir as unknown as MockInstance).mockResolvedValue([`${fileName}.tmp.123`]);
|
|
|
|
const result = await readJsonWithRecovery(filePath, {}, { autoRestore: false });
|
|
|
|
expect(result.data).toEqual(tempData);
|
|
expect(secureFs.rename).not.toHaveBeenCalled();
|
|
expect(secureFs.copyFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle directory read errors gracefully', async () => {
|
|
const filePath = path.join(tempDir, 'data.json');
|
|
const backupData = { backup: true };
|
|
|
|
const enoentError = new Error('File not found') as NodeJS.ErrnoException;
|
|
enoentError.code = 'ENOENT';
|
|
|
|
(secureFs.readFile as unknown as MockInstance)
|
|
.mockRejectedValueOnce(enoentError) // Main file
|
|
.mockResolvedValueOnce(JSON.stringify(backupData)); // backup1
|
|
|
|
(secureFs.readdir as unknown as MockInstance).mockRejectedValue(new Error('Dir read failed'));
|
|
(secureFs.copyFile as unknown as MockInstance).mockResolvedValue(undefined);
|
|
|
|
const result = await readJsonWithRecovery(filePath, {});
|
|
|
|
// Should skip temp files and go to backups
|
|
expect(result.data).toEqual(backupData);
|
|
expect(result.source).toBe('backup');
|
|
});
|
|
|
|
it('should handle corrupted main file with valid error message', async () => {
|
|
const filePath = path.join(tempDir, 'corrupted.json');
|
|
const defaultValue = { default: true };
|
|
|
|
const parseError = new SyntaxError('Unexpected token');
|
|
(secureFs.readFile as unknown as MockInstance).mockResolvedValueOnce('{{invalid');
|
|
(secureFs.readdir as unknown as MockInstance).mockResolvedValue([]);
|
|
|
|
// Mock to actually throw parse error
|
|
(secureFs.readFile as unknown as MockInstance).mockImplementationOnce(() => {
|
|
return Promise.resolve('{{invalid json');
|
|
});
|
|
|
|
const result = await readJsonWithRecovery(filePath, defaultValue);
|
|
|
|
expect(result.recovered).toBe(true);
|
|
expect(result.error).toContain('Failed to parse');
|
|
});
|
|
|
|
it('should handle restore failures gracefully', async () => {
|
|
const filePath = path.join(tempDir, 'data.json');
|
|
const fileName = path.basename(filePath);
|
|
const tempData = { fromTemp: true };
|
|
|
|
const enoentError = new Error('File not found') as NodeJS.ErrnoException;
|
|
enoentError.code = 'ENOENT';
|
|
|
|
(secureFs.readFile as unknown as MockInstance)
|
|
.mockRejectedValueOnce(enoentError)
|
|
.mockResolvedValueOnce(JSON.stringify(tempData));
|
|
|
|
(secureFs.readdir as unknown as MockInstance).mockResolvedValue([`${fileName}.tmp.123`]);
|
|
(secureFs.rename as unknown as MockInstance).mockRejectedValue(new Error('Restore failed'));
|
|
|
|
const result = await readJsonWithRecovery(filePath, {});
|
|
|
|
// Should still return data even if restore failed
|
|
expect(result.data).toEqual(tempData);
|
|
expect(result.source).toBe('temp');
|
|
});
|
|
});
|
|
|
|
describe('Edge cases', () => {
|
|
it('should handle empty file path gracefully', async () => {
|
|
(secureFs.readFile as unknown as MockInstance).mockRejectedValue(new Error('Invalid path'));
|
|
|
|
const result = await readJsonFile('', { default: true });
|
|
|
|
expect(result).toEqual({ default: true });
|
|
});
|
|
|
|
it('should handle special characters in file path', async () => {
|
|
const filePath = path.join(tempDir, 'file with spaces & special!.json');
|
|
const data = { special: 'chars' };
|
|
|
|
(secureFs.writeFile as unknown as MockInstance).mockResolvedValue(undefined);
|
|
(secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined);
|
|
|
|
await atomicWriteJson(filePath, data);
|
|
|
|
expect(secureFs.writeFile).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle very large objects', async () => {
|
|
const filePath = path.join(tempDir, 'large.json');
|
|
const largeArray = Array.from({ length: 10000 }, (_, i) => ({
|
|
id: i,
|
|
data: `item-${i}`,
|
|
}));
|
|
|
|
(secureFs.writeFile as unknown as MockInstance).mockResolvedValue(undefined);
|
|
(secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined);
|
|
|
|
await atomicWriteJson(filePath, largeArray);
|
|
|
|
const writeCall = (secureFs.writeFile as unknown as MockInstance).mock.calls[0];
|
|
expect(JSON.parse(writeCall[1])).toEqual(largeArray);
|
|
});
|
|
|
|
it('should handle unicode content', async () => {
|
|
const filePath = path.join(tempDir, 'unicode.json');
|
|
const data = { emoji: '🎉', japanese: 'こんにちは', chinese: '你好' };
|
|
|
|
(secureFs.writeFile as unknown as MockInstance).mockResolvedValue(undefined);
|
|
(secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined);
|
|
|
|
await atomicWriteJson(filePath, data);
|
|
|
|
const writeCall = (secureFs.writeFile as unknown as MockInstance).mock.calls[0];
|
|
expect(JSON.parse(writeCall[1])).toEqual(data);
|
|
});
|
|
|
|
it('should handle circular reference error in JSON', async () => {
|
|
const filePath = path.join(tempDir, 'circular.json');
|
|
const circular: Record<string, unknown> = { key: 'value' };
|
|
circular.self = circular;
|
|
|
|
await expect(atomicWriteJson(filePath, circular)).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('Type safety', () => {
|
|
interface TestConfig {
|
|
version: number;
|
|
settings: {
|
|
enabled: boolean;
|
|
name: string;
|
|
};
|
|
}
|
|
|
|
it('should preserve types in readJsonFile', async () => {
|
|
const filePath = path.join(tempDir, 'config.json');
|
|
const expected: TestConfig = {
|
|
version: 1,
|
|
settings: { enabled: true, name: 'test' },
|
|
};
|
|
|
|
(secureFs.readFile as unknown as MockInstance).mockResolvedValue(JSON.stringify(expected));
|
|
|
|
const result = await readJsonFile<TestConfig>(filePath, {
|
|
version: 0,
|
|
settings: { enabled: false, name: '' },
|
|
});
|
|
|
|
expect(result.version).toBe(1);
|
|
expect(result.settings.enabled).toBe(true);
|
|
expect(result.settings.name).toBe('test');
|
|
});
|
|
|
|
it('should preserve types in updateJsonAtomically', async () => {
|
|
const filePath = path.join(tempDir, 'counter.json');
|
|
|
|
interface Counter {
|
|
count: number;
|
|
}
|
|
|
|
(secureFs.readFile as unknown as MockInstance).mockResolvedValue(
|
|
JSON.stringify({ count: 5 })
|
|
);
|
|
(secureFs.writeFile as unknown as MockInstance).mockResolvedValue(undefined);
|
|
(secureFs.rename as unknown as MockInstance).mockResolvedValue(undefined);
|
|
|
|
await updateJsonAtomically<Counter>(filePath, { count: 0 }, (data) => ({
|
|
count: data.count + 1,
|
|
}));
|
|
|
|
const writeCall = (secureFs.writeFile as unknown as MockInstance).mock.calls[0];
|
|
const writtenData: Counter = JSON.parse(writeCall[1]);
|
|
expect(writtenData.count).toBe(6);
|
|
});
|
|
});
|
|
});
|