agent-ecosystem/test/main/utils/pathDecoder.test.ts

415 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
__setPathDecoderCopyDirectoryForTests,
buildSessionPath,
buildSubagentsPath,
buildTodoPath,
decodePath,
encodePath,
encodePathPortable,
extractProjectName,
extractSessionId,
getProjectDirNameCandidates,
getAppDataPath,
getProjectsBasePath,
getSchedulesBasePath,
getTodosBasePath,
isValidEncodedPath,
setAppDataBasePath,
setClaudeBasePathOverride,
} from '../../../src/main/utils/pathDecoder';
describe('pathDecoder', () => {
const defaultHome = process.env.HOME ?? '/home/testuser';
const tempDirs: string[] = [];
afterEach(() => {
vi.restoreAllMocks();
__setPathDecoderCopyDirectoryForTests(null);
setAppDataBasePath(null);
setClaudeBasePathOverride(null);
vi.stubEnv('HOME', defaultHome);
while (tempDirs.length > 0) {
const tempDir = tempDirs.pop();
if (tempDir) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
});
function createTempHome(): string {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'path-decoder-home-'));
tempDirs.push(tempHome);
vi.stubEnv('HOME', tempHome);
return tempHome;
}
describe('encodePath', () => {
it('should encode a macOS-style absolute path', () => {
expect(encodePath('/Users/username/projectname')).toBe('-Users-username-projectname');
});
it('should encode a Windows-style absolute path', () => {
expect(encodePath('C:\\Users\\username\\projectname')).toBe('C--Users-username-projectname');
});
it('should handle empty string', () => {
expect(encodePath('')).toBe('');
});
it('should round-trip with decodePath for POSIX paths', () => {
const original = '/Users/username/projectname';
expect(decodePath(encodePath(original))).toBe(original);
});
it('should round-trip with decodePath for Windows paths', () => {
const original = 'C:/Users/username/projectname';
expect(decodePath(encodePath(original))).toBe(original);
});
it('should encode a Linux-style path', () => {
expect(encodePath('/home/user/projects/myapp')).toBe('-home-user-projects-myapp');
});
it('should produce orchestrator-compatible Windows storage keys', () => {
expect(encodePathPortable('C:\\Users\\User\\PROJECT_IT\\сlaude_team')).toBe(
'c--users-user-project-it--laude-team'
);
});
});
describe('decodePath', () => {
it('should decode a simple encoded path', () => {
expect(decodePath('-Users-username-projectname')).toBe('/Users/username/projectname');
});
it('should handle empty string', () => {
expect(decodePath('')).toBe('');
});
it('should ensure leading slash for absolute paths', () => {
expect(decodePath('Users-username-projectname')).toBe('/Users/username/projectname');
});
it('should decode path with multiple segments', () => {
expect(decodePath('-home-user-projects-myapp-src')).toBe('/home/user/projects/myapp/src');
});
it('should handle single segment path', () => {
expect(decodePath('-project')).toBe('/project');
});
it('should handle path with underscores', () => {
expect(decodePath('-Users-username-my_projectname')).toBe('/Users/username/my_projectname');
});
it('should handle path with dots', () => {
expect(decodePath('-Users-username-.config')).toBe('/Users/username/.config');
});
it('should decode Windows-style encoded path without adding leading slash', () => {
expect(decodePath('-C:-Users-username-projectname')).toBe('C:/Users/username/projectname');
});
it('should decode legacy Windows-style encoded path without leading dash', () => {
expect(decodePath('C--Users-username-projectname')).toBe('C:/Users/username/projectname');
});
});
describe('extractProjectName', () => {
it('should extract project name from encoded path', () => {
expect(extractProjectName('-Users-username-projectname')).toBe('projectname');
});
it('should handle deeply nested paths', () => {
expect(extractProjectName('-home-user-dev-projects-appname')).toBe('appname');
});
it('should return encoded name if decoding fails', () => {
expect(extractProjectName('')).toBe('');
});
it('should handle single segment', () => {
expect(extractProjectName('-projectname')).toBe('projectname');
});
it('should handle path with underscore in project name', () => {
expect(extractProjectName('-Users-username-my_cool_projectname')).toBe('my_cool_projectname');
});
it('should prefer cwdHint over lossy decode for dashed project names', () => {
// Without cwdHint, dashes are decoded as slashes (lossy)
expect(extractProjectName('-Users-name-claude-devtools')).toBe('devtools');
// With cwdHint, the actual project name is preserved
expect(extractProjectName('-Users-name-claude-devtools', '/Users/name/claude-devtools')).toBe(
'claude-devtools'
);
});
it('should fall back to decoded name when cwdHint is undefined', () => {
expect(extractProjectName('-Users-username-projectname')).toBe('projectname');
});
});
describe('isValidEncodedPath', () => {
it('should return true for valid encoded path', () => {
expect(isValidEncodedPath('-Users-username-projectname')).toBe(true);
});
it('should return false for empty string', () => {
expect(isValidEncodedPath('')).toBe(false);
});
it('should return false for path without leading dash', () => {
expect(isValidEncodedPath('Users-username-projectname')).toBe(false);
});
it('should return true for path with underscores', () => {
expect(isValidEncodedPath('-Users-username-my_projectname')).toBe(true);
});
it('should return true for path with dots', () => {
expect(isValidEncodedPath('-Users-username-.config')).toBe(true);
});
it('should return true for path with numbers', () => {
expect(isValidEncodedPath('-Users-username-projectname123')).toBe(true);
});
it('should return true for path with spaces', () => {
expect(isValidEncodedPath('-Users-username-My Projectname')).toBe(true);
});
it('should return true for valid Windows-style encoded path', () => {
expect(isValidEncodedPath('C--Users-username-projectname')).toBe(true);
});
it('should return true for old colon Windows-style encoded path', () => {
expect(isValidEncodedPath('-C:-Users-username-projectname')).toBe(true);
});
it('should return true for legacy Windows-style encoded path', () => {
expect(isValidEncodedPath('C--Users-username-projectname')).toBe(true);
});
it('should return true for Windows encoded paths with underscores and Unicode', () => {
expect(isValidEncodedPath('C--Users-User-PROJECT_IT-сlaude_team')).toBe(true);
});
it('should return false for misplaced colons', () => {
expect(isValidEncodedPath('-Users-username:project')).toBe(false);
expect(isValidEncodedPath('-C:-Users-name-project:extra')).toBe(false);
});
});
describe('getProjectDirNameCandidates', () => {
it('includes the orchestrator storage key for the current Windows project path shape', () => {
expect(getProjectDirNameCandidates('C--Users-User-PROJECT_IT-сlaude_team')).toEqual(
expect.arrayContaining([
'C--Users-User-PROJECT_IT-сlaude_team',
'c--users-user-project-it--laude-team',
])
);
});
});
describe('extractSessionId', () => {
it('should extract session ID from JSONL filename', () => {
expect(extractSessionId('abc123.jsonl')).toBe('abc123');
});
it('should handle UUID-style session IDs', () => {
expect(extractSessionId('550e8400-e29b-41d4-a716-446655440000.jsonl')).toBe(
'550e8400-e29b-41d4-a716-446655440000'
);
});
it('should handle filename without extension', () => {
expect(extractSessionId('session123')).toBe('session123');
});
it('should handle empty string', () => {
expect(extractSessionId('')).toBe('');
});
});
describe('buildSessionPath', () => {
it('should construct correct session path', () => {
expect(buildSessionPath('/base', 'project-id', 'session-123')).toBe(
path.join('/base', 'project-id', 'session-123.jsonl')
);
});
it('should handle paths with special characters', () => {
expect(buildSessionPath('/home/user/.claude/projects', '-Users-name', 'abc123')).toBe(
path.join('/home/user/.claude/projects', '-Users-name', 'abc123.jsonl')
);
});
});
describe('buildSubagentsPath', () => {
it('should construct correct subagents path', () => {
expect(buildSubagentsPath('/base', 'project-id', 'session-123')).toBe(
path.join('/base', 'project-id', 'session-123', 'subagents')
);
});
});
describe('buildTodoPath', () => {
it('should construct correct todo path', () => {
expect(buildTodoPath('/home/user/.claude', 'session-123')).toBe(
path.join('/home/user/.claude', 'todos', 'session-123.json')
);
});
});
describe('getProjectsBasePath', () => {
it('should return projects base path', () => {
expect(getProjectsBasePath()).toBe(path.join(defaultHome, '.claude', 'projects'));
});
});
describe('getTodosBasePath', () => {
it('should return todos base path', () => {
expect(getTodosBasePath()).toBe(path.join(defaultHome, '.claude', 'todos'));
});
});
describe('getSchedulesBasePath', () => {
it('should use the new schedules directory when no legacy data exists', () => {
const root = path.join(createTempHome(), '.claude');
setClaudeBasePathOverride(root);
expect(getSchedulesBasePath()).toBe(path.join(root, 'agent-teams-schedules'));
});
it('should migrate legacy schedules data when the new directory is absent', () => {
const root = path.join(createTempHome(), '.claude');
const legacyRoot = path.join(root, 'claude-devtools-schedules');
const files = [
['schedules.json', '[{"id":"sched-1"}]'],
['runs/sched-1.json', '[{"id":"run-1"}]'],
['logs/sched-1/run-1.log', 'stdout'],
] as const;
setClaudeBasePathOverride(root);
for (const [relativePath, content] of files) {
const legacyFile = path.join(legacyRoot, relativePath);
fs.mkdirSync(path.dirname(legacyFile), { recursive: true });
fs.writeFileSync(legacyFile, content);
}
expect(getSchedulesBasePath()).toBe(path.join(root, 'agent-teams-schedules'));
for (const [relativePath, content] of files) {
expect(
fs.readFileSync(path.join(root, 'agent-teams-schedules', relativePath), 'utf8')
).toBe(content);
}
expect(fs.existsSync(legacyRoot)).toBe(true);
});
it('should prefer populated new schedules data over legacy schedules data', () => {
const root = path.join(createTempHome(), '.claude');
const currentFile = path.join(root, 'agent-teams-schedules', 'schedules.json');
const legacyFile = path.join(root, 'claude-devtools-schedules', 'schedules.json');
setClaudeBasePathOverride(root);
fs.mkdirSync(path.dirname(currentFile), { recursive: true });
fs.mkdirSync(path.dirname(legacyFile), { recursive: true });
fs.writeFileSync(currentFile, '[{"id":"current"}]');
fs.writeFileSync(legacyFile, '[{"id":"legacy"}]');
expect(getSchedulesBasePath()).toBe(path.join(root, 'agent-teams-schedules'));
expect(fs.readFileSync(currentFile, 'utf8')).toBe('[{"id":"current"}]');
});
it('should fall back to legacy schedules when copying fails and new directory is empty', () => {
const root = path.join(createTempHome(), '.claude');
const currentRoot = path.join(root, 'agent-teams-schedules');
const legacyRoot = path.join(root, 'claude-devtools-schedules');
setClaudeBasePathOverride(root);
fs.mkdirSync(currentRoot, { recursive: true });
fs.mkdirSync(legacyRoot, { recursive: true });
fs.writeFileSync(path.join(legacyRoot, 'schedules.json'), '[{"id":"legacy"}]');
__setPathDecoderCopyDirectoryForTests(() => {
throw new Error('copy failed');
});
expect(getSchedulesBasePath()).toBe(legacyRoot);
});
});
describe('getAppDataPath', () => {
it('should use explicit app data base override', () => {
setAppDataBasePath('/tmp/agent-teams-data');
expect(getAppDataPath()).toBe(path.join('/tmp/agent-teams-data', 'data'));
});
it('should use the new fallback app data path when no legacy data exists', () => {
const home = createTempHome();
expect(getAppDataPath()).toBe(path.join(home, '.agent-teams-ai', 'data'));
});
it('should migrate legacy fallback app data when the new path is absent', () => {
const home = createTempHome();
const legacyRoot = path.join(home, '.claude-agent-teams-ui');
const files = [
['data/attachments/team-a/note.txt', 'legacy attachment'],
['data/task-attachments/team-a/task-1/file.txt', 'legacy task attachment'],
['backups/registry.json', '{}'],
['mcp-configs/agent-teams-mcp-old.json', '{}'],
['mcp-server/1.3.0/index.js', 'console.log("mcp")'],
['future-store/state.json', '{"kept":true}'],
] as const;
for (const [relativePath, content] of files) {
const legacyFile = path.join(legacyRoot, relativePath);
fs.mkdirSync(path.dirname(legacyFile), { recursive: true });
fs.writeFileSync(legacyFile, content);
}
expect(getAppDataPath()).toBe(path.join(home, '.agent-teams-ai', 'data'));
for (const [relativePath, content] of files) {
expect(fs.readFileSync(path.join(home, '.agent-teams-ai', relativePath), 'utf8')).toBe(
content
);
}
expect(fs.existsSync(path.join(home, '.claude-agent-teams-ui'))).toBe(true);
});
it('should prefer populated new fallback app data over legacy data', () => {
const home = createTempHome();
const currentFile = path.join(home, '.agent-teams-ai', 'data', 'current.txt');
const legacyFile = path.join(home, '.claude-agent-teams-ui', 'data', 'legacy.txt');
fs.mkdirSync(path.dirname(currentFile), { recursive: true });
fs.mkdirSync(path.dirname(legacyFile), { recursive: true });
fs.writeFileSync(currentFile, 'current data');
fs.writeFileSync(legacyFile, 'legacy data');
expect(getAppDataPath()).toBe(path.join(home, '.agent-teams-ai', 'data'));
expect(fs.existsSync(path.join(home, '.agent-teams-ai', 'data', 'legacy.txt'))).toBe(false);
});
it('should fall back to legacy fallback app data when copying fails and new path is empty', () => {
const home = createTempHome();
const currentRoot = path.join(home, '.agent-teams-ai');
const legacyRoot = path.join(home, '.claude-agent-teams-ui');
fs.mkdirSync(currentRoot, { recursive: true });
fs.mkdirSync(path.join(legacyRoot, 'data'), { recursive: true });
fs.writeFileSync(path.join(legacyRoot, 'data', 'legacy.txt'), 'legacy data');
__setPathDecoderCopyDirectoryForTests(() => {
throw new Error('copy failed');
});
expect(getAppDataPath()).toBe(path.join(legacyRoot, 'data'));
});
});
});