mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
test(path-resolver): add unit tests for path resolution and validation
Solution-ID: SOL-1735386000002 Issue-ID: ISS-1766921318981-16 Task-ID: T1
This commit is contained in:
283
ccw/tests/path-resolver.test.ts
Normal file
283
ccw/tests/path-resolver.test.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Unit tests for path-resolver utility module.
|
||||
*
|
||||
* Notes:
|
||||
* - Targets the runtime implementation shipped in `ccw/dist`.
|
||||
* - Uses in-memory stubs for fs + os to avoid touching the real filesystem.
|
||||
*/
|
||||
|
||||
import { after, beforeEach, describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
// Use CJS exports so we can monkeypatch properties before importing the ESM under test.
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fs = require('node:fs') as typeof import('node:fs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const os = require('node:os') as typeof import('node:os');
|
||||
|
||||
const TEST_CCW_HOME = path.join(process.cwd(), '.tmp-ccw-path-resolver-home');
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
process.env.CCW_DATA_DIR = TEST_CCW_HOME;
|
||||
|
||||
type FsState = {
|
||||
existing: Set<string>;
|
||||
files: Map<string, string>;
|
||||
directories: Set<string>;
|
||||
realpaths: Map<string, string>;
|
||||
mkdirCalls: Array<{ path: string; options: unknown }>;
|
||||
writeCalls: Array<{ path: string; data: string; encoding: string }>;
|
||||
};
|
||||
|
||||
const fsState: FsState = {
|
||||
existing: new Set(),
|
||||
files: new Map(),
|
||||
directories: new Set(),
|
||||
realpaths: new Map(),
|
||||
mkdirCalls: [],
|
||||
writeCalls: [],
|
||||
};
|
||||
|
||||
function key(filePath: string): string {
|
||||
return path.resolve(filePath).replace(/\\/g, '/').toLowerCase();
|
||||
}
|
||||
|
||||
function setExists(filePath: string, exists: boolean): void {
|
||||
const normalized = key(filePath);
|
||||
if (exists) fsState.existing.add(normalized);
|
||||
else fsState.existing.delete(normalized);
|
||||
}
|
||||
|
||||
function setDir(filePath: string, isDirectory: boolean): void {
|
||||
const normalized = key(filePath);
|
||||
if (isDirectory) fsState.directories.add(normalized);
|
||||
else fsState.directories.delete(normalized);
|
||||
}
|
||||
|
||||
function setFile(filePath: string, content: string): void {
|
||||
const normalized = key(filePath);
|
||||
fsState.files.set(normalized, content);
|
||||
fsState.existing.add(normalized);
|
||||
}
|
||||
|
||||
function setRealpath(filePath: string, realPath: string): void {
|
||||
fsState.realpaths.set(key(filePath), realPath);
|
||||
}
|
||||
|
||||
const originalFs = {
|
||||
existsSync: fs.existsSync,
|
||||
readFileSync: fs.readFileSync,
|
||||
writeFileSync: fs.writeFileSync,
|
||||
mkdirSync: fs.mkdirSync,
|
||||
realpathSync: fs.realpathSync,
|
||||
statSync: fs.statSync,
|
||||
};
|
||||
const originalHomedir = os.homedir;
|
||||
|
||||
const TEST_HOME = path.join(process.cwd(), '.tmp-ccw-path-resolver-user-home');
|
||||
os.homedir = () => TEST_HOME;
|
||||
|
||||
fs.existsSync = ((filePath: string) => fsState.existing.has(key(filePath))) as any;
|
||||
fs.readFileSync = ((filePath: string, encoding: string) => {
|
||||
assert.equal(encoding, 'utf8');
|
||||
const value = fsState.files.get(key(filePath));
|
||||
if (value === undefined) {
|
||||
throw new Error(`ENOENT: no such file or directory, open '${filePath}'`);
|
||||
}
|
||||
return value;
|
||||
}) as any;
|
||||
fs.writeFileSync = ((filePath: string, data: string, encoding: string) => {
|
||||
fsState.writeCalls.push({ path: filePath, data: String(data), encoding: String(encoding) });
|
||||
setFile(filePath, String(data));
|
||||
}) as any;
|
||||
fs.mkdirSync = ((dirPath: string, options: unknown) => {
|
||||
fsState.mkdirCalls.push({ path: dirPath, options });
|
||||
setExists(dirPath, true);
|
||||
setDir(dirPath, true);
|
||||
}) as any;
|
||||
fs.realpathSync = ((filePath: string) => {
|
||||
const mapped = fsState.realpaths.get(key(filePath));
|
||||
return mapped ?? filePath;
|
||||
}) as any;
|
||||
fs.statSync = ((filePath: string) => {
|
||||
return {
|
||||
isDirectory: () => fsState.directories.has(key(filePath)),
|
||||
} as any;
|
||||
}) as any;
|
||||
|
||||
const pathResolverUrl = new URL('../dist/utils/path-resolver.js', import.meta.url).href;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let pathResolver: any;
|
||||
|
||||
beforeEach(() => {
|
||||
fsState.existing.clear();
|
||||
fsState.files.clear();
|
||||
fsState.directories.clear();
|
||||
fsState.realpaths.clear();
|
||||
fsState.mkdirCalls.length = 0;
|
||||
fsState.writeCalls.length = 0;
|
||||
});
|
||||
|
||||
describe('path-resolver utility module', async () => {
|
||||
pathResolver = await import(pathResolverUrl);
|
||||
|
||||
it('resolvePath expands ~ and resolves relative paths', () => {
|
||||
const resolvedHome = pathResolver.resolvePath('~/proj');
|
||||
assert.equal(resolvedHome, path.join(TEST_HOME, 'proj'));
|
||||
|
||||
const relativeInput = path.join('some', 'dir');
|
||||
assert.equal(pathResolver.resolvePath(relativeInput), path.resolve(relativeInput));
|
||||
|
||||
assert.equal(pathResolver.resolvePath(''), process.cwd());
|
||||
});
|
||||
|
||||
it('validatePath rejects empty input and control characters', () => {
|
||||
assert.deepEqual(pathResolver.validatePath(''), {
|
||||
valid: false,
|
||||
path: null,
|
||||
error: 'Path is required',
|
||||
});
|
||||
|
||||
const res = pathResolver.validatePath('C:\\allowed\\file.txt\0');
|
||||
assert.equal(res.valid, false);
|
||||
assert.equal(res.path, null);
|
||||
assert.equal(res.error, 'Path contains invalid characters');
|
||||
});
|
||||
|
||||
it('validatePath enforces baseDir boundary and detects traversal', () => {
|
||||
const baseDir = 'C:\\allowed';
|
||||
const traversal = pathResolver.validatePath('C:\\allowed\\..\\secret', { baseDir });
|
||||
assert.equal(traversal.valid, false);
|
||||
assert.equal(traversal.path, null);
|
||||
assert.ok(traversal.error?.includes('Path must be within'));
|
||||
|
||||
const outside = pathResolver.validatePath('C:\\secret', { baseDir });
|
||||
assert.equal(outside.valid, false);
|
||||
assert.ok(outside.error?.includes('Path must be within'));
|
||||
});
|
||||
|
||||
it('validatePath validates existence when mustExist is set', () => {
|
||||
const missing = pathResolver.validatePath('C:\\does-not-exist', { mustExist: true });
|
||||
assert.equal(missing.valid, false);
|
||||
assert.equal(missing.path, null);
|
||||
assert.ok(missing.error?.includes('Path does not exist:'));
|
||||
});
|
||||
|
||||
it('validatePath resolves symlinks via realpathSync when path exists', () => {
|
||||
const baseDir = 'C:\\allowed';
|
||||
const linkPath = 'C:\\allowed\\link';
|
||||
setExists(linkPath, true);
|
||||
setRealpath(linkPath, 'C:\\secret');
|
||||
|
||||
const res = pathResolver.validatePath(linkPath, { baseDir });
|
||||
assert.equal(res.valid, false);
|
||||
assert.equal(res.path, null);
|
||||
assert.ok(res.error?.includes('Path must be within'));
|
||||
});
|
||||
|
||||
it('validateOutputPath rejects directories and resolves relative output paths', () => {
|
||||
assert.equal(pathResolver.validateOutputPath('').valid, false);
|
||||
|
||||
const out = pathResolver.validateOutputPath('out.txt', 'C:\\base');
|
||||
assert.equal(out.valid, true);
|
||||
assert.equal(out.path, path.resolve(path.join('C:\\base', 'out.txt')));
|
||||
|
||||
const existingDir = path.resolve('C:\\base\\dir-output');
|
||||
setExists(existingDir, true);
|
||||
setDir(existingDir, true);
|
||||
const dirRes = pathResolver.validateOutputPath(existingDir, 'C:\\base');
|
||||
assert.equal(dirRes.valid, false);
|
||||
assert.equal(dirRes.error, 'Output path is a directory, expected a file');
|
||||
});
|
||||
|
||||
it('getTemplateLocations and findTemplate use known locations', () => {
|
||||
const homeTemplates = path.join(TEST_HOME, '.claude', 'templates');
|
||||
const cwdTemplates = path.join(process.cwd(), '.claude', 'templates');
|
||||
setExists(homeTemplates, true);
|
||||
setExists(cwdTemplates, false);
|
||||
|
||||
const locations = pathResolver.getTemplateLocations();
|
||||
assert.deepEqual(locations, [homeTemplates]);
|
||||
|
||||
const templateName = 'workflow-dashboard.html';
|
||||
const templatePath = path.join(homeTemplates, templateName);
|
||||
setExists(templatePath, true);
|
||||
|
||||
assert.equal(pathResolver.findTemplate(templateName), templatePath);
|
||||
assert.equal(pathResolver.findTemplate('does-not-exist.html'), null);
|
||||
});
|
||||
|
||||
it('ensureDir creates directory only when missing', () => {
|
||||
const dirPath = 'C:\\tmp\\ensureDir';
|
||||
pathResolver.ensureDir(dirPath);
|
||||
assert.equal(fsState.mkdirCalls.length, 1);
|
||||
assert.equal(fsState.mkdirCalls[0]?.path, dirPath);
|
||||
|
||||
pathResolver.ensureDir(dirPath);
|
||||
assert.equal(fsState.mkdirCalls.length, 1);
|
||||
});
|
||||
|
||||
it('normalizePathForDisplay converts backslashes to forward slashes', () => {
|
||||
assert.equal(pathResolver.normalizePathForDisplay('C:\\a\\b'), 'C:/a/b');
|
||||
});
|
||||
|
||||
it('getRecentPaths reads legacy file when legacy exists and new does not', () => {
|
||||
const legacyFile = path.join(TEST_HOME, '.ccw-recent-paths.json');
|
||||
setExists(legacyFile, true);
|
||||
setFile(legacyFile, JSON.stringify({ paths: ['C:/one', 'C:/two'] }));
|
||||
|
||||
const paths = pathResolver.getRecentPaths();
|
||||
assert.deepEqual(paths, ['C:/one', 'C:/two']);
|
||||
});
|
||||
|
||||
it('trackRecentPath updates recent list and writes to centralized storage', () => {
|
||||
const recentFile = path.join(TEST_CCW_HOME, 'config', 'recent-paths.json');
|
||||
setExists(recentFile, true);
|
||||
setFile(recentFile, JSON.stringify({ paths: ['C:/old'] }));
|
||||
|
||||
pathResolver.trackRecentPath('C:\\project\\demo');
|
||||
|
||||
assert.ok(fsState.writeCalls.length >= 1);
|
||||
const latest = fsState.writeCalls.at(-1);
|
||||
assert.equal(latest?.path, recentFile);
|
||||
|
||||
const data = JSON.parse(latest?.data ?? '{}') as { paths?: string[] };
|
||||
assert.deepEqual(data.paths?.[0], 'C:/project/demo');
|
||||
});
|
||||
|
||||
it('removeRecentPath removes matching path and persists update', () => {
|
||||
const recentFile = path.join(TEST_CCW_HOME, 'config', 'recent-paths.json');
|
||||
setExists(recentFile, true);
|
||||
setFile(recentFile, JSON.stringify({ paths: ['C:/one', 'C:/two'] }));
|
||||
|
||||
const removed = pathResolver.removeRecentPath('C:\\two');
|
||||
assert.equal(removed, true);
|
||||
|
||||
const latest = fsState.writeCalls.at(-1);
|
||||
const data = JSON.parse(latest?.data ?? '{}') as { paths?: string[] };
|
||||
assert.deepEqual(data.paths, ['C:/one']);
|
||||
|
||||
assert.equal(pathResolver.removeRecentPath('C:\\not-present'), false);
|
||||
});
|
||||
});
|
||||
|
||||
after(() => {
|
||||
fs.existsSync = originalFs.existsSync;
|
||||
fs.readFileSync = originalFs.readFileSync;
|
||||
fs.writeFileSync = originalFs.writeFileSync;
|
||||
fs.mkdirSync = originalFs.mkdirSync;
|
||||
fs.realpathSync = originalFs.realpathSync;
|
||||
fs.statSync = originalFs.statSync;
|
||||
os.homedir = originalHomedir;
|
||||
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in ORIGINAL_ENV)) {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user