servers/src/memory/__tests__/file-path.test.ts
Amey Pawar 649af5856e
fix(memory): expand leading ~ in MEMORY_FILE_PATH to the home directory (#4447)
* fix(memory): expand leading ~ in MEMORY_FILE_PATH to the home directory

MCP clients pass MEMORY_FILE_PATH from JSON config, where no shell
expands a leading "~". Because path.isAbsolute("~/memory.jsonl") is
false, the value was joined onto the package directory, so the server
persisted to a literal "~" folder inside the install instead of the
user's intended location.

Add an expandHome() helper that mirrors the one already used by the
filesystem server (src/filesystem/path-utils.ts) and apply it in
ensureMemoryFilePath() before the existing absolute/relative resolution.
Absolute paths, relative paths, and a "~" not at the start are
unaffected. Adds unit tests for expandHome plus an end-to-end test
through ensureMemoryFilePath.

Addresses #1600

* test(memory): use a literal ~/ path in the tilde expansion test

path.join('~', ...) produces a backslash on Windows, which expandHome does
not match, so the test failed there.

---------

Co-authored-by: olaservo <olahungerford@gmail.com>
2026-09-02 18:15:44 -07:00

192 lines
6.2 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { fileURLToPath } from 'url';
import { ensureMemoryFilePath, defaultMemoryPath, expandHome } from '../index.js';
describe('ensureMemoryFilePath', () => {
const testDir = path.dirname(fileURLToPath(import.meta.url));
const oldMemoryPath = path.join(testDir, '..', 'memory.json');
const newMemoryPath = path.join(testDir, '..', 'memory.jsonl');
let originalEnv: string | undefined;
beforeEach(() => {
// Save original environment variable
originalEnv = process.env.MEMORY_FILE_PATH;
// Delete environment variable
delete process.env.MEMORY_FILE_PATH;
});
afterEach(async () => {
// Restore original environment variable
if (originalEnv !== undefined) {
process.env.MEMORY_FILE_PATH = originalEnv;
} else {
delete process.env.MEMORY_FILE_PATH;
}
// Clean up test files
try {
await fs.unlink(oldMemoryPath);
} catch {
// Ignore if file doesn't exist
}
try {
await fs.unlink(newMemoryPath);
} catch {
// Ignore if file doesn't exist
}
});
describe('with MEMORY_FILE_PATH environment variable', () => {
it('should return absolute path when MEMORY_FILE_PATH is absolute', async () => {
const absolutePath = '/tmp/custom-memory.jsonl';
process.env.MEMORY_FILE_PATH = absolutePath;
const result = await ensureMemoryFilePath();
expect(result).toBe(absolutePath);
});
it('should convert relative path to absolute when MEMORY_FILE_PATH is relative', async () => {
const relativePath = 'custom-memory.jsonl';
process.env.MEMORY_FILE_PATH = relativePath;
const result = await ensureMemoryFilePath();
expect(path.isAbsolute(result)).toBe(true);
expect(result).toContain('custom-memory.jsonl');
});
it('should handle Windows absolute paths', async () => {
const windowsPath = 'C:\\temp\\memory.jsonl';
process.env.MEMORY_FILE_PATH = windowsPath;
const result = await ensureMemoryFilePath();
// On Windows, should return as-is; on Unix, will be treated as relative
if (process.platform === 'win32') {
expect(result).toBe(windowsPath);
} else {
expect(path.isAbsolute(result)).toBe(true);
}
});
it('should expand a leading "~/" to the home directory', async () => {
process.env.MEMORY_FILE_PATH = '~/custom-memory.jsonl';
const result = await ensureMemoryFilePath();
expect(result).toBe(path.join(os.homedir(), 'custom-memory.jsonl'));
expect(path.isAbsolute(result)).toBe(true);
});
});
describe('without MEMORY_FILE_PATH environment variable', () => {
it('should return default path when no files exist', async () => {
const result = await ensureMemoryFilePath();
expect(result).toBe(defaultMemoryPath);
});
it('should migrate from memory.json to memory.jsonl when only old file exists', async () => {
// Create old memory.json file
await fs.writeFile(oldMemoryPath, '{"test":"data"}');
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const result = await ensureMemoryFilePath();
expect(result).toBe(defaultMemoryPath);
// Verify migration happened
const newFileExists = await fs.access(newMemoryPath).then(() => true).catch(() => false);
const oldFileExists = await fs.access(oldMemoryPath).then(() => true).catch(() => false);
expect(newFileExists).toBe(true);
expect(oldFileExists).toBe(false);
// Verify console messages
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('DETECTED: Found legacy memory.json file')
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('COMPLETED: Successfully migrated')
);
consoleErrorSpy.mockRestore();
});
it('should use new file when both old and new files exist', async () => {
// Create both files
await fs.writeFile(oldMemoryPath, '{"old":"data"}');
await fs.writeFile(newMemoryPath, '{"new":"data"}');
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const result = await ensureMemoryFilePath();
expect(result).toBe(defaultMemoryPath);
// Verify no migration happened (both files should still exist)
const newFileExists = await fs.access(newMemoryPath).then(() => true).catch(() => false);
const oldFileExists = await fs.access(oldMemoryPath).then(() => true).catch(() => false);
expect(newFileExists).toBe(true);
expect(oldFileExists).toBe(true);
// Verify no console messages about migration
expect(consoleErrorSpy).not.toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
it('should preserve file content during migration', async () => {
const testContent = '{"entities": [{"name": "test", "type": "person"}]}';
await fs.writeFile(oldMemoryPath, testContent);
await ensureMemoryFilePath();
const migratedContent = await fs.readFile(newMemoryPath, 'utf-8');
expect(migratedContent).toBe(testContent);
});
});
describe('defaultMemoryPath', () => {
it('should end with memory.jsonl', () => {
expect(defaultMemoryPath).toMatch(/memory\.jsonl$/);
});
it('should be an absolute path', () => {
expect(path.isAbsolute(defaultMemoryPath)).toBe(true);
});
});
});
describe('expandHome', () => {
it('expands a bare "~" to the home directory', () => {
expect(expandHome('~')).toBe(os.homedir());
});
it('expands a leading "~/" to the home directory', () => {
expect(expandHome('~/notes/memory.jsonl')).toBe(
path.join(os.homedir(), 'notes/memory.jsonl')
);
});
it('leaves a "~" not followed by a separator unchanged', () => {
expect(expandHome('~backup.jsonl')).toBe('~backup.jsonl');
});
it('leaves absolute paths unchanged', () => {
expect(expandHome('/var/data/memory.jsonl')).toBe('/var/data/memory.jsonl');
});
it('leaves relative paths unchanged', () => {
expect(expandHome(path.join('data', 'memory.jsonl'))).toBe(
path.join('data', 'memory.jsonl')
);
});
});