@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.
- package/dist/commands/add.d.ts +13 -1
- package/dist/commands/add.js +39 -20
- package/dist/commands/add.spec.js +301 -11
- package/package.json +1 -1
- package/src/commands/add.spec.ts +441 -12
- package/src/commands/add.ts +621 -598
- package/src/commands/init.ts +314 -314
package/dist/commands/add.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ComponentName } from '../registry/index.js';
|
|
2
|
-
interface AddOptions {
|
|
2
|
+
export interface AddOptions {
|
|
3
3
|
yes?: boolean;
|
|
4
4
|
overwrite?: boolean;
|
|
5
5
|
all?: boolean;
|
|
@@ -7,7 +7,19 @@ interface AddOptions {
|
|
|
7
7
|
remote?: boolean;
|
|
8
8
|
branch: string;
|
|
9
9
|
}
|
|
10
|
+
interface ConflictCheckResult {
|
|
11
|
+
toInstall: ComponentName[];
|
|
12
|
+
toSkip: string[];
|
|
13
|
+
conflicting: ComponentName[];
|
|
14
|
+
peerFilesToUpdate: Set<string>;
|
|
15
|
+
contentCache: Map<string, string>;
|
|
16
|
+
}
|
|
17
|
+
export declare function normalizeContent(str: string): string;
|
|
18
|
+
export declare function fetchAndTransform(file: string, options: AddOptions, utilsAlias: string): Promise<string>;
|
|
10
19
|
export declare function resolveDependencies(names: ComponentName[]): Set<ComponentName>;
|
|
11
20
|
export declare function promptOptionalDependencies(resolved: Set<ComponentName>, options: AddOptions): Promise<ComponentName[]>;
|
|
21
|
+
export declare function checkFileConflict(file: string, targetDir: string, options: AddOptions, utilsAlias: string, contentCache: Map<string, string>): Promise<'identical' | 'changed' | 'missing'>;
|
|
22
|
+
export declare function classifyComponent(name: ComponentName, targetDir: string, options: AddOptions, utilsAlias: string, contentCache: Map<string, string>, peerFilesToUpdate: Set<string>): Promise<'install' | 'skip' | 'conflict'>;
|
|
23
|
+
export declare function detectConflicts(allComponents: Set<ComponentName>, targetDir: string, options: AddOptions, utilsAlias: string): Promise<ConflictCheckResult>;
|
|
12
24
|
export declare function add(components: string[], options: AddOptions): Promise<void>;
|
|
13
25
|
export {};
|
package/dist/commands/add.js
CHANGED
|
@@ -50,7 +50,7 @@ function aliasToProjectPath(aliasOrPath) {
|
|
|
50
50
|
? path.join('src', aliasOrPath.slice(2))
|
|
51
51
|
: aliasOrPath;
|
|
52
52
|
}
|
|
53
|
-
function normalizeContent(str) {
|
|
53
|
+
export function normalizeContent(str) {
|
|
54
54
|
return str.replaceAll('\r\n', '\n').trim();
|
|
55
55
|
}
|
|
56
56
|
// ---------------------------------------------------------------------------
|
|
@@ -94,9 +94,9 @@ async function fetchLibContent(file, options) {
|
|
|
94
94
|
}
|
|
95
95
|
return response.text();
|
|
96
96
|
}
|
|
97
|
-
async function fetchAndTransform(file, options, utilsAlias) {
|
|
97
|
+
export async function fetchAndTransform(file, options, utilsAlias) {
|
|
98
98
|
let content = await fetchComponentContent(file, options);
|
|
99
|
-
content = content.replaceAll(/(\.\.\/)+lib
|
|
99
|
+
content = content.replaceAll(/(\.\.\/)+lib\//g, utilsAlias + '/');
|
|
100
100
|
return content;
|
|
101
101
|
}
|
|
102
102
|
// ---------------------------------------------------------------------------
|
|
@@ -175,7 +175,7 @@ export async function promptOptionalDependencies(resolved, options) {
|
|
|
175
175
|
// ---------------------------------------------------------------------------
|
|
176
176
|
// Conflict detection
|
|
177
177
|
// ---------------------------------------------------------------------------
|
|
178
|
-
async function checkFileConflict(file, targetDir, options, utilsAlias, contentCache) {
|
|
178
|
+
export async function checkFileConflict(file, targetDir, options, utilsAlias, contentCache) {
|
|
179
179
|
const targetPath = path.join(targetDir, file);
|
|
180
180
|
if (!await fs.pathExists(targetPath)) {
|
|
181
181
|
return 'missing';
|
|
@@ -194,38 +194,33 @@ async function checkFileConflict(file, targetDir, options, utilsAlias, contentCa
|
|
|
194
194
|
}
|
|
195
195
|
async function checkPeerFiles(component, targetDir, options, utilsAlias, contentCache, peerFilesToUpdate) {
|
|
196
196
|
if (!component.peerFiles)
|
|
197
|
-
return
|
|
198
|
-
let hasChanges = false;
|
|
197
|
+
return;
|
|
199
198
|
for (const file of component.peerFiles) {
|
|
200
199
|
const status = await checkFileConflict(file, targetDir, options, utilsAlias, contentCache);
|
|
201
200
|
if (status === 'changed') {
|
|
202
|
-
hasChanges = true;
|
|
203
201
|
peerFilesToUpdate.add(file);
|
|
204
202
|
}
|
|
205
203
|
}
|
|
206
|
-
return hasChanges;
|
|
207
204
|
}
|
|
208
|
-
async function classifyComponent(name, targetDir, options, utilsAlias, contentCache, peerFilesToUpdate) {
|
|
205
|
+
export async function classifyComponent(name, targetDir, options, utilsAlias, contentCache, peerFilesToUpdate) {
|
|
209
206
|
const component = registry[name];
|
|
210
|
-
let
|
|
207
|
+
let ownFilesChanged = false;
|
|
211
208
|
let isFullyPresent = true;
|
|
212
209
|
for (const file of component.files) {
|
|
213
210
|
const status = await checkFileConflict(file, targetDir, options, utilsAlias, contentCache);
|
|
214
211
|
if (status === 'missing')
|
|
215
212
|
isFullyPresent = false;
|
|
216
213
|
if (status === 'changed')
|
|
217
|
-
|
|
214
|
+
ownFilesChanged = true;
|
|
218
215
|
}
|
|
219
|
-
|
|
220
|
-
if (
|
|
221
|
-
hasChanges = true;
|
|
222
|
-
if (isFullyPresent && !hasChanges)
|
|
216
|
+
await checkPeerFiles(component, targetDir, options, utilsAlias, contentCache, peerFilesToUpdate);
|
|
217
|
+
if (isFullyPresent && !ownFilesChanged)
|
|
223
218
|
return 'skip';
|
|
224
|
-
if (
|
|
219
|
+
if (ownFilesChanged)
|
|
225
220
|
return 'conflict';
|
|
226
221
|
return 'install';
|
|
227
222
|
}
|
|
228
|
-
async function detectConflicts(allComponents, targetDir, options, utilsAlias) {
|
|
223
|
+
export async function detectConflicts(allComponents, targetDir, options, utilsAlias) {
|
|
229
224
|
const toInstall = [];
|
|
230
225
|
const toSkip = [];
|
|
231
226
|
const conflicting = [];
|
|
@@ -399,9 +394,28 @@ export async function add(components, options) {
|
|
|
399
394
|
// Prompt user for overwrite decisions
|
|
400
395
|
const toOverwrite = await promptOverwrite(conflicting, options);
|
|
401
396
|
const finalComponents = [...toInstall, ...toOverwrite];
|
|
397
|
+
// Remove peer files that belong only to declined components
|
|
398
|
+
const declined = conflicting.filter(c => !toOverwrite.includes(c));
|
|
399
|
+
for (const name of declined) {
|
|
400
|
+
const component = registry[name];
|
|
401
|
+
if (!component.peerFiles)
|
|
402
|
+
continue;
|
|
403
|
+
for (const file of component.peerFiles) {
|
|
404
|
+
const stillNeeded = finalComponents.some(fc => registry[fc].peerFiles?.includes(file));
|
|
405
|
+
if (!stillNeeded) {
|
|
406
|
+
peerFilesToUpdate.delete(file);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
402
410
|
if (finalComponents.length === 0) {
|
|
403
|
-
if (toSkip.length > 0) {
|
|
404
|
-
|
|
411
|
+
if (toSkip.length > 0 || declined.length > 0) {
|
|
412
|
+
if (toSkip.length > 0) {
|
|
413
|
+
console.log(chalk.green(`\nAll components are up to date! (${toSkip.length} skipped)`));
|
|
414
|
+
}
|
|
415
|
+
if (declined.length > 0) {
|
|
416
|
+
console.log('\n' + chalk.dim('Components skipped (kept local changes):'));
|
|
417
|
+
declined.forEach(name => console.log(chalk.dim(' - ') + chalk.yellow(name)));
|
|
418
|
+
}
|
|
405
419
|
}
|
|
406
420
|
else {
|
|
407
421
|
console.log(chalk.dim('\nNo components to install.'));
|
|
@@ -411,6 +425,7 @@ export async function add(components, options) {
|
|
|
411
425
|
// Install component files
|
|
412
426
|
const spinner = ora('Installing components...').start();
|
|
413
427
|
let successCount = 0;
|
|
428
|
+
const finalComponentSet = new Set(finalComponents);
|
|
414
429
|
try {
|
|
415
430
|
await fs.ensureDir(targetDir);
|
|
416
431
|
for (const name of finalComponents) {
|
|
@@ -432,13 +447,17 @@ export async function add(components, options) {
|
|
|
432
447
|
}
|
|
433
448
|
// Post-install: lib files, npm deps, shortcuts
|
|
434
449
|
const libDir = resolveProjectPath(cwd, aliasToProjectPath(utilsAlias));
|
|
435
|
-
await installLibFiles(
|
|
450
|
+
await installLibFiles(finalComponentSet, cwd, libDir, options);
|
|
436
451
|
await installNpmDependencies(finalComponents, cwd);
|
|
437
452
|
await ensureShortcutService(targetDir, cwd, config, options);
|
|
438
453
|
if (toSkip.length > 0) {
|
|
439
454
|
console.log('\n' + chalk.dim('Components skipped (up to date):'));
|
|
440
455
|
toSkip.forEach(name => console.log(chalk.dim(' - ') + chalk.gray(name)));
|
|
441
456
|
}
|
|
457
|
+
if (declined.length > 0) {
|
|
458
|
+
console.log('\n' + chalk.dim('Components skipped (kept local changes):'));
|
|
459
|
+
declined.forEach(name => console.log(chalk.dim(' - ') + chalk.yellow(name)));
|
|
460
|
+
}
|
|
442
461
|
console.log('');
|
|
443
462
|
}
|
|
444
463
|
catch (error) {
|
|
@@ -1,6 +1,305 @@
|
|
|
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 { resolveDependencies, promptOptionalDependencies, normalizeContent, fetchAndTransform, checkFileConflict, classifyComponent, detectConflicts, } from './add.js';
|
|
3
3
|
import { registry } from '../registry/index.js';
|
|
4
|
+
import fs from 'fs-extra';
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Module-level mocks
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
vi.mock('fs-extra', () => ({
|
|
9
|
+
default: {
|
|
10
|
+
existsSync: vi.fn(() => false),
|
|
11
|
+
pathExists: vi.fn(() => Promise.resolve(false)),
|
|
12
|
+
readFile: vi.fn(() => Promise.resolve('')),
|
|
13
|
+
writeFile: vi.fn(() => Promise.resolve()),
|
|
14
|
+
ensureDir: vi.fn(() => Promise.resolve()),
|
|
15
|
+
},
|
|
16
|
+
}));
|
|
17
|
+
vi.mock('prompts', () => ({
|
|
18
|
+
default: vi.fn(),
|
|
19
|
+
}));
|
|
20
|
+
const mockedFs = vi.mocked(fs);
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// normalizeContent
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
describe('normalizeContent', () => {
|
|
25
|
+
it('trims whitespace from both ends', () => {
|
|
26
|
+
expect(normalizeContent(' hello ')).toBe('hello');
|
|
27
|
+
});
|
|
28
|
+
it('converts CRLF to LF', () => {
|
|
29
|
+
expect(normalizeContent('line1\r\nline2\r\n')).toBe('line1\nline2');
|
|
30
|
+
});
|
|
31
|
+
it('handles mixed line endings', () => {
|
|
32
|
+
expect(normalizeContent('a\r\nb\nc\r\n')).toBe('a\nb\nc');
|
|
33
|
+
});
|
|
34
|
+
it('returns empty string for whitespace-only input', () => {
|
|
35
|
+
expect(normalizeContent(' \r\n ')).toBe('');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// fetchAndTransform
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
describe('fetchAndTransform', () => {
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
vi.restoreAllMocks();
|
|
44
|
+
mockedFs.existsSync.mockReturnValue(false);
|
|
45
|
+
});
|
|
46
|
+
it('replaces single-level relative lib imports with alias', async () => {
|
|
47
|
+
const mockContent = "import { cn } from '../lib/utils';";
|
|
48
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(mockContent, { status: 200 }));
|
|
49
|
+
const result = await fetchAndTransform('button/button.ts', { branch: 'master', remote: true }, '@/components/lib');
|
|
50
|
+
expect(result).toBe("import { cn } from '@/components/lib/utils';");
|
|
51
|
+
});
|
|
52
|
+
it('replaces multi-level relative lib imports with alias', async () => {
|
|
53
|
+
const mockContent = "import { cn } from '../../lib/utils';";
|
|
54
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(mockContent, { status: 200 }));
|
|
55
|
+
const result = await fetchAndTransform('deep/nested/comp.ts', { branch: 'master', remote: true }, '@/components/lib');
|
|
56
|
+
expect(result).toBe("import { cn } from '@/components/lib/utils';");
|
|
57
|
+
});
|
|
58
|
+
it('replaces all occurrences in a file (global regex)', async () => {
|
|
59
|
+
const mockContent = [
|
|
60
|
+
"import { cn } from '../lib/utils';",
|
|
61
|
+
"import { foo } from '../lib/helpers';",
|
|
62
|
+
].join('\n');
|
|
63
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(mockContent, { status: 200 }));
|
|
64
|
+
const result = await fetchAndTransform('comp.ts', { branch: 'master', remote: true }, '@/lib');
|
|
65
|
+
expect(result).toBe([
|
|
66
|
+
"import { cn } from '@/lib/utils';",
|
|
67
|
+
"import { foo } from '@/lib/helpers';",
|
|
68
|
+
].join('\n'));
|
|
69
|
+
});
|
|
70
|
+
it('does not throw with replaceAll and regex (the original bug)', async () => {
|
|
71
|
+
const mockContent = "import { cn } from '../lib/utils';";
|
|
72
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(mockContent, { status: 200 }));
|
|
73
|
+
await expect(fetchAndTransform('comp.ts', { branch: 'master', remote: true }, '@/lib')).resolves.not.toThrow();
|
|
74
|
+
});
|
|
75
|
+
it('leaves content unchanged when no lib imports exist', async () => {
|
|
76
|
+
const mockContent = "import { Component } from '@angular/core';";
|
|
77
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(mockContent, { status: 200 }));
|
|
78
|
+
const result = await fetchAndTransform('comp.ts', { branch: 'master', remote: true }, '@/lib');
|
|
79
|
+
expect(result).toBe("import { Component } from '@angular/core';");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// checkFileConflict
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
describe('checkFileConflict', () => {
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
vi.restoreAllMocks();
|
|
88
|
+
mockedFs.existsSync.mockReturnValue(false);
|
|
89
|
+
});
|
|
90
|
+
it('returns "missing" when file does not exist on disk', async () => {
|
|
91
|
+
mockedFs.pathExists.mockResolvedValue(false);
|
|
92
|
+
const cache = new Map();
|
|
93
|
+
const result = await checkFileConflict('button/button.ts', '/project/ui', { branch: 'master', remote: true }, '@/lib', cache);
|
|
94
|
+
expect(result).toBe('missing');
|
|
95
|
+
});
|
|
96
|
+
it('returns "identical" when local and remote content match', async () => {
|
|
97
|
+
const content = "import { cn } from '@/lib/utils';\nexport class Button {}";
|
|
98
|
+
mockedFs.pathExists.mockResolvedValue(true);
|
|
99
|
+
mockedFs.readFile.mockResolvedValue(content);
|
|
100
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(content.replaceAll('@/lib/', '../lib/'), { status: 200 }));
|
|
101
|
+
const cache = new Map();
|
|
102
|
+
const result = await checkFileConflict('button/button.ts', '/project/ui', { branch: 'master', remote: true }, '@/lib', cache);
|
|
103
|
+
expect(result).toBe('identical');
|
|
104
|
+
});
|
|
105
|
+
it('returns "changed" when local and remote content differ', async () => {
|
|
106
|
+
mockedFs.pathExists.mockResolvedValue(true);
|
|
107
|
+
mockedFs.readFile.mockResolvedValue('// local modified version');
|
|
108
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('// remote original version', { status: 200 }));
|
|
109
|
+
const cache = new Map();
|
|
110
|
+
const result = await checkFileConflict('button/button.ts', '/project/ui', { branch: 'master', remote: true }, '@/lib', cache);
|
|
111
|
+
expect(result).toBe('changed');
|
|
112
|
+
});
|
|
113
|
+
it('populates the content cache on successful fetch', async () => {
|
|
114
|
+
mockedFs.pathExists.mockResolvedValue(true);
|
|
115
|
+
mockedFs.readFile.mockResolvedValue('// local');
|
|
116
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('// remote', { status: 200 }));
|
|
117
|
+
const cache = new Map();
|
|
118
|
+
await checkFileConflict('button/button.ts', '/project/ui', { branch: 'master', remote: true }, '@/lib', cache);
|
|
119
|
+
expect(cache.has('button/button.ts')).toBe(true);
|
|
120
|
+
expect(cache.get('button/button.ts')).toBe('// remote');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// classifyComponent — peer-file decoupling
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
describe('classifyComponent', () => {
|
|
127
|
+
beforeEach(() => {
|
|
128
|
+
vi.restoreAllMocks();
|
|
129
|
+
mockedFs.existsSync.mockReturnValue(false);
|
|
130
|
+
});
|
|
131
|
+
it('returns "install" when component files are missing', async () => {
|
|
132
|
+
mockedFs.pathExists.mockResolvedValue(false);
|
|
133
|
+
const cache = new Map();
|
|
134
|
+
const peerFiles = new Set();
|
|
135
|
+
const result = await classifyComponent('badge', '/project/ui', { branch: 'master', remote: true }, '@/lib', cache, peerFiles);
|
|
136
|
+
expect(result).toBe('install');
|
|
137
|
+
});
|
|
138
|
+
it('returns "skip" when all component files are present and identical', async () => {
|
|
139
|
+
const content = '// badge content';
|
|
140
|
+
mockedFs.pathExists.mockResolvedValue(true);
|
|
141
|
+
mockedFs.readFile.mockResolvedValue(content);
|
|
142
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(content, { status: 200 }));
|
|
143
|
+
const cache = new Map();
|
|
144
|
+
const peerFiles = new Set();
|
|
145
|
+
const result = await classifyComponent('badge', '/project/ui', { branch: 'master', remote: true }, '@/lib', cache, peerFiles);
|
|
146
|
+
expect(result).toBe('skip');
|
|
147
|
+
});
|
|
148
|
+
it('returns "conflict" when own files have changed', async () => {
|
|
149
|
+
mockedFs.pathExists.mockResolvedValue(true);
|
|
150
|
+
mockedFs.readFile.mockResolvedValue('// modified locally');
|
|
151
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('// original remote', { status: 200 }));
|
|
152
|
+
const cache = new Map();
|
|
153
|
+
const peerFiles = new Set();
|
|
154
|
+
const result = await classifyComponent('badge', '/project/ui', { branch: 'master', remote: true }, '@/lib', cache, peerFiles);
|
|
155
|
+
expect(result).toBe('conflict');
|
|
156
|
+
});
|
|
157
|
+
it('does NOT return "conflict" when only peer files have changed', async () => {
|
|
158
|
+
const componentWithPeers = Object.entries(registry).find(([, def]) => def.peerFiles && def.peerFiles.length > 0);
|
|
159
|
+
if (!componentWithPeers) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const [name, def] = componentWithPeers;
|
|
163
|
+
let readCallCount = 0;
|
|
164
|
+
mockedFs.pathExists.mockResolvedValue(true);
|
|
165
|
+
mockedFs.readFile.mockImplementation((() => {
|
|
166
|
+
readCallCount++;
|
|
167
|
+
if (readCallCount <= def.files.length) {
|
|
168
|
+
return Promise.resolve('// identical content');
|
|
169
|
+
}
|
|
170
|
+
return Promise.resolve('// modified peer file');
|
|
171
|
+
}));
|
|
172
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(() => Promise.resolve(new Response('// identical content', { status: 200 })));
|
|
173
|
+
const cache = new Map();
|
|
174
|
+
const peerFilesToUpdate = new Set();
|
|
175
|
+
const result = await classifyComponent(name, '/project/ui', { branch: 'master', remote: true }, '@/lib', cache, peerFilesToUpdate);
|
|
176
|
+
expect(result).toBe('skip');
|
|
177
|
+
expect(peerFilesToUpdate.size).toBeGreaterThan(0);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// detectConflicts
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
describe('detectConflicts', () => {
|
|
184
|
+
beforeEach(() => {
|
|
185
|
+
vi.restoreAllMocks();
|
|
186
|
+
mockedFs.existsSync.mockReturnValue(false);
|
|
187
|
+
});
|
|
188
|
+
it('classifies missing components as toInstall', async () => {
|
|
189
|
+
mockedFs.pathExists.mockResolvedValue(false);
|
|
190
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('// content', { status: 200 }));
|
|
191
|
+
const components = new Set(['badge']);
|
|
192
|
+
const result = await detectConflicts(components, '/project/ui', { branch: 'master', remote: true }, '@/lib');
|
|
193
|
+
expect(result.toInstall).toContain('badge');
|
|
194
|
+
expect(result.conflicting).not.toContain('badge');
|
|
195
|
+
expect(result.toSkip).not.toContain('badge');
|
|
196
|
+
});
|
|
197
|
+
it('classifies identical components as toSkip', async () => {
|
|
198
|
+
const content = '// badge content';
|
|
199
|
+
mockedFs.pathExists.mockResolvedValue(true);
|
|
200
|
+
mockedFs.readFile.mockResolvedValue(content);
|
|
201
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(content, { status: 200 }));
|
|
202
|
+
const components = new Set(['badge']);
|
|
203
|
+
const result = await detectConflicts(components, '/project/ui', { branch: 'master', remote: true }, '@/lib');
|
|
204
|
+
expect(result.toSkip).toContain('badge');
|
|
205
|
+
expect(result.toInstall).not.toContain('badge');
|
|
206
|
+
expect(result.conflicting).not.toContain('badge');
|
|
207
|
+
});
|
|
208
|
+
it('classifies changed components as conflicting', async () => {
|
|
209
|
+
mockedFs.pathExists.mockResolvedValue(true);
|
|
210
|
+
mockedFs.readFile.mockResolvedValue('// modified locally');
|
|
211
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('// remote content', { status: 200 }));
|
|
212
|
+
const components = new Set(['badge']);
|
|
213
|
+
const result = await detectConflicts(components, '/project/ui', { branch: 'master', remote: true }, '@/lib');
|
|
214
|
+
expect(result.conflicting).toContain('badge');
|
|
215
|
+
expect(result.toInstall).not.toContain('badge');
|
|
216
|
+
expect(result.toSkip).not.toContain('badge');
|
|
217
|
+
});
|
|
218
|
+
it('populates contentCache for files it checks', async () => {
|
|
219
|
+
mockedFs.pathExists.mockResolvedValue(true);
|
|
220
|
+
mockedFs.readFile.mockResolvedValue('// local');
|
|
221
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('// remote', { status: 200 }));
|
|
222
|
+
const components = new Set(['badge']);
|
|
223
|
+
const result = await detectConflicts(components, '/project/ui', { branch: 'master', remote: true }, '@/lib');
|
|
224
|
+
expect(result.contentCache.size).toBeGreaterThan(0);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// Peer file filtering after overwrite selection
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
describe('peer file filtering logic', () => {
|
|
231
|
+
it('removes peer files of declined components from peerFilesToUpdate', () => {
|
|
232
|
+
const peerFilesToUpdate = new Set(['shared/peer-a.ts', 'shared/peer-b.ts', 'shared/peer-c.ts']);
|
|
233
|
+
const conflicting = ['compA', 'compB'];
|
|
234
|
+
const toOverwrite = ['compA'];
|
|
235
|
+
const finalComponents = ['compA'];
|
|
236
|
+
const mockPeerFiles = {
|
|
237
|
+
compA: ['shared/peer-a.ts'],
|
|
238
|
+
compB: ['shared/peer-b.ts', 'shared/peer-c.ts'],
|
|
239
|
+
};
|
|
240
|
+
const declined = conflicting.filter(c => !toOverwrite.includes(c));
|
|
241
|
+
for (const name of declined) {
|
|
242
|
+
const peerFiles = mockPeerFiles[name];
|
|
243
|
+
if (!peerFiles)
|
|
244
|
+
continue;
|
|
245
|
+
for (const file of peerFiles) {
|
|
246
|
+
const stillNeeded = finalComponents.some(fc => mockPeerFiles[fc]?.includes(file));
|
|
247
|
+
if (!stillNeeded) {
|
|
248
|
+
peerFilesToUpdate.delete(file);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
expect(peerFilesToUpdate.has('shared/peer-a.ts')).toBe(true);
|
|
253
|
+
expect(peerFilesToUpdate.has('shared/peer-b.ts')).toBe(false);
|
|
254
|
+
expect(peerFilesToUpdate.has('shared/peer-c.ts')).toBe(false);
|
|
255
|
+
});
|
|
256
|
+
it('keeps shared peer files when another final component still needs them', () => {
|
|
257
|
+
const peerFilesToUpdate = new Set(['shared/common.ts']);
|
|
258
|
+
const conflicting = ['compA', 'compB'];
|
|
259
|
+
const toOverwrite = ['compA'];
|
|
260
|
+
const finalComponents = ['compA'];
|
|
261
|
+
const mockPeerFiles = {
|
|
262
|
+
compA: ['shared/common.ts'],
|
|
263
|
+
compB: ['shared/common.ts'],
|
|
264
|
+
};
|
|
265
|
+
const declined = conflicting.filter(c => !toOverwrite.includes(c));
|
|
266
|
+
for (const name of declined) {
|
|
267
|
+
const peerFiles = mockPeerFiles[name];
|
|
268
|
+
if (!peerFiles)
|
|
269
|
+
continue;
|
|
270
|
+
for (const file of peerFiles) {
|
|
271
|
+
const stillNeeded = finalComponents.some(fc => mockPeerFiles[fc]?.includes(file));
|
|
272
|
+
if (!stillNeeded) {
|
|
273
|
+
peerFilesToUpdate.delete(file);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
expect(peerFilesToUpdate.has('shared/common.ts')).toBe(true);
|
|
278
|
+
});
|
|
279
|
+
it('removes all peer files when all components are declined', () => {
|
|
280
|
+
const peerFilesToUpdate = new Set(['shared/peer-a.ts', 'shared/peer-b.ts']);
|
|
281
|
+
const conflicting = ['compA', 'compB'];
|
|
282
|
+
const toOverwrite = [];
|
|
283
|
+
const finalComponents = [];
|
|
284
|
+
const mockPeerFiles = {
|
|
285
|
+
compA: ['shared/peer-a.ts'],
|
|
286
|
+
compB: ['shared/peer-b.ts'],
|
|
287
|
+
};
|
|
288
|
+
const declined = conflicting.filter(c => !toOverwrite.includes(c));
|
|
289
|
+
for (const name of declined) {
|
|
290
|
+
const peerFiles = mockPeerFiles[name];
|
|
291
|
+
if (!peerFiles)
|
|
292
|
+
continue;
|
|
293
|
+
for (const file of peerFiles) {
|
|
294
|
+
const stillNeeded = finalComponents.some(fc => mockPeerFiles[fc]?.includes(file));
|
|
295
|
+
if (!stillNeeded) {
|
|
296
|
+
peerFilesToUpdate.delete(file);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
expect(peerFilesToUpdate.size).toBe(0);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
4
303
|
// ---------------------------------------------------------------------------
|
|
5
304
|
// resolveDependencies
|
|
6
305
|
// ---------------------------------------------------------------------------
|
|
@@ -11,13 +310,11 @@ describe('resolveDependencies', () => {
|
|
|
11
310
|
expect(result.size).toBe(1);
|
|
12
311
|
});
|
|
13
312
|
it('includes transitive dependencies', () => {
|
|
14
|
-
// button depends on ripple
|
|
15
313
|
const result = resolveDependencies(['button']);
|
|
16
314
|
expect(result).toContain('button');
|
|
17
315
|
expect(result).toContain('ripple');
|
|
18
316
|
});
|
|
19
317
|
it('resolves deep transitive chains', () => {
|
|
20
|
-
// date-picker -> calendar -> button -> ripple, calendar -> select
|
|
21
318
|
const result = resolveDependencies(['date-picker']);
|
|
22
319
|
expect(result).toContain('date-picker');
|
|
23
320
|
expect(result).toContain('calendar');
|
|
@@ -26,13 +323,11 @@ describe('resolveDependencies', () => {
|
|
|
26
323
|
expect(result).toContain('select');
|
|
27
324
|
});
|
|
28
325
|
it('deduplicates shared dependencies across multiple inputs', () => {
|
|
29
|
-
// Both button-group and speed-dial depend on button
|
|
30
326
|
const result = resolveDependencies(['button-group', 'speed-dial']);
|
|
31
327
|
expect(result).toContain('button-group');
|
|
32
328
|
expect(result).toContain('speed-dial');
|
|
33
329
|
expect(result).toContain('button');
|
|
34
330
|
expect(result).toContain('ripple');
|
|
35
|
-
// Count how many times button appears — should be exactly once (it's a Set)
|
|
36
331
|
const asArray = [...result];
|
|
37
332
|
expect(asArray.filter((n) => n === 'button')).toHaveLength(1);
|
|
38
333
|
});
|
|
@@ -49,9 +344,6 @@ describe('resolveDependencies', () => {
|
|
|
49
344
|
// ---------------------------------------------------------------------------
|
|
50
345
|
// promptOptionalDependencies
|
|
51
346
|
// ---------------------------------------------------------------------------
|
|
52
|
-
vi.mock('prompts', () => ({
|
|
53
|
-
default: vi.fn(),
|
|
54
|
-
}));
|
|
55
347
|
describe('promptOptionalDependencies', () => {
|
|
56
348
|
it('returns empty array when no components have optional deps', async () => {
|
|
57
349
|
const resolved = new Set(['badge', 'button', 'ripple']);
|
|
@@ -69,13 +361,11 @@ describe('promptOptionalDependencies', () => {
|
|
|
69
361
|
expect(result).toContain('context-menu');
|
|
70
362
|
});
|
|
71
363
|
it('filters out optional deps already in the resolved set', async () => {
|
|
72
|
-
// context-menu is already resolved, so it should not be offered
|
|
73
364
|
const resolved = new Set(['data-table', 'context-menu', 'table', 'input', 'button', 'ripple', 'checkbox', 'select', 'pagination', 'popover', 'component-outlet', 'icon']);
|
|
74
365
|
const result = await promptOptionalDependencies(resolved, { all: true, branch: 'master' });
|
|
75
366
|
expect(result).not.toContain('context-menu');
|
|
76
367
|
});
|
|
77
368
|
it('deduplicates optional deps across components', async () => {
|
|
78
|
-
// Both data-table and tree have context-menu as optional
|
|
79
369
|
const resolved = new Set(['data-table', 'tree', 'table', 'input', 'button', 'ripple', 'checkbox', 'select', 'pagination', 'popover', 'component-outlet', 'icon']);
|
|
80
370
|
const result = await promptOptionalDependencies(resolved, { all: true, branch: 'master' });
|
|
81
371
|
const contextMenuCount = result.filter((n) => n === 'context-menu').length;
|