Skip to content

Commit 38d4aea

Browse files
authored
Merge pull request #188 from HubSpot/fix/migration-bugs
Fix: Add function to interpret tilde in file paths
2 parents 2e46b9f + 77b753a commit 38d4aea

File tree

2 files changed

+248
-133
lines changed

2 files changed

+248
-133
lines changed

lib/__tests__/path.test.ts

Lines changed: 235 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -1,139 +1,241 @@
1-
import path, { PlatformPath } from 'path';
2-
import { splitHubSpotPath, splitLocalPath } from '../path';
1+
import os from 'os';
2+
import {
3+
convertToUnixPath,
4+
splitLocalPath,
5+
splitHubSpotPath,
6+
getCwd,
7+
getExt,
8+
getAllowedExtensions,
9+
isAllowedExtension,
10+
sanitizeFileName,
11+
isValidPath,
12+
untildify,
13+
} from '../path';
14+
import { ALLOWED_EXTENSIONS } from '../../constants/extensions';
315

4-
describe('lib/path', () => {
5-
describe('splitHubSpotPath()', () => {
6-
const testSplit = (
7-
filepath: string,
8-
expectedParts: Array<string>,
9-
joined: string
10-
) => {
11-
test(filepath, () => {
12-
const parts = splitHubSpotPath(filepath);
13-
expect(parts).toEqual(expectedParts);
14-
expect(path.posix.join(...parts)).toBe(joined);
15-
});
16-
};
17-
testSplit('', [], '.');
18-
testSplit('a', ['a'], 'a');
19-
testSplit('a/b', ['a', 'b'], 'a/b');
20-
testSplit('a/b/', ['a', 'b'], 'a/b');
21-
testSplit('a/b///', ['a', 'b'], 'a/b');
22-
testSplit('/', ['/'], '/');
23-
testSplit('///', ['/'], '/');
24-
testSplit('/a', ['/', 'a'], '/a');
25-
testSplit('///a', ['/', 'a'], '/a');
26-
testSplit('/a/b', ['/', 'a', 'b'], '/a/b');
27-
testSplit('a.js', ['a.js'], 'a.js');
28-
testSplit('/a.js/', ['/', 'a.js'], '/a.js');
29-
testSplit('/x/a.js/', ['/', 'x', 'a.js'], '/x/a.js');
30-
testSplit('///x/////a.js///', ['/', 'x', 'a.js'], '/x/a.js');
31-
testSplit(
32-
'/project/My Module.module',
33-
['/', 'project', 'My Module.module'],
34-
'/project/My Module.module'
35-
);
36-
testSplit(
37-
'project/My Module.module/js',
38-
['project', 'My Module.module', 'js'],
39-
'project/My Module.module/js'
40-
);
41-
testSplit(
42-
'project/My Module.module/js/main.js/',
43-
['project', 'My Module.module', 'js', 'main.js'],
44-
'project/My Module.module/js/main.js'
45-
);
46-
testSplit(
47-
'project/My Module.module/js/../css/',
48-
['project', 'My Module.module', 'css'],
49-
'project/My Module.module/css'
50-
);
51-
testSplit(
52-
'./project/My Module.module/js',
53-
['project', 'My Module.module', 'js'],
54-
'project/My Module.module/js'
55-
);
56-
testSplit(
57-
'../project/My Module.module/js',
58-
['..', 'project', 'My Module.module', 'js'],
59-
'../project/My Module.module/js'
60-
);
16+
jest.mock('os', () => ({
17+
homedir: jest.fn(),
18+
}));
19+
20+
jest.mock('path', () => ({
21+
...jest.requireActual('path'),
22+
sep: '/',
23+
posix: {
24+
sep: '/',
25+
},
26+
win32: {
27+
sep: '\\',
28+
},
29+
}));
30+
31+
describe('path utility functions', () => {
32+
describe('convertToUnixPath()', () => {
33+
test('converts Windows path to Unix path', () => {
34+
expect(convertToUnixPath('C:\\Users\\test\\file.txt')).toBe(
35+
'/Users/test/file.txt'
36+
);
37+
});
38+
39+
test('normalizes Unix path', () => {
40+
expect(convertToUnixPath('/home//user/./file.txt')).toBe(
41+
'/home/user/file.txt'
42+
);
43+
});
6144
});
45+
46+
describe('convertToLocalFileSystemPath()', () => {
47+
afterEach(() => {
48+
jest.resetModules();
49+
});
50+
51+
test('converts to Unix path when on Unix-like system', async () => {
52+
jest.doMock('path', () => ({ ...jest.requireActual('path'), sep: '/' }));
53+
const { convertToLocalFileSystemPath } = await import('../path');
54+
expect(convertToLocalFileSystemPath('/home/user/file.txt')).toBe(
55+
'/home/user/file.txt'
56+
);
57+
});
58+
59+
test('converts to Windows path when on Windows system', async () => {
60+
jest.doMock('path', () => ({ ...jest.requireActual('path'), sep: '\\' }));
61+
const { convertToLocalFileSystemPath } = await import('../path');
62+
expect(convertToLocalFileSystemPath('C:/Users/test/file.txt')).toBe(
63+
'C:\\Users\\test\\file.txt'
64+
);
65+
});
66+
});
67+
6268
describe('splitLocalPath()', () => {
63-
function createTestSplit(
64-
pathImplementation: PlatformPath
65-
): (
66-
filepath: string,
67-
expectedParts: Array<string>,
68-
joined: string
69-
) => void {
70-
return (
71-
filepath: string,
72-
expectedParts: Array<string>,
73-
joined: string
74-
) => {
75-
test(filepath, () => {
76-
const parts = splitLocalPath(filepath, pathImplementation);
77-
expect(parts).toEqual(expectedParts);
78-
expect(pathImplementation.join(...parts)).toBe(joined);
79-
});
80-
};
81-
}
82-
83-
type TestCases = Array<[string, Array<string>, string]>;
84-
85-
function getLocalFileSystemTestCases(
86-
pathImplementation: PlatformPath
87-
): TestCases {
88-
const { sep } = pathImplementation;
89-
const isWin32 = sep === path.win32.sep;
90-
const splitRoot = isWin32 ? 'C:' : '/';
91-
const pathRoot = isWin32 ? 'C:\\' : '/';
92-
return [
93-
[
94-
`${pathRoot}My Module.module`,
95-
[splitRoot, 'My Module.module'],
96-
`${pathRoot}My Module.module`,
97-
],
98-
[
99-
`${pathRoot}My Module.module${sep}`,
100-
[splitRoot, 'My Module.module'],
101-
`${pathRoot}My Module.module`,
102-
],
103-
[`My Module.module${sep}`, ['My Module.module'], 'My Module.module'],
104-
[
105-
`${pathRoot}My Module.module${sep}js${sep}main.js`,
106-
[splitRoot, 'My Module.module', 'js', 'main.js'],
107-
`${pathRoot}My Module.module${sep}js${sep}main.js`,
108-
],
109-
[
110-
`${pathRoot}My Module.module${sep}${sep}${sep}js${sep}main.js${sep}${sep}`,
111-
[splitRoot, 'My Module.module', 'js', 'main.js'],
112-
`${pathRoot}My Module.module${sep}js${sep}main.js`,
113-
],
114-
[
115-
`${pathRoot}My Module.module${sep}js${sep}..${sep}css`,
116-
[splitRoot, 'My Module.module', 'css'],
117-
`${pathRoot}My Module.module${sep}css`,
118-
],
119-
[
120-
`My Module.module${sep}js${sep}..${sep}css`,
121-
['My Module.module', 'css'],
122-
`My Module.module${sep}css`,
123-
],
124-
];
125-
}
126-
const platforms = ['posix', 'win32'] as const;
127-
platforms.forEach(platform => {
128-
describe(platform, () => {
129-
const pathImplementation = path[platform];
130-
const testSplit = createTestSplit(pathImplementation);
131-
const testCases = getLocalFileSystemTestCases(pathImplementation);
132-
if (!(testCases && testCases.length)) {
133-
throw new Error(`Missing ${platform} splitLocalPath() test cases`);
134-
}
135-
testCases.forEach(testCase => testSplit(...testCase));
136-
});
69+
test('splits Unix path correctly', () => {
70+
expect(
71+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
72+
// @ts-ignore
73+
splitLocalPath('/home/user/file.txt', {
74+
sep: '/',
75+
normalize: (p: string) => p,
76+
})
77+
).toEqual(['/', 'home', 'user', 'file.txt']);
78+
});
79+
80+
test('splits Windows path correctly', () => {
81+
expect(
82+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
83+
// @ts-ignore
84+
splitLocalPath('C:\\Users\\test\\file.txt', {
85+
sep: '\\',
86+
normalize: (p: string) => p,
87+
})
88+
).toEqual(['C:', 'Users', 'test', 'file.txt']);
89+
});
90+
91+
test('handles empty path', () => {
92+
expect(splitLocalPath('')).toEqual([]);
93+
});
94+
});
95+
96+
describe('splitHubSpotPath()', () => {
97+
test('splits HubSpot path correctly', () => {
98+
expect(splitHubSpotPath('/project/My Module.module/js/main.js')).toEqual([
99+
'/',
100+
'project',
101+
'My Module.module',
102+
'js',
103+
'main.js',
104+
]);
105+
});
106+
107+
test('handles root path', () => {
108+
expect(splitHubSpotPath('/')).toEqual(['/']);
109+
});
110+
111+
test('handles empty path', () => {
112+
expect(splitHubSpotPath('')).toEqual([]);
113+
});
114+
});
115+
116+
describe('getCwd()', () => {
117+
const originalEnv = process.env;
118+
const originalCwd = process.cwd;
119+
120+
beforeEach(() => {
121+
process.env = { ...originalEnv };
122+
process.cwd = jest.fn().mockReturnValue('/mocked/cwd');
123+
});
124+
125+
afterEach(() => {
126+
process.env = originalEnv;
127+
process.cwd = originalCwd;
128+
});
129+
130+
test('returns INIT_CWD if set', () => {
131+
process.env.INIT_CWD = '/custom/init/cwd';
132+
expect(getCwd()).toBe('/custom/init/cwd');
133+
});
134+
135+
test('returns process.cwd() if INIT_CWD not set', () => {
136+
delete process.env.INIT_CWD;
137+
expect(getCwd()).toBe('/mocked/cwd');
138+
});
139+
});
140+
141+
describe('getExt()', () => {
142+
test('returns lowercase extension without dot', () => {
143+
expect(getExt('file.TXT')).toBe('txt');
144+
});
145+
146+
test('returns empty string for no extension', () => {
147+
expect(getExt('file')).toBe('');
148+
});
149+
150+
test('handles non-string input', () => {
151+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
152+
// @ts-ignore
153+
expect(getExt(null as '')).toBe('');
154+
});
155+
});
156+
157+
describe('getAllowedExtensions()', () => {
158+
test('returns default allowed extensions', () => {
159+
const result = getAllowedExtensions();
160+
expect(result).toBeInstanceOf(Set);
161+
expect(result).toEqual(new Set(ALLOWED_EXTENSIONS));
162+
});
163+
164+
test('includes additional extensions', () => {
165+
const result = getAllowedExtensions(['custom']);
166+
expect(result.has('custom')).toBe(true);
167+
});
168+
});
169+
170+
describe('isAllowedExtension()', () => {
171+
test('returns true for allowed extension', () => {
172+
expect(isAllowedExtension('file.txt')).toBe(true);
173+
});
174+
175+
test('returns false for disallowed extension', () => {
176+
expect(isAllowedExtension('file.exe')).toBe(false);
177+
});
178+
179+
test('allows custom extensions', () => {
180+
expect(isAllowedExtension('file.custom', ['custom'])).toBe(true);
181+
});
182+
});
183+
184+
describe('sanitizeFileName()', () => {
185+
test('replaces invalid characters', () => {
186+
expect(sanitizeFileName('file:name?.txt')).toBe('file-name-.txt');
187+
});
188+
189+
test('handles reserved names', () => {
190+
expect(sanitizeFileName('CON')).toBe('-CON');
191+
});
192+
193+
test('removes trailing periods and spaces', () => {
194+
expect(sanitizeFileName('file.txt. ')).toBe('file.txt');
195+
});
196+
});
197+
198+
describe('isValidPath()', () => {
199+
test('returns true for valid path', () => {
200+
expect(isValidPath('/valid/path/file.txt')).toBe(true);
201+
});
202+
203+
test('returns false for path with invalid characters', () => {
204+
expect(isValidPath('/invalid/path/file?.txt')).toBe(false);
205+
});
206+
207+
test('returns false for reserved names', () => {
208+
expect(isValidPath('/some/path/CON')).toBe(false);
209+
});
210+
});
211+
212+
describe('untildify()', () => {
213+
const originalHomedir = os.homedir;
214+
215+
beforeEach(() => {
216+
(os.homedir as jest.Mock) = jest.fn().mockReturnValue('/home/user');
217+
});
218+
219+
afterEach(() => {
220+
os.homedir = originalHomedir;
221+
});
222+
223+
test('replaces tilde with home directory', () => {
224+
expect(untildify('~/documents/file.txt')).toBe(
225+
'/home/user/documents/file.txt'
226+
);
227+
});
228+
229+
test('does not modify paths without tilde', () => {
230+
expect(untildify('/absolute/path/file.txt')).toBe(
231+
'/absolute/path/file.txt'
232+
);
233+
});
234+
235+
test('throws TypeError for non-string input', () => {
236+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
237+
// @ts-ignore
238+
expect(() => untildify(null as '')).toThrow(TypeError);
137239
});
138240
});
139241
});

lib/path.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import path from 'path';
22
import unixify from 'unixify';
33
import { ALLOWED_EXTENSIONS } from '../constants/extensions';
4+
import os from 'os';
45

56
export function convertToUnixPath(_path: string): string {
67
return unixify(path.normalize(_path));
@@ -129,3 +130,15 @@ export function isValidPath(_path: string): boolean {
129130

130131
return true;
131132
}
133+
134+
// Based on the untildify package: https://github.com/sindresorhus/untildify/blob/main/index.js
135+
export function untildify(pathWithTilde: string): string {
136+
const homeDirectory = os.homedir();
137+
if (typeof pathWithTilde !== 'string') {
138+
throw new TypeError(`Expected a string, got ${typeof pathWithTilde}`);
139+
}
140+
141+
return homeDirectory
142+
? pathWithTilde.replace(/^~(?=$|\/|\\)/, homeDirectory)
143+
: pathWithTilde;
144+
}

0 commit comments

Comments
 (0)