@garyr/pt-cli 0.30.1 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/dist/commands/learnCommand.js +66 -51
- package/doc/testing.md +48 -0
- package/package.json +2 -1
- package/src/commands/learnCommand.ts +65 -53
- package/tests/config-utils.test.ts +473 -0
- package/tests/config.test.ts +36 -6
- package/tests/init.test.ts +428 -14
- package/tests/learn.test.ts +838 -0
- package/tests/substitute.test.ts +479 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import { test, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
// Force a temporary home directory for testing BEFORE importing from the CLI
|
|
7
|
+
const testHome = path.join(process.cwd(), '.test-home-config-utils');
|
|
8
|
+
process.env.HOME = testHome;
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
ensureConfigDir,
|
|
12
|
+
getTemplateNames,
|
|
13
|
+
getDefaultPostConfig,
|
|
14
|
+
shouldExclude,
|
|
15
|
+
shouldIgnore,
|
|
16
|
+
shouldExcludeFile,
|
|
17
|
+
sanitizePath,
|
|
18
|
+
DEFAULT_EXCLUDES,
|
|
19
|
+
HOME_DIR,
|
|
20
|
+
PtConfig,
|
|
21
|
+
} from '../src/config.js';
|
|
22
|
+
|
|
23
|
+
// Clean up test home after all tests
|
|
24
|
+
after(() => {
|
|
25
|
+
if (fs.existsSync(testHome)) {
|
|
26
|
+
fs.rmSync(testHome, { recursive: true, force: true });
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// ─── ensureConfigDir ─────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
test('ensureConfigDir creates dir when it does not exist', () => {
|
|
33
|
+
// Make sure the dir does NOT exist before the test
|
|
34
|
+
if (fs.existsSync(HOME_DIR)) {
|
|
35
|
+
fs.rmSync(HOME_DIR, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
assert.ok(!fs.existsSync(HOME_DIR), 'Precondition: HOME_DIR should not exist');
|
|
38
|
+
|
|
39
|
+
ensureConfigDir();
|
|
40
|
+
|
|
41
|
+
assert.ok(fs.existsSync(HOME_DIR), 'HOME_DIR should be created');
|
|
42
|
+
assert.ok(fs.statSync(HOME_DIR).isDirectory(), 'HOME_DIR should be a directory');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('ensureConfigDir does nothing when dir already exists', () => {
|
|
46
|
+
// Ensure directory exists first
|
|
47
|
+
if (!fs.existsSync(HOME_DIR)) {
|
|
48
|
+
fs.mkdirSync(HOME_DIR, { recursive: true });
|
|
49
|
+
}
|
|
50
|
+
// Place a marker file inside to prove the dir is not recreated
|
|
51
|
+
const markerPath = path.join(HOME_DIR, '.marker');
|
|
52
|
+
fs.writeFileSync(markerPath, 'exists');
|
|
53
|
+
|
|
54
|
+
ensureConfigDir();
|
|
55
|
+
|
|
56
|
+
assert.ok(fs.existsSync(HOME_DIR), 'HOME_DIR should still exist');
|
|
57
|
+
assert.ok(fs.existsSync(markerPath), 'Marker file inside HOME_DIR should still exist');
|
|
58
|
+
|
|
59
|
+
// Clean up marker
|
|
60
|
+
fs.unlinkSync(markerPath);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ─── getTemplateNames ────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
test('getTemplateNames returns empty array for empty templates', () => {
|
|
66
|
+
const config: PtConfig = { version: '3.0', templates: {} };
|
|
67
|
+
const names = getTemplateNames(config);
|
|
68
|
+
assert.deepStrictEqual(names, []);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('getTemplateNames returns correct names', () => {
|
|
72
|
+
const config: PtConfig = {
|
|
73
|
+
version: '3.0',
|
|
74
|
+
templates: {
|
|
75
|
+
'node-api': { description: 'Node API', folders: [] },
|
|
76
|
+
'react-app': { description: 'React App', folders: [] },
|
|
77
|
+
'python-cli': { description: 'Python CLI', folders: [] },
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
const names = getTemplateNames(config);
|
|
81
|
+
assert.deepStrictEqual(names, ['node-api', 'react-app', 'python-cli']);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('getTemplateNames handles undefined templates', () => {
|
|
85
|
+
// Simulate a config with templates missing entirely
|
|
86
|
+
const config = { version: '3.0' } as PtConfig;
|
|
87
|
+
const names = getTemplateNames(config);
|
|
88
|
+
assert.deepStrictEqual(names, []);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ─── getDefaultPostConfig ────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
test('getDefaultPostConfig returns empty array when no default_post_config', () => {
|
|
94
|
+
const config: PtConfig = { version: '3.0', templates: {} };
|
|
95
|
+
const tasks = getDefaultPostConfig(config);
|
|
96
|
+
assert.deepStrictEqual(tasks, []);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('getDefaultPostConfig returns empty array when default_post_config is undefined', () => {
|
|
100
|
+
const config = { version: '3.0', templates: {} } as PtConfig;
|
|
101
|
+
delete (config as any).default_post_config;
|
|
102
|
+
const tasks = getDefaultPostConfig(config);
|
|
103
|
+
assert.deepStrictEqual(tasks, []);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('getDefaultPostConfig defaults checked to true', () => {
|
|
107
|
+
const config: PtConfig = {
|
|
108
|
+
version: '3.0',
|
|
109
|
+
templates: {},
|
|
110
|
+
default_post_config: [
|
|
111
|
+
{ description: 'Install deps', command: 'npm install' },
|
|
112
|
+
{ description: 'Run lint', command: 'npm run lint' },
|
|
113
|
+
],
|
|
114
|
+
};
|
|
115
|
+
const tasks = getDefaultPostConfig(config);
|
|
116
|
+
|
|
117
|
+
assert.strictEqual(tasks.length, 2);
|
|
118
|
+
assert.strictEqual(tasks[0].checked, true, 'First task should default checked to true');
|
|
119
|
+
assert.strictEqual(tasks[1].checked, true, 'Second task should default checked to true');
|
|
120
|
+
assert.strictEqual(tasks[0].description, 'Install deps');
|
|
121
|
+
assert.strictEqual(tasks[0].command, 'npm install');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('getDefaultPostConfig preserves checked=false', () => {
|
|
125
|
+
const config: PtConfig = {
|
|
126
|
+
version: '3.0',
|
|
127
|
+
templates: {},
|
|
128
|
+
default_post_config: [
|
|
129
|
+
{ description: 'Optional step', command: 'echo optional', checked: false },
|
|
130
|
+
{ description: 'Required step', command: 'echo required', checked: true },
|
|
131
|
+
{ description: 'Default step', command: 'echo default' },
|
|
132
|
+
],
|
|
133
|
+
};
|
|
134
|
+
const tasks = getDefaultPostConfig(config);
|
|
135
|
+
|
|
136
|
+
assert.strictEqual(tasks.length, 3);
|
|
137
|
+
assert.strictEqual(tasks[0].checked, false, 'Explicitly false should stay false');
|
|
138
|
+
assert.strictEqual(tasks[1].checked, true, 'Explicitly true should stay true');
|
|
139
|
+
assert.strictEqual(tasks[2].checked, true, 'Undefined checked should default to true');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ─── DEFAULT_EXCLUDES ────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
test('DEFAULT_EXCLUDES contains expected patterns', () => {
|
|
145
|
+
assert.ok(Array.isArray(DEFAULT_EXCLUDES), 'DEFAULT_EXCLUDES should be an array');
|
|
146
|
+
assert.ok(DEFAULT_EXCLUDES.includes('.git'), 'Should include .git');
|
|
147
|
+
assert.ok(DEFAULT_EXCLUDES.includes('node_modules'), 'Should include node_modules');
|
|
148
|
+
assert.ok(DEFAULT_EXCLUDES.includes('dist'), 'Should include dist');
|
|
149
|
+
assert.ok(DEFAULT_EXCLUDES.includes('build'), 'Should include build');
|
|
150
|
+
assert.ok(DEFAULT_EXCLUDES.includes('.DS_Store'), 'Should include .DS_Store');
|
|
151
|
+
assert.ok(DEFAULT_EXCLUDES.includes('.vscode'), 'Should include .vscode');
|
|
152
|
+
assert.ok(DEFAULT_EXCLUDES.includes('Thumbs.db'), 'Should include Thumbs.db');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ─── shouldExclude ───────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
test('shouldExclude excludes default patterns', () => {
|
|
158
|
+
const dirPath = '/project';
|
|
159
|
+
// Each default exclude should be matched by name
|
|
160
|
+
for (const name of DEFAULT_EXCLUDES) {
|
|
161
|
+
const fullPath = path.join(dirPath, name);
|
|
162
|
+
assert.ok(
|
|
163
|
+
shouldExclude(dirPath, fullPath),
|
|
164
|
+
`Should exclude "${name}" (default pattern)`
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('shouldExclude does NOT exclude normal directories', () => {
|
|
170
|
+
const dirPath = '/project';
|
|
171
|
+
const normalDirs = ['src', 'lib', 'tests', 'docs', 'scripts', 'assets', 'public'];
|
|
172
|
+
for (const name of normalDirs) {
|
|
173
|
+
const fullPath = path.join(dirPath, name);
|
|
174
|
+
assert.ok(
|
|
175
|
+
!shouldExclude(dirPath, fullPath),
|
|
176
|
+
`Should NOT exclude "${name}"`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('shouldExclude handles custom excludes', () => {
|
|
182
|
+
const dirPath = '/project';
|
|
183
|
+
const customExcludes = ['vendor', 'tmp', 'coverage'];
|
|
184
|
+
|
|
185
|
+
for (const name of customExcludes) {
|
|
186
|
+
const fullPath = path.join(dirPath, name);
|
|
187
|
+
assert.ok(
|
|
188
|
+
shouldExclude(dirPath, fullPath, customExcludes),
|
|
189
|
+
`Should exclude custom pattern "${name}"`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Normal dirs still pass
|
|
194
|
+
assert.ok(!shouldExclude(dirPath, path.join(dirPath, 'src'), customExcludes));
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('shouldExclude handles git submodules via .gitmodules detection', () => {
|
|
198
|
+
// Create a temporary project structure with a .gitmodules file
|
|
199
|
+
const tmpProject = path.join(testHome, '_test_shouldExclude_submodule');
|
|
200
|
+
const submoduleName = 'my-submodule';
|
|
201
|
+
const submodulePath = path.join(tmpProject, submoduleName);
|
|
202
|
+
fs.mkdirSync(submodulePath, { recursive: true });
|
|
203
|
+
|
|
204
|
+
// Write a .gitmodules file that references the submodule
|
|
205
|
+
const gitmodulesContent = `[submodule "${submoduleName}"]\n\tpath = ${submoduleName}\n\turl = https://example.com/repo.git\n`;
|
|
206
|
+
fs.writeFileSync(path.join(tmpProject, '.gitmodules'), gitmodulesContent);
|
|
207
|
+
|
|
208
|
+
// shouldExclude checks for .gitmodules in the parent of fullPath
|
|
209
|
+
const result = shouldExclude(tmpProject, submodulePath);
|
|
210
|
+
assert.ok(result, 'Should exclude a git submodule listed in .gitmodules');
|
|
211
|
+
|
|
212
|
+
// Non-submodule dir in the same project should NOT be excluded
|
|
213
|
+
const otherDir = path.join(tmpProject, 'regular-dir');
|
|
214
|
+
fs.mkdirSync(otherDir, { recursive: true });
|
|
215
|
+
const otherResult = shouldExclude(tmpProject, otherDir);
|
|
216
|
+
assert.ok(!otherResult, 'Should NOT exclude a non-submodule directory');
|
|
217
|
+
|
|
218
|
+
// Clean up
|
|
219
|
+
fs.rmSync(tmpProject, { recursive: true, force: true });
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('shouldExclude with empty custom excludes behaves like default only', () => {
|
|
223
|
+
const dirPath = '/project';
|
|
224
|
+
assert.ok(shouldExclude(dirPath, path.join(dirPath, 'node_modules'), []));
|
|
225
|
+
assert.ok(!shouldExclude(dirPath, path.join(dirPath, 'src'), []));
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// ─── shouldIgnore ────────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
test('shouldIgnore returns false when no patterns provided', () => {
|
|
231
|
+
assert.strictEqual(shouldIgnore('src', 'src'), false);
|
|
232
|
+
assert.strictEqual(shouldIgnore('src', 'src', []), false);
|
|
233
|
+
assert.strictEqual(shouldIgnore('src', 'src', undefined), false);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('shouldIgnore deep match with **/FOLDER', () => {
|
|
237
|
+
assert.ok(
|
|
238
|
+
shouldIgnore('logs', 'app/data/logs', ['**/logs']),
|
|
239
|
+
'Should match "logs" at any depth via **/logs'
|
|
240
|
+
);
|
|
241
|
+
assert.ok(
|
|
242
|
+
shouldIgnore('logs', 'logs', ['**/logs']),
|
|
243
|
+
'Should match "logs" at root via **/logs'
|
|
244
|
+
);
|
|
245
|
+
assert.ok(
|
|
246
|
+
!shouldIgnore('src', 'app/src', ['**/logs']),
|
|
247
|
+
'Should not match folder named "src" when pattern is **/logs'
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('shouldIgnore deep match with **/FOLDER/ trailing slash', () => {
|
|
252
|
+
assert.ok(
|
|
253
|
+
shouldIgnore('cache', 'deep/nested/cache', ['**/cache/']),
|
|
254
|
+
'Should match with trailing slash in pattern'
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('shouldIgnore FOLDER/* wildcard children match', () => {
|
|
259
|
+
// FOLDER/* should match children of FOLDER, not FOLDER itself
|
|
260
|
+
assert.ok(
|
|
261
|
+
shouldIgnore('child', 'DAILIES/child', ['DAILIES/*']),
|
|
262
|
+
'Should match child inside DAILIES with DAILIES/*'
|
|
263
|
+
);
|
|
264
|
+
assert.ok(
|
|
265
|
+
!shouldIgnore('DAILIES', 'DAILIES', ['DAILIES/*']),
|
|
266
|
+
'Should NOT match DAILIES itself with DAILIES/*'
|
|
267
|
+
);
|
|
268
|
+
assert.ok(
|
|
269
|
+
shouldIgnore('deep', 'DAILIES/deep', ['DAILIES/*']),
|
|
270
|
+
'Should match any immediate child of DAILIES'
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('shouldIgnore FOLDER/** deep wildcard match', () => {
|
|
275
|
+
assert.ok(
|
|
276
|
+
shouldIgnore('nested', 'DAILIES/nested', ['DAILIES/**']),
|
|
277
|
+
'Should match child inside DAILIES with DAILIES/**'
|
|
278
|
+
);
|
|
279
|
+
assert.ok(
|
|
280
|
+
!shouldIgnore('DAILIES', 'DAILIES', ['DAILIES/**']),
|
|
281
|
+
'Should NOT match DAILIES itself with DAILIES/**'
|
|
282
|
+
);
|
|
283
|
+
assert.ok(
|
|
284
|
+
shouldIgnore('deep', 'DAILIES/deep', ['DAILIES/**']),
|
|
285
|
+
'Should match deep nested child of DAILIES'
|
|
286
|
+
);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('shouldIgnore exact match by name', () => {
|
|
290
|
+
assert.ok(
|
|
291
|
+
shouldIgnore('temp', 'temp', ['temp']),
|
|
292
|
+
'Should match exact folder name'
|
|
293
|
+
);
|
|
294
|
+
assert.ok(
|
|
295
|
+
!shouldIgnore('temporary', 'temporary', ['temp']),
|
|
296
|
+
'Should NOT match partial folder name'
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test('shouldIgnore exact match by path', () => {
|
|
301
|
+
assert.ok(
|
|
302
|
+
shouldIgnore('logs', 'data/logs', ['data/logs']),
|
|
303
|
+
'Should match exact relative path'
|
|
304
|
+
);
|
|
305
|
+
assert.ok(
|
|
306
|
+
!shouldIgnore('logs', 'other/logs', ['data/logs']),
|
|
307
|
+
'Should NOT match different path with same name'
|
|
308
|
+
);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('shouldIgnore trailing slash handling on exact match', () => {
|
|
312
|
+
assert.ok(
|
|
313
|
+
shouldIgnore('temp', 'temp', ['temp/']),
|
|
314
|
+
'Should match exact name even with trailing slash in pattern'
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test('shouldIgnore returns false for non-matching patterns', () => {
|
|
319
|
+
const patterns = ['**/cache', 'vendor/*', 'tmp'];
|
|
320
|
+
assert.strictEqual(shouldIgnore('src', 'src', patterns), false);
|
|
321
|
+
assert.strictEqual(shouldIgnore('lib', 'project/lib', patterns), false);
|
|
322
|
+
assert.strictEqual(shouldIgnore('app', 'app', patterns), false);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('shouldIgnore handles multiple patterns', () => {
|
|
326
|
+
const patterns = ['**/logs', 'tmp', 'data/*'];
|
|
327
|
+
|
|
328
|
+
assert.ok(shouldIgnore('logs', 'deep/logs', patterns), 'Should match **/logs');
|
|
329
|
+
assert.ok(shouldIgnore('tmp', 'tmp', patterns), 'Should match tmp');
|
|
330
|
+
assert.ok(shouldIgnore('file', 'data/file', patterns), 'Should match data/*');
|
|
331
|
+
assert.ok(!shouldIgnore('src', 'src', patterns), 'Should not match src');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// ─── shouldExcludeFile ───────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
test('shouldExcludeFile excludes wildcard extension patterns', () => {
|
|
337
|
+
const wildcardExcludes = [
|
|
338
|
+
'module.pyc',
|
|
339
|
+
'cache.pyo',
|
|
340
|
+
'native.pyd',
|
|
341
|
+
'info.egg-info',
|
|
342
|
+
'dist.egg',
|
|
343
|
+
'dep.whl',
|
|
344
|
+
'lib.so',
|
|
345
|
+
'lib.dll',
|
|
346
|
+
'lib.dylib',
|
|
347
|
+
'app.exe',
|
|
348
|
+
'main.o',
|
|
349
|
+
'archive.a',
|
|
350
|
+
'static.lib',
|
|
351
|
+
'Main.class',
|
|
352
|
+
'app.jar',
|
|
353
|
+
'app.war',
|
|
354
|
+
'app.ear',
|
|
355
|
+
'server.log',
|
|
356
|
+
'data.tmp',
|
|
357
|
+
'file.swp',
|
|
358
|
+
'file.swo',
|
|
359
|
+
'backup~',
|
|
360
|
+
'readme.md',
|
|
361
|
+
'notes.txt',
|
|
362
|
+
'data.json',
|
|
363
|
+
'config.yaml',
|
|
364
|
+
'settings.yml',
|
|
365
|
+
'setup.ini',
|
|
366
|
+
'app.conf',
|
|
367
|
+
'lint.config',
|
|
368
|
+
];
|
|
369
|
+
|
|
370
|
+
for (const file of wildcardExcludes) {
|
|
371
|
+
assert.ok(
|
|
372
|
+
shouldExcludeFile(file),
|
|
373
|
+
`Should exclude "${file}"`
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test('shouldExcludeFile excludes exact match files', () => {
|
|
379
|
+
const exactExcludes = [
|
|
380
|
+
'.Python',
|
|
381
|
+
'.bak',
|
|
382
|
+
'.gitconfig',
|
|
383
|
+
'.makerc',
|
|
384
|
+
'Gemfile.lock',
|
|
385
|
+
'package.json',
|
|
386
|
+
'package-lock.json',
|
|
387
|
+
'yarn.lock',
|
|
388
|
+
'pnpm-lock.yaml',
|
|
389
|
+
'composer.json',
|
|
390
|
+
'composer.lock',
|
|
391
|
+
];
|
|
392
|
+
|
|
393
|
+
for (const file of exactExcludes) {
|
|
394
|
+
assert.ok(
|
|
395
|
+
shouldExcludeFile(file),
|
|
396
|
+
`Should exclude exact match "${file}"`
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test('shouldExcludeFile does NOT exclude normal source files', () => {
|
|
402
|
+
const normalFiles = [
|
|
403
|
+
'index.js',
|
|
404
|
+
'app.ts',
|
|
405
|
+
'styles.css',
|
|
406
|
+
'template.html',
|
|
407
|
+
'image.png',
|
|
408
|
+
'photo.jpg',
|
|
409
|
+
'Dockerfile',
|
|
410
|
+
'Makefile',
|
|
411
|
+
'script.sh',
|
|
412
|
+
'main.go',
|
|
413
|
+
'lib.rs',
|
|
414
|
+
'App.vue',
|
|
415
|
+
'Component.jsx',
|
|
416
|
+
'handler.py',
|
|
417
|
+
'server.rb',
|
|
418
|
+
];
|
|
419
|
+
|
|
420
|
+
for (const file of normalFiles) {
|
|
421
|
+
assert.ok(
|
|
422
|
+
!shouldExcludeFile(file),
|
|
423
|
+
`Should NOT exclude "${file}"`
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
// ─── sanitizePath ────────────────────────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
test('sanitizePath removes .. segments', () => {
|
|
431
|
+
const result = sanitizePath('foo/../bar');
|
|
432
|
+
assert.strictEqual(result, ['foo', 'bar'].join(path.sep));
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test('sanitizePath removes . segments', () => {
|
|
436
|
+
const result = sanitizePath('foo/./bar');
|
|
437
|
+
assert.strictEqual(result, ['foo', 'bar'].join(path.sep));
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test('sanitizePath removes empty segments', () => {
|
|
441
|
+
const result = sanitizePath('foo//bar///baz');
|
|
442
|
+
assert.strictEqual(result, ['foo', 'bar', 'baz'].join(path.sep));
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test('sanitizePath handles mixed separators', () => {
|
|
446
|
+
const result = sanitizePath('foo\\..\\bar/./baz');
|
|
447
|
+
assert.strictEqual(result, ['foo', 'bar', 'baz'].join(path.sep));
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test('sanitizePath normal path passes through', () => {
|
|
451
|
+
const result = sanitizePath('src/components/App');
|
|
452
|
+
assert.strictEqual(result, ['src', 'components', 'App'].join(path.sep));
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test('sanitizePath strips leading traversal attempts', () => {
|
|
456
|
+
const result = sanitizePath('../../etc/passwd');
|
|
457
|
+
assert.strictEqual(result, ['etc', 'passwd'].join(path.sep));
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test('sanitizePath handles path with only dots and slashes', () => {
|
|
461
|
+
const result = sanitizePath('../../../..');
|
|
462
|
+
assert.strictEqual(result, '');
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test('sanitizePath trims whitespace from segments', () => {
|
|
466
|
+
const result = sanitizePath(' foo / bar / baz ');
|
|
467
|
+
assert.strictEqual(result, ['foo', 'bar', 'baz'].join(path.sep));
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test('sanitizePath returns empty string for empty input', () => {
|
|
471
|
+
const result = sanitizePath('');
|
|
472
|
+
assert.strictEqual(result, '');
|
|
473
|
+
});
|
package/tests/config.test.ts
CHANGED
|
@@ -2,12 +2,16 @@ import { test, before, after } from 'node:test';
|
|
|
2
2
|
import assert from 'node:assert';
|
|
3
3
|
import fs from 'fs';
|
|
4
4
|
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
5
6
|
|
|
6
7
|
// Force a temporary home directory for testing before importing anything from the CLI
|
|
7
8
|
const testHome = path.join(process.cwd(), '.test-home-config');
|
|
8
|
-
process.env.HOME = testHome;
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// Mock os.homedir() to return the test directory (Node.js doesn't use HOME env var on Linux)
|
|
11
|
+
const originalHomedir = os.homedir;
|
|
12
|
+
os.homedir = () => testHome;
|
|
13
|
+
|
|
14
|
+
import { normalizeVariable, saveConfig, loadConfig, PtConfig, CONFIG_PATH, HOME_DIR } from '../src/config.js';
|
|
11
15
|
|
|
12
16
|
test('normalizeVariable key ordering', () => {
|
|
13
17
|
const variableInput = {
|
|
@@ -368,10 +372,26 @@ test('saveConfig with empty config file', () => {
|
|
|
368
372
|
// Create empty config file
|
|
369
373
|
fs.writeFileSync(CONFIG_PATH, '');
|
|
370
374
|
|
|
371
|
-
//
|
|
372
|
-
|
|
375
|
+
// loadConfig calls process.exit(1) on error, so we need to mock it
|
|
376
|
+
// to prevent the test runner from dying
|
|
377
|
+
let exitCalled = false;
|
|
378
|
+
let exitCode: number | undefined;
|
|
379
|
+
const originalExit = process.exit;
|
|
380
|
+
process.exit = ((code?: number) => {
|
|
381
|
+
exitCalled = true;
|
|
382
|
+
exitCode = code;
|
|
383
|
+
throw new Error('process.exit called');
|
|
384
|
+
}) as any;
|
|
385
|
+
|
|
386
|
+
try {
|
|
373
387
|
loadConfig();
|
|
374
|
-
|
|
388
|
+
assert.fail('loadConfig should have called process.exit');
|
|
389
|
+
} catch (e) {
|
|
390
|
+
assert.ok(exitCalled, 'process.exit should have been called');
|
|
391
|
+
assert.strictEqual(exitCode, 1, 'Should exit with code 1');
|
|
392
|
+
} finally {
|
|
393
|
+
process.exit = originalExit;
|
|
394
|
+
}
|
|
375
395
|
|
|
376
396
|
// Clean up
|
|
377
397
|
if (fs.existsSync(CONFIG_PATH)) {
|
|
@@ -551,7 +571,6 @@ test('loadConfig handles migration from v2.0', () => {
|
|
|
551
571
|
templates: {
|
|
552
572
|
'test-template': {
|
|
553
573
|
name: 'Test Template',
|
|
554
|
-
description: 'Test Description',
|
|
555
574
|
type: 'web',
|
|
556
575
|
folders: []
|
|
557
576
|
}
|
|
@@ -745,4 +764,15 @@ test('saveConfig preserves existing config when not deleting', () => {
|
|
|
745
764
|
if (savedConfig) {
|
|
746
765
|
saveConfig(savedConfig);
|
|
747
766
|
}
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
// Cleanup: restore original os.homedir function and clean up test directory
|
|
770
|
+
after(() => {
|
|
771
|
+
// Restore original os.homedir function
|
|
772
|
+
os.homedir = originalHomedir;
|
|
773
|
+
|
|
774
|
+
// Clean up test home directory if it exists
|
|
775
|
+
if (fs.existsSync(testHome)) {
|
|
776
|
+
fs.rmdirSync(testHome, { recursive: true });
|
|
777
|
+
}
|
|
748
778
|
});
|