@gilav21/shadcn-angular 0.0.22 → 0.0.23

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.
@@ -1,6 +1,445 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import { resolveDependencies, promptOptionalDependencies } from './add.js';
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import {
3
+ resolveDependencies,
4
+ promptOptionalDependencies,
5
+ normalizeContent,
6
+ fetchAndTransform,
7
+ checkFileConflict,
8
+ classifyComponent,
9
+ detectConflicts,
10
+ type AddOptions,
11
+ } from './add.js';
3
12
  import { registry, type ComponentName, type ComponentDefinition } from '../registry/index.js';
13
+ import fs from 'fs-extra';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Module-level mocks
17
+ // ---------------------------------------------------------------------------
18
+
19
+ vi.mock('fs-extra', () => ({
20
+ default: {
21
+ existsSync: vi.fn(() => false),
22
+ pathExists: vi.fn(() => Promise.resolve(false)),
23
+ readFile: vi.fn(() => Promise.resolve('')),
24
+ writeFile: vi.fn(() => Promise.resolve()),
25
+ ensureDir: vi.fn(() => Promise.resolve()),
26
+ },
27
+ }));
28
+
29
+ vi.mock('prompts', () => ({
30
+ default: vi.fn(),
31
+ }));
32
+
33
+ const mockedFs = vi.mocked(fs);
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // normalizeContent
37
+ // ---------------------------------------------------------------------------
38
+
39
+ describe('normalizeContent', () => {
40
+ it('trims whitespace from both ends', () => {
41
+ expect(normalizeContent(' hello ')).toBe('hello');
42
+ });
43
+
44
+ it('converts CRLF to LF', () => {
45
+ expect(normalizeContent('line1\r\nline2\r\n')).toBe('line1\nline2');
46
+ });
47
+
48
+ it('handles mixed line endings', () => {
49
+ expect(normalizeContent('a\r\nb\nc\r\n')).toBe('a\nb\nc');
50
+ });
51
+
52
+ it('returns empty string for whitespace-only input', () => {
53
+ expect(normalizeContent(' \r\n ')).toBe('');
54
+ });
55
+ });
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // fetchAndTransform
59
+ // ---------------------------------------------------------------------------
60
+
61
+ describe('fetchAndTransform', () => {
62
+ beforeEach(() => {
63
+ vi.restoreAllMocks();
64
+ mockedFs.existsSync.mockReturnValue(false);
65
+ });
66
+
67
+ it('replaces single-level relative lib imports with alias', async () => {
68
+ const mockContent = "import { cn } from '../lib/utils';";
69
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
70
+ new Response(mockContent, { status: 200 }),
71
+ );
72
+
73
+ const result = await fetchAndTransform('button/button.ts', { branch: 'master', remote: true }, '@/components/lib');
74
+ expect(result).toBe("import { cn } from '@/components/lib/utils';");
75
+ });
76
+
77
+ it('replaces multi-level relative lib imports with alias', async () => {
78
+ const mockContent = "import { cn } from '../../lib/utils';";
79
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
80
+ new Response(mockContent, { status: 200 }),
81
+ );
82
+
83
+ const result = await fetchAndTransform('deep/nested/comp.ts', { branch: 'master', remote: true }, '@/components/lib');
84
+ expect(result).toBe("import { cn } from '@/components/lib/utils';");
85
+ });
86
+
87
+ it('replaces all occurrences in a file (global regex)', async () => {
88
+ const mockContent = [
89
+ "import { cn } from '../lib/utils';",
90
+ "import { foo } from '../lib/helpers';",
91
+ ].join('\n');
92
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
93
+ new Response(mockContent, { status: 200 }),
94
+ );
95
+
96
+ const result = await fetchAndTransform('comp.ts', { branch: 'master', remote: true }, '@/lib');
97
+ expect(result).toBe([
98
+ "import { cn } from '@/lib/utils';",
99
+ "import { foo } from '@/lib/helpers';",
100
+ ].join('\n'));
101
+ });
102
+
103
+ it('does not throw with replaceAll and regex (the original bug)', async () => {
104
+ const mockContent = "import { cn } from '../lib/utils';";
105
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
106
+ new Response(mockContent, { status: 200 }),
107
+ );
108
+
109
+ await expect(
110
+ fetchAndTransform('comp.ts', { branch: 'master', remote: true }, '@/lib'),
111
+ ).resolves.not.toThrow();
112
+ });
113
+
114
+ it('leaves content unchanged when no lib imports exist', async () => {
115
+ const mockContent = "import { Component } from '@angular/core';";
116
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
117
+ new Response(mockContent, { status: 200 }),
118
+ );
119
+
120
+ const result = await fetchAndTransform('comp.ts', { branch: 'master', remote: true }, '@/lib');
121
+ expect(result).toBe("import { Component } from '@angular/core';");
122
+ });
123
+ });
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // checkFileConflict
127
+ // ---------------------------------------------------------------------------
128
+
129
+ describe('checkFileConflict', () => {
130
+ beforeEach(() => {
131
+ vi.restoreAllMocks();
132
+ mockedFs.existsSync.mockReturnValue(false);
133
+ });
134
+
135
+ it('returns "missing" when file does not exist on disk', async () => {
136
+ mockedFs.pathExists.mockResolvedValue(false as never);
137
+ const cache = new Map<string, string>();
138
+
139
+ const result = await checkFileConflict(
140
+ 'button/button.ts', '/project/ui', { branch: 'master', remote: true }, '@/lib', cache,
141
+ );
142
+ expect(result).toBe('missing');
143
+ });
144
+
145
+ it('returns "identical" when local and remote content match', async () => {
146
+ const content = "import { cn } from '@/lib/utils';\nexport class Button {}";
147
+ mockedFs.pathExists.mockResolvedValue(true as never);
148
+ mockedFs.readFile.mockResolvedValue(content as never);
149
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
150
+ new Response(content.replaceAll('@/lib/', '../lib/'), { status: 200 }),
151
+ );
152
+ const cache = new Map<string, string>();
153
+
154
+ const result = await checkFileConflict(
155
+ 'button/button.ts', '/project/ui', { branch: 'master', remote: true }, '@/lib', cache,
156
+ );
157
+ expect(result).toBe('identical');
158
+ });
159
+
160
+ it('returns "changed" when local and remote content differ', async () => {
161
+ mockedFs.pathExists.mockResolvedValue(true as never);
162
+ mockedFs.readFile.mockResolvedValue('// local modified version' as never);
163
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
164
+ new Response('// remote original version', { status: 200 }),
165
+ );
166
+ const cache = new Map<string, string>();
167
+
168
+ const result = await checkFileConflict(
169
+ 'button/button.ts', '/project/ui', { branch: 'master', remote: true }, '@/lib', cache,
170
+ );
171
+ expect(result).toBe('changed');
172
+ });
173
+
174
+ it('populates the content cache on successful fetch', async () => {
175
+ mockedFs.pathExists.mockResolvedValue(true as never);
176
+ mockedFs.readFile.mockResolvedValue('// local' as never);
177
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
178
+ new Response('// remote', { status: 200 }),
179
+ );
180
+ const cache = new Map<string, string>();
181
+
182
+ await checkFileConflict(
183
+ 'button/button.ts', '/project/ui', { branch: 'master', remote: true }, '@/lib', cache,
184
+ );
185
+ expect(cache.has('button/button.ts')).toBe(true);
186
+ expect(cache.get('button/button.ts')).toBe('// remote');
187
+ });
188
+ });
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // classifyComponent — peer-file decoupling
192
+ // ---------------------------------------------------------------------------
193
+
194
+ describe('classifyComponent', () => {
195
+ beforeEach(() => {
196
+ vi.restoreAllMocks();
197
+ mockedFs.existsSync.mockReturnValue(false);
198
+ });
199
+
200
+ it('returns "install" when component files are missing', async () => {
201
+ mockedFs.pathExists.mockResolvedValue(false as never);
202
+ const cache = new Map<string, string>();
203
+ const peerFiles = new Set<string>();
204
+
205
+ const result = await classifyComponent(
206
+ 'badge', '/project/ui', { branch: 'master', remote: true }, '@/lib', cache, peerFiles,
207
+ );
208
+ expect(result).toBe('install');
209
+ });
210
+
211
+ it('returns "skip" when all component files are present and identical', async () => {
212
+ const content = '// badge content';
213
+ mockedFs.pathExists.mockResolvedValue(true as never);
214
+ mockedFs.readFile.mockResolvedValue(content as never);
215
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
216
+ new Response(content, { status: 200 }),
217
+ );
218
+ const cache = new Map<string, string>();
219
+ const peerFiles = new Set<string>();
220
+
221
+ const result = await classifyComponent(
222
+ 'badge', '/project/ui', { branch: 'master', remote: true }, '@/lib', cache, peerFiles,
223
+ );
224
+ expect(result).toBe('skip');
225
+ });
226
+
227
+ it('returns "conflict" when own files have changed', async () => {
228
+ mockedFs.pathExists.mockResolvedValue(true as never);
229
+ mockedFs.readFile.mockResolvedValue('// modified locally' as never);
230
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
231
+ new Response('// original remote', { status: 200 }),
232
+ );
233
+ const cache = new Map<string, string>();
234
+ const peerFiles = new Set<string>();
235
+
236
+ const result = await classifyComponent(
237
+ 'badge', '/project/ui', { branch: 'master', remote: true }, '@/lib', cache, peerFiles,
238
+ );
239
+ expect(result).toBe('conflict');
240
+ });
241
+
242
+ it('does NOT return "conflict" when only peer files have changed', async () => {
243
+ const componentWithPeers = Object.entries(registry).find(
244
+ ([, def]) => def.peerFiles && def.peerFiles.length > 0,
245
+ );
246
+
247
+ if (!componentWithPeers) {
248
+ return;
249
+ }
250
+
251
+ const [name, def] = componentWithPeers;
252
+ let readCallCount = 0;
253
+
254
+ mockedFs.pathExists.mockResolvedValue(true as never);
255
+ mockedFs.readFile.mockImplementation((() => {
256
+ readCallCount++;
257
+ if (readCallCount <= def.files.length) {
258
+ return Promise.resolve('// identical content');
259
+ }
260
+ return Promise.resolve('// modified peer file');
261
+ }) as never);
262
+
263
+ vi.spyOn(globalThis, 'fetch').mockImplementation(() =>
264
+ Promise.resolve(new Response('// identical content', { status: 200 })),
265
+ );
266
+
267
+ const cache = new Map<string, string>();
268
+ const peerFilesToUpdate = new Set<string>();
269
+
270
+ const result = await classifyComponent(
271
+ name as ComponentName, '/project/ui', { branch: 'master', remote: true }, '@/lib',
272
+ cache, peerFilesToUpdate,
273
+ );
274
+
275
+ expect(result).toBe('skip');
276
+ expect(peerFilesToUpdate.size).toBeGreaterThan(0);
277
+ });
278
+ });
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // detectConflicts
282
+ // ---------------------------------------------------------------------------
283
+
284
+ describe('detectConflicts', () => {
285
+ beforeEach(() => {
286
+ vi.restoreAllMocks();
287
+ mockedFs.existsSync.mockReturnValue(false);
288
+ });
289
+
290
+ it('classifies missing components as toInstall', async () => {
291
+ mockedFs.pathExists.mockResolvedValue(false as never);
292
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
293
+ new Response('// content', { status: 200 }),
294
+ );
295
+
296
+ const components = new Set<ComponentName>(['badge']);
297
+ const result = await detectConflicts(components, '/project/ui', { branch: 'master', remote: true }, '@/lib');
298
+
299
+ expect(result.toInstall).toContain('badge');
300
+ expect(result.conflicting).not.toContain('badge');
301
+ expect(result.toSkip).not.toContain('badge');
302
+ });
303
+
304
+ it('classifies identical components as toSkip', async () => {
305
+ const content = '// badge content';
306
+ mockedFs.pathExists.mockResolvedValue(true as never);
307
+ mockedFs.readFile.mockResolvedValue(content as never);
308
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
309
+ new Response(content, { status: 200 }),
310
+ );
311
+
312
+ const components = new Set<ComponentName>(['badge']);
313
+ const result = await detectConflicts(components, '/project/ui', { branch: 'master', remote: true }, '@/lib');
314
+
315
+ expect(result.toSkip).toContain('badge');
316
+ expect(result.toInstall).not.toContain('badge');
317
+ expect(result.conflicting).not.toContain('badge');
318
+ });
319
+
320
+ it('classifies changed components as conflicting', async () => {
321
+ mockedFs.pathExists.mockResolvedValue(true as never);
322
+ mockedFs.readFile.mockResolvedValue('// modified locally' as never);
323
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
324
+ new Response('// remote content', { status: 200 }),
325
+ );
326
+
327
+ const components = new Set<ComponentName>(['badge']);
328
+ const result = await detectConflicts(components, '/project/ui', { branch: 'master', remote: true }, '@/lib');
329
+
330
+ expect(result.conflicting).toContain('badge');
331
+ expect(result.toInstall).not.toContain('badge');
332
+ expect(result.toSkip).not.toContain('badge');
333
+ });
334
+
335
+ it('populates contentCache for files it checks', async () => {
336
+ mockedFs.pathExists.mockResolvedValue(true as never);
337
+ mockedFs.readFile.mockResolvedValue('// local' as never);
338
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
339
+ new Response('// remote', { status: 200 }),
340
+ );
341
+
342
+ const components = new Set<ComponentName>(['badge']);
343
+ const result = await detectConflicts(components, '/project/ui', { branch: 'master', remote: true }, '@/lib');
344
+
345
+ expect(result.contentCache.size).toBeGreaterThan(0);
346
+ });
347
+ });
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // Peer file filtering after overwrite selection
351
+ // ---------------------------------------------------------------------------
352
+
353
+ describe('peer file filtering logic', () => {
354
+ it('removes peer files of declined components from peerFilesToUpdate', () => {
355
+ const peerFilesToUpdate = new Set(['shared/peer-a.ts', 'shared/peer-b.ts', 'shared/peer-c.ts']);
356
+ const conflicting: string[] = ['compA', 'compB'];
357
+ const toOverwrite: string[] = ['compA'];
358
+ const finalComponents: string[] = ['compA'];
359
+
360
+ const mockPeerFiles: Record<string, string[] | undefined> = {
361
+ compA: ['shared/peer-a.ts'],
362
+ compB: ['shared/peer-b.ts', 'shared/peer-c.ts'],
363
+ };
364
+
365
+ const declined = conflicting.filter(c => !toOverwrite.includes(c));
366
+
367
+ for (const name of declined) {
368
+ const peerFiles = mockPeerFiles[name];
369
+ if (!peerFiles) continue;
370
+ for (const file of peerFiles) {
371
+ const stillNeeded = finalComponents.some(fc =>
372
+ mockPeerFiles[fc]?.includes(file),
373
+ );
374
+ if (!stillNeeded) {
375
+ peerFilesToUpdate.delete(file);
376
+ }
377
+ }
378
+ }
379
+
380
+ expect(peerFilesToUpdate.has('shared/peer-a.ts')).toBe(true);
381
+ expect(peerFilesToUpdate.has('shared/peer-b.ts')).toBe(false);
382
+ expect(peerFilesToUpdate.has('shared/peer-c.ts')).toBe(false);
383
+ });
384
+
385
+ it('keeps shared peer files when another final component still needs them', () => {
386
+ const peerFilesToUpdate = new Set(['shared/common.ts']);
387
+ const conflicting: string[] = ['compA', 'compB'];
388
+ const toOverwrite: string[] = ['compA'];
389
+ const finalComponents: string[] = ['compA'];
390
+
391
+ const mockPeerFiles: Record<string, string[] | undefined> = {
392
+ compA: ['shared/common.ts'],
393
+ compB: ['shared/common.ts'],
394
+ };
395
+
396
+ const declined = conflicting.filter(c => !toOverwrite.includes(c));
397
+
398
+ for (const name of declined) {
399
+ const peerFiles = mockPeerFiles[name];
400
+ if (!peerFiles) continue;
401
+ for (const file of peerFiles) {
402
+ const stillNeeded = finalComponents.some(fc =>
403
+ mockPeerFiles[fc]?.includes(file),
404
+ );
405
+ if (!stillNeeded) {
406
+ peerFilesToUpdate.delete(file);
407
+ }
408
+ }
409
+ }
410
+
411
+ expect(peerFilesToUpdate.has('shared/common.ts')).toBe(true);
412
+ });
413
+
414
+ it('removes all peer files when all components are declined', () => {
415
+ const peerFilesToUpdate = new Set(['shared/peer-a.ts', 'shared/peer-b.ts']);
416
+ const conflicting: string[] = ['compA', 'compB'];
417
+ const toOverwrite: string[] = [];
418
+ const finalComponents: string[] = [];
419
+
420
+ const mockPeerFiles: Record<string, string[] | undefined> = {
421
+ compA: ['shared/peer-a.ts'],
422
+ compB: ['shared/peer-b.ts'],
423
+ };
424
+
425
+ const declined = conflicting.filter(c => !toOverwrite.includes(c));
426
+
427
+ for (const name of declined) {
428
+ const peerFiles = mockPeerFiles[name];
429
+ if (!peerFiles) continue;
430
+ for (const file of peerFiles) {
431
+ const stillNeeded = finalComponents.some(fc =>
432
+ mockPeerFiles[fc]?.includes(file),
433
+ );
434
+ if (!stillNeeded) {
435
+ peerFilesToUpdate.delete(file);
436
+ }
437
+ }
438
+ }
439
+
440
+ expect(peerFilesToUpdate.size).toBe(0);
441
+ });
442
+ });
4
443
 
5
444
  // ---------------------------------------------------------------------------
6
445
  // resolveDependencies
@@ -14,14 +453,12 @@ describe('resolveDependencies', () => {
14
453
  });
15
454
 
16
455
  it('includes transitive dependencies', () => {
17
- // button depends on ripple
18
456
  const result = resolveDependencies(['button']);
19
457
  expect(result).toContain('button');
20
458
  expect(result).toContain('ripple');
21
459
  });
22
460
 
23
461
  it('resolves deep transitive chains', () => {
24
- // date-picker -> calendar -> button -> ripple, calendar -> select
25
462
  const result = resolveDependencies(['date-picker']);
26
463
  expect(result).toContain('date-picker');
27
464
  expect(result).toContain('calendar');
@@ -31,14 +468,12 @@ describe('resolveDependencies', () => {
31
468
  });
32
469
 
33
470
  it('deduplicates shared dependencies across multiple inputs', () => {
34
- // Both button-group and speed-dial depend on button
35
471
  const result = resolveDependencies(['button-group', 'speed-dial']);
36
472
  expect(result).toContain('button-group');
37
473
  expect(result).toContain('speed-dial');
38
474
  expect(result).toContain('button');
39
475
  expect(result).toContain('ripple');
40
476
 
41
- // Count how many times button appears — should be exactly once (it's a Set)
42
477
  const asArray = [...result];
43
478
  expect(asArray.filter((n: string) => n === 'button')).toHaveLength(1);
44
479
  });
@@ -59,10 +494,6 @@ describe('resolveDependencies', () => {
59
494
  // promptOptionalDependencies
60
495
  // ---------------------------------------------------------------------------
61
496
 
62
- vi.mock('prompts', () => ({
63
- default: vi.fn(),
64
- }));
65
-
66
497
  describe('promptOptionalDependencies', () => {
67
498
  it('returns empty array when no components have optional deps', async () => {
68
499
  const resolved = new Set<ComponentName>(['badge', 'button', 'ripple']);
@@ -83,14 +514,12 @@ describe('promptOptionalDependencies', () => {
83
514
  });
84
515
 
85
516
  it('filters out optional deps already in the resolved set', async () => {
86
- // context-menu is already resolved, so it should not be offered
87
517
  const resolved = new Set<ComponentName>(['data-table', 'context-menu', 'table', 'input', 'button', 'ripple', 'checkbox', 'select', 'pagination', 'popover', 'component-outlet', 'icon']);
88
518
  const result = await promptOptionalDependencies(resolved, { all: true, branch: 'master' });
89
519
  expect(result).not.toContain('context-menu');
90
520
  });
91
521
 
92
522
  it('deduplicates optional deps across components', async () => {
93
- // Both data-table and tree have context-menu as optional
94
523
  const resolved = new Set<ComponentName>(['data-table', 'tree', 'table', 'input', 'button', 'ripple', 'checkbox', 'select', 'pagination', 'popover', 'component-outlet', 'icon']);
95
524
  const result = await promptOptionalDependencies(resolved, { all: true, branch: 'master' });
96
525
  const contextMenuCount = result.filter((n: string) => n === 'context-menu').length;