@crashbytes/dendro 1.1.0 → 1.1.1

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.
@@ -0,0 +1,39 @@
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [ main ]
6
+ pull_request:
7
+ branches: [ main ]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ node-version: [20.x, 22.x]
15
+
16
+ steps:
17
+ - name: Checkout code
18
+ uses: actions/checkout@v4
19
+
20
+ - name: Setup Node.js ${{ matrix.node-version }}
21
+ uses: actions/setup-node@v4
22
+ with:
23
+ node-version: ${{ matrix.node-version }}
24
+ cache: 'npm'
25
+
26
+ - name: Install dependencies
27
+ run: npm ci
28
+
29
+ - name: Run tests with coverage
30
+ run: npm run test:coverage
31
+
32
+ - name: Upload coverage to Codecov
33
+ if: matrix.node-version == '20.x'
34
+ uses: codecov/codecov-action@v4
35
+ with:
36
+ token: ${{ secrets.CODECOV_TOKEN }}
37
+ files: ./coverage/lcov.info
38
+ flags: unittests
39
+ fail_ci_if_error: false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crashbytes/dendro",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "A beautiful directory tree visualization CLI with file type icons - dendro (δένδρο) means 'tree' in Greek",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -8,7 +8,10 @@
8
8
  "dendro": "bin/cli.js"
9
9
  },
10
10
  "scripts": {
11
- "test": "node test.js"
11
+ "test": "vitest run",
12
+ "test:watch": "vitest",
13
+ "test:coverage": "vitest run --coverage",
14
+ "test:ui": "vitest --ui"
12
15
  },
13
16
  "keywords": [
14
17
  "directory",
@@ -29,10 +32,15 @@
29
32
  },
30
33
  "homepage": "https://github.com/CrashBytes/dendro#readme",
31
34
  "dependencies": {
32
- "commander": "^14.0.2",
33
- "chalk": "^5.6.2"
35
+ "chalk": "^5.6.2",
36
+ "commander": "^14.0.3"
34
37
  },
35
38
  "engines": {
36
39
  "node": ">=20.0.0"
40
+ },
41
+ "devDependencies": {
42
+ "@vitest/coverage-v8": "^4.0.18",
43
+ "@vitest/ui": "^4.0.18",
44
+ "vitest": "^4.0.18"
37
45
  }
38
46
  }
@@ -0,0 +1,387 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { buildTree, renderTree, getTreeStats, getIcon, icons } from '../index.js';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import os from 'os';
6
+
7
+ describe('getIcon', () => {
8
+ it('should return directory icon for directories', () => {
9
+ expect(getIcon('my-folder', true)).toBe(icons.directory);
10
+ expect(getIcon('src', true)).toBe(icons.directory);
11
+ });
12
+
13
+ it('should return correct icons for JavaScript files', () => {
14
+ expect(getIcon('app.js', false)).toBe(icons.javascript);
15
+ expect(getIcon('component.jsx', false)).toBe(icons.javascript);
16
+ expect(getIcon('module.mjs', false)).toBe(icons.javascript);
17
+ expect(getIcon('common.cjs', false)).toBe(icons.javascript);
18
+ });
19
+
20
+ it('should return correct icons for TypeScript files', () => {
21
+ expect(getIcon('app.ts', false)).toBe(icons.typescript);
22
+ expect(getIcon('component.tsx', false)).toBe(icons.typescript);
23
+ });
24
+
25
+ it('should return correct icons for config files', () => {
26
+ expect(getIcon('config.yaml', false)).toBe(icons.config);
27
+ expect(getIcon('config.yml', false)).toBe(icons.config);
28
+ expect(getIcon('config.toml', false)).toBe(icons.config);
29
+ });
30
+
31
+
32
+ it('should return correct icons for markdown files', () => {
33
+ expect(getIcon('README.md', false)).toBe(icons.markdown);
34
+ expect(getIcon('CHANGELOG.mdx', false)).toBe(icons.markdown);
35
+ });
36
+
37
+ it('should return correct icons for data files', () => {
38
+ expect(getIcon('data.json', false)).toBe(icons.json);
39
+ expect(getIcon('package.json', false)).toBe(icons.json);
40
+ });
41
+
42
+ it('should return correct icons for image files', () => {
43
+ expect(getIcon('photo.png', false)).toBe(icons.image);
44
+ expect(getIcon('logo.svg', false)).toBe(icons.image);
45
+ expect(getIcon('image.jpg', false)).toBe(icons.image);
46
+ expect(getIcon('pic.jpeg', false)).toBe(icons.image);
47
+ });
48
+
49
+ it('should return correct icons for lock files', () => {
50
+ expect(getIcon('package-lock.json', false)).toBe(icons.lock);
51
+ expect(getIcon('yarn.lock', false)).toBe(icons.lock);
52
+ expect(getIcon('pnpm-lock.yaml', false)).toBe(icons.lock);
53
+ });
54
+
55
+ it('should return correct icons for git files', () => {
56
+ expect(getIcon('.gitignore', false)).toBe(icons.git);
57
+ expect(getIcon('.gitattributes', false)).toBe(icons.git);
58
+ });
59
+
60
+ it('should return default icon for unknown file types', () => {
61
+ expect(getIcon('unknown.xyz', false)).toBe(icons.default);
62
+ });
63
+
64
+ it('should be case-insensitive for extensions', () => {
65
+ expect(getIcon('FILE.JS', false)).toBe(icons.javascript);
66
+ expect(getIcon('FILE.PNG', false)).toBe(icons.image);
67
+ });
68
+ });
69
+
70
+
71
+ describe('buildTree', () => {
72
+ let tempDir;
73
+
74
+ beforeEach(() => {
75
+ // Create a temporary test directory structure
76
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dendro-test-'));
77
+
78
+ // Create test structure
79
+ fs.mkdirSync(path.join(tempDir, 'src'));
80
+ fs.mkdirSync(path.join(tempDir, 'test'));
81
+ fs.mkdirSync(path.join(tempDir, '.git'));
82
+ fs.writeFileSync(path.join(tempDir, 'README.md'), '# Test');
83
+ fs.writeFileSync(path.join(tempDir, 'package.json'), '{}');
84
+ fs.writeFileSync(path.join(tempDir, 'src', 'index.js'), 'console.log("test")');
85
+ fs.writeFileSync(path.join(tempDir, 'test', 'test.js'), 'test');
86
+ fs.writeFileSync(path.join(tempDir, '.git', 'config'), '');
87
+ });
88
+
89
+ afterEach(() => {
90
+ // Clean up temp directory
91
+ fs.rmSync(tempDir, { recursive: true, force: true });
92
+ });
93
+
94
+ it('should build a tree structure', () => {
95
+ const tree = buildTree(tempDir);
96
+
97
+ expect(tree).toBeDefined();
98
+ expect(tree.type).toBe('directory');
99
+ expect(tree.name).toBe(path.basename(tempDir));
100
+ expect(tree.children).toBeDefined();
101
+ expect(Array.isArray(tree.children)).toBe(true);
102
+ });
103
+
104
+ it('should include files and directories', () => {
105
+ const tree = buildTree(tempDir);
106
+
107
+ expect(tree.children.length).toBeGreaterThan(0);
108
+
109
+ const hasFiles = tree.children.some(child => child.type === 'file');
110
+ const hasDirs = tree.children.some(child => child.type === 'directory');
111
+
112
+ expect(hasFiles).toBe(true);
113
+ expect(hasDirs).toBe(true);
114
+ });
115
+
116
+
117
+ it('should respect maxDepth option', () => {
118
+ // maxDepth: 1 means we go to depth 0 only (root), children at depth 1 return null
119
+ const tree1 = buildTree(tempDir, { maxDepth: 1 });
120
+ expect(tree1.children.every(child => !child.children || child.children.length === 0)).toBe(true);
121
+
122
+ // maxDepth: 2 means we go to depths 0 and 1, children at depth 2 return null
123
+ const tree2 = buildTree(tempDir, { maxDepth: 2 });
124
+ const srcDir = tree2.children.find(child => child.name === 'src' && child.type === 'directory');
125
+ // srcDir exists at depth 1, but its children (at depth 2) won't be built
126
+ if (srcDir) {
127
+ expect(srcDir.children).toBeDefined();
128
+ // Children array exists but should be empty since they're at maxDepth
129
+ expect(srcDir.children.length).toBe(0);
130
+ }
131
+ });
132
+
133
+ it('should hide hidden files by default', () => {
134
+ const tree = buildTree(tempDir, { showHidden: false });
135
+
136
+ const hasHidden = tree.children.some(child => child.name.startsWith('.'));
137
+ expect(hasHidden).toBe(false);
138
+ });
139
+
140
+ it('should show hidden files when showHidden is true', () => {
141
+ const tree = buildTree(tempDir, { showHidden: true });
142
+
143
+ const gitDir = tree.children.find(child => child.name === '.git');
144
+ expect(gitDir).toBeDefined();
145
+ });
146
+
147
+ it('should exclude patterns', () => {
148
+ const tree = buildTree(tempDir, {
149
+ excludePatterns: [/^test$/]
150
+ });
151
+
152
+ const testDir = tree.children.find(child => child.name === 'test');
153
+ expect(testDir).toBeUndefined();
154
+ });
155
+
156
+ it('should sort directories before files', () => {
157
+ const tree = buildTree(tempDir);
158
+
159
+ let lastWasDir = true;
160
+ for (const child of tree.children) {
161
+ if (child.type === 'file' && lastWasDir) {
162
+ lastWasDir = false;
163
+ } else if (child.type === 'directory' && !lastWasDir) {
164
+ // Found a directory after a file - sorting is wrong
165
+ expect(true).toBe(false);
166
+ }
167
+ }
168
+ expect(true).toBe(true);
169
+ });
170
+
171
+
172
+ it('should sort items alphabetically within type', () => {
173
+ const tree = buildTree(tempDir);
174
+
175
+ const dirs = tree.children.filter(c => c.type === 'directory');
176
+ const files = tree.children.filter(c => c.type === 'file');
177
+
178
+ // Check directories are sorted
179
+ for (let i = 0; i < dirs.length - 1; i++) {
180
+ expect(dirs[i].name.localeCompare(dirs[i + 1].name)).toBeLessThanOrEqual(0);
181
+ }
182
+
183
+ // Check files are sorted
184
+ for (let i = 0; i < files.length - 1; i++) {
185
+ expect(files[i].name.localeCompare(files[i + 1].name)).toBeLessThanOrEqual(0);
186
+ }
187
+ });
188
+
189
+ it('should return null for non-existent paths', () => {
190
+ const tree = buildTree('/non/existent/path');
191
+ expect(tree).toBeNull();
192
+ });
193
+
194
+ it('should handle empty directories', () => {
195
+ const emptyDir = path.join(tempDir, 'empty');
196
+ fs.mkdirSync(emptyDir);
197
+
198
+ const tree = buildTree(emptyDir);
199
+ expect(tree).toBeDefined();
200
+ expect(tree.children).toBeDefined();
201
+ expect(tree.children.length).toBe(0);
202
+ });
203
+ });
204
+
205
+
206
+ describe('renderTree', () => {
207
+ it('should render a tree structure as text', () => {
208
+ const tree = {
209
+ name: 'root',
210
+ type: 'directory',
211
+ icon: '📁',
212
+ children: [
213
+ { name: 'file1.js', type: 'file', icon: '📜', path: '/root/file1.js' },
214
+ { name: 'file2.md', type: 'file', icon: '📝', path: '/root/file2.md' }
215
+ ]
216
+ };
217
+
218
+ const output = renderTree(tree);
219
+ expect(output).toContain('root');
220
+ expect(output).toContain('file1.js');
221
+ expect(output).toContain('file2.md');
222
+ expect(output).toContain('📁');
223
+ expect(output).toContain('📜');
224
+ expect(output).toContain('📝');
225
+ });
226
+
227
+ it('should use tree connectors', () => {
228
+ const tree = {
229
+ name: 'root',
230
+ type: 'directory',
231
+ icon: '📁',
232
+ children: [
233
+ { name: 'file1.js', type: 'file', icon: '📜', path: '/root/file1.js' }
234
+ ]
235
+ };
236
+
237
+ const output = renderTree(tree);
238
+ expect(output).toMatch(/[└├]/); // Should contain tree connectors
239
+ });
240
+
241
+ it('should hide icons when showIcons is false', () => {
242
+ const tree = {
243
+ name: 'root',
244
+ type: 'directory',
245
+ icon: '📁',
246
+ children: [
247
+ { name: 'file.js', type: 'file', icon: '📜', path: '/root/file.js' }
248
+ ]
249
+ };
250
+
251
+ const output = renderTree(tree, { showIcons: false });
252
+ expect(output).not.toContain('📁');
253
+ expect(output).not.toContain('📜');
254
+ expect(output).toContain('root');
255
+ expect(output).toContain('file.js');
256
+ });
257
+
258
+
259
+ it('should show paths when showPaths is true', () => {
260
+ const tree = {
261
+ name: 'root',
262
+ type: 'directory',
263
+ icon: '📁',
264
+ path: '/test/root',
265
+ children: [
266
+ { name: 'file.js', type: 'file', icon: '📜', path: '/test/root/file.js' }
267
+ ]
268
+ };
269
+
270
+ const output = renderTree(tree, { showPaths: true });
271
+ expect(output).toContain('/test/root');
272
+ expect(output).toContain('/test/root/file.js');
273
+ });
274
+
275
+ it('should return empty string for null tree', () => {
276
+ const output = renderTree(null);
277
+ expect(output).toBe('');
278
+ });
279
+
280
+ it('should handle nested structures', () => {
281
+ const tree = {
282
+ name: 'root',
283
+ type: 'directory',
284
+ icon: '📁',
285
+ children: [
286
+ {
287
+ name: 'src',
288
+ type: 'directory',
289
+ icon: '📁',
290
+ children: [
291
+ { name: 'index.js', type: 'file', icon: '📜', path: '/root/src/index.js' },
292
+ { name: 'utils.js', type: 'file', icon: '📜', path: '/root/src/utils.js' }
293
+ ]
294
+ }
295
+ ]
296
+ };
297
+
298
+ const output = renderTree(tree);
299
+ expect(output).toContain('root');
300
+ expect(output).toContain('src');
301
+ expect(output).toContain('index.js');
302
+ // With siblings in src, we should get vertical lines
303
+ expect(output).toMatch(/[│├]/);
304
+ });
305
+ });
306
+
307
+
308
+ describe('getTreeStats', () => {
309
+ it('should count files and directories', () => {
310
+ const tree = {
311
+ name: 'root',
312
+ type: 'directory',
313
+ children: [
314
+ { name: 'file1.js', type: 'file' },
315
+ { name: 'file2.js', type: 'file' },
316
+ {
317
+ name: 'subdir',
318
+ type: 'directory',
319
+ children: [
320
+ { name: 'file3.js', type: 'file' }
321
+ ]
322
+ }
323
+ ]
324
+ };
325
+
326
+ const stats = getTreeStats(tree);
327
+ expect(stats.files).toBe(3);
328
+ expect(stats.directories).toBe(2); // root + subdir
329
+ });
330
+
331
+ it('should return zero counts for null tree', () => {
332
+ const stats = getTreeStats(null);
333
+ expect(stats.files).toBe(0);
334
+ expect(stats.directories).toBe(0);
335
+ });
336
+
337
+ it('should handle empty directories', () => {
338
+ const tree = {
339
+ name: 'empty',
340
+ type: 'directory',
341
+ children: []
342
+ };
343
+
344
+ const stats = getTreeStats(tree);
345
+ expect(stats.files).toBe(0);
346
+ expect(stats.directories).toBe(1);
347
+ });
348
+
349
+ it('should handle single file', () => {
350
+ const tree = {
351
+ name: 'file.js',
352
+ type: 'file'
353
+ };
354
+
355
+ const stats = getTreeStats(tree);
356
+ expect(stats.files).toBe(1);
357
+ expect(stats.directories).toBe(0);
358
+ });
359
+
360
+ it('should handle deeply nested structures', () => {
361
+ const tree = {
362
+ name: 'root',
363
+ type: 'directory',
364
+ children: [
365
+ {
366
+ name: 'level1',
367
+ type: 'directory',
368
+ children: [
369
+ {
370
+ name: 'level2',
371
+ type: 'directory',
372
+ children: [
373
+ { name: 'deep.js', type: 'file' }
374
+ ]
375
+ },
376
+ { name: 'file1.js', type: 'file' }
377
+ ]
378
+ },
379
+ { name: 'file2.js', type: 'file' }
380
+ ]
381
+ };
382
+
383
+ const stats = getTreeStats(tree);
384
+ expect(stats.files).toBe(3);
385
+ expect(stats.directories).toBe(3); // root, level1, level2
386
+ });
387
+ });
@@ -0,0 +1,26 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ coverage: {
8
+ provider: 'v8',
9
+ reporter: ['text', 'html', 'lcov'],
10
+ exclude: [
11
+ 'node_modules/**',
12
+ 'test/**',
13
+ 'bin/cli.js', // CLI is harder to test, focus on core logic
14
+ '*.config.js',
15
+ 'coverage/**',
16
+ 'dist/**'
17
+ ],
18
+ thresholds: {
19
+ lines: 80,
20
+ functions: 80,
21
+ branches: 70,
22
+ statements: 80
23
+ }
24
+ }
25
+ }
26
+ });