// tools/path-normalization.test.mjs import { describe, it, expect } from 'vitest'; import path from 'node:path'; import { expandHome, expandEnvVars, caseFold, globToRegExp, globMatch, pathNormalize, } from './path-normalization.mjs'; describe('expandHome', () => { it('replaces leading ~ with homedir', () => { expect(expandHome('~/.claude/runtime', '/home/u')).toBe('/home/u/.claude/runtime'); }); it('replaces bare ~', () => { expect(expandHome('~', '/home/u')).toBe('/home/u'); }); it('does NOT replace ~ in the middle', () => { expect(expandHome('/a/~/b', '/home/u')).toBe('/a/~/b'); }); it('leaves path without ~ unchanged', () => { expect(expandHome('/abs/path', '/home/u')).toBe('/abs/path'); }); }); describe('expandEnvVars', () => { it('%APPDATA%/x → /a/x', () => { expect(expandEnvVars('%APPDATA%/x', { APPDATA: '/a' })).toBe('/a/x'); }); it('${HOME}/y → /h/y', () => { expect(expandEnvVars('${HOME}/y', { HOME: '/h' })).toBe('/h/y'); }); it('$USERPROFILE\\z → C:/u\\z', () => { expect(expandEnvVars('$USERPROFILE\\z', { USERPROFILE: 'C:/u' })).toBe('C:/u\\z'); }); it('%SECRET%/x unchanged (not whitelisted)', () => { expect(expandEnvVars('%SECRET%/x', { SECRET: '/s' })).toBe('%SECRET%/x'); }); it('$HOMEDIR not matched inside longer name (boundary)', () => { expect(expandEnvVars('$HOMEDIR', { HOME: '/h' })).toBe('$HOMEDIR'); }); }); describe('caseFold', () => { it('lowercases on win32', () => { expect(caseFold('C:/Foo/Bar', 'win32')).toBe('c:/foo/bar'); }); it('unchanged on linux', () => { expect(caseFold('/Foo/Bar', 'linux')).toBe('/Foo/Bar'); }); }); describe('globToRegExp / globMatch', () => { it('single * matches within segment', () => { expect(globMatch('docs/superpowers/plans/x.md', 'docs/superpowers/plans/*.md')).toBe(true); }); it('single * does NOT cross segment boundary', () => { expect(globMatch('docs/superpowers/plans/sub/x.md', 'docs/superpowers/plans/*.md')).toBe(false); }); it('** matches across segments', () => { expect(globMatch('a/b/c/x.test.mjs', '**/*.test.mjs')).toBe(true); }); it('** matches wildcard extension', () => { expect(globMatch('x.test.ts', '**/*.test.*')).toBe(true); }); it('does not match unrelated path', () => { expect(globMatch('app/Models/User.php', 'docs/superpowers/plans/*.md')).toBe(false); }); it('tests/** matches nested', () => { expect(globMatch('tests/Feature/Foo.php', 'tests/**')).toBe(true); }); it('backslash normalized before match', () => { expect(globMatch('C:\\proj\\tests\\a.test.mjs', '**/*.test.mjs')).toBe(true); }); }); describe('pathNormalize', () => { it('expands ~ and resolves', () => { expect(pathNormalize('~/x', { homedir: '/h', realpath: (p) => p, resolve: (p) => p, platform: 'linux', env: {} })).toBe('/h/x'); }); it('collapses .. via path.resolve', () => { // Use path.posix.resolve to simulate POSIX behaviour cross-platform const result = pathNormalize('a/../b', { realpath: (p) => p, resolve: path.posix.resolve, platform: 'linux', homedir: '/h', env: {} }); expect(result.endsWith('/b')).toBe(true); }); it('falls back to resolved on ENOENT', () => { expect(pathNormalize('/missing', { realpath: () => { throw new Error('ENOENT'); }, resolve: (p) => p, platform: 'linux', homedir: '/h', env: {}, })).toBe('/missing'); }); it('case-folds on win32', () => { expect(pathNormalize('/A/B', { realpath: (p) => p, resolve: (p) => p, platform: 'win32', homedir: '/h', env: {} })).toBe('/a/b'); }); });