@craft-ng/dev-tools 0.1.9 → 0.4.0-beta.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.
- package/README.md +96 -3
- package/package.json +1 -1
- package/src/eslint-rules/app-start-registry-match.cjs +357 -0
- package/src/eslint-rules/brand-angular-deps-match.cjs +214 -80
- package/src/eslint-rules/brand-angular-gen-deps-required.cjs +175 -0
- package/src/eslint-rules/index.cjs +10 -2
- package/src/eslint-rules/no-angular-inject.cjs +1 -78
- package/src/eslint-rules/no-angular-provide-app-initializer.cjs +79 -0
- package/src/eslint-rules/prefer-browser-boundaries.cjs +92 -0
- package/src/eslint-rules/prefer-craft-http-client.cjs +120 -0
- package/src/eslint-rules/prefer-craft-service.cjs +140 -0
- package/src/scripts/angular-brand-codemod.d.ts +25 -0
- package/src/scripts/angular-brand-codemod.js +450 -49
- package/src/scripts/angular-brand-codemod.js.map +1 -1
- package/src/scripts/angular-brand-codemod.spec.ts +716 -3
- package/src/scripts/angular-brand-codemod.ts +904 -61
- package/src/eslint-rules/no-direct-angular-class-export.cjs +0 -240
|
@@ -3,7 +3,11 @@ import { tmpdir } from 'node:os';
|
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
5
|
import { Project } from 'ts-morph';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
discoverAngularBrandConfigFilePath,
|
|
8
|
+
loadAngularBrandConfigFromFile,
|
|
9
|
+
transformSourceFile,
|
|
10
|
+
} from './angular-brand-codemod';
|
|
7
11
|
|
|
8
12
|
const tempDirectories: string[] = [];
|
|
9
13
|
|
|
@@ -85,7 +89,10 @@ describe('angular-brand-codemod', () => {
|
|
|
85
89
|
'publicProperties: GetPublicComponentProperties<ParentComponent>;',
|
|
86
90
|
);
|
|
87
91
|
expect(output).toContain('GenDeps_ChildComponent: GenDeps_ChildComponent;');
|
|
92
|
+
expect(output).toContain('propertiesDeps: {');
|
|
93
|
+
expect(output).toContain('api: {');
|
|
88
94
|
expect(output).toContain('ApiService: ApiService;');
|
|
95
|
+
expect(output).toContain('http: {');
|
|
89
96
|
expect(output).toContain('HttpClient: HttpClient;');
|
|
90
97
|
expect(output).toMatch(/provided: \{\s+UserStore: UserStore;\s+\};/m);
|
|
91
98
|
expect(output).toMatch(
|
|
@@ -132,6 +139,7 @@ describe('angular-brand-codemod', () => {
|
|
|
132
139
|
expect(output).not.toContain('deps({');
|
|
133
140
|
expect(output).toContain('export default class DemoComponent {}');
|
|
134
141
|
expect(output).toContain('export type GenDeps_DemoComponent = GetDeps<{');
|
|
142
|
+
expect(output).toContain('propertiesDeps: {};');
|
|
135
143
|
expect(output).toContain(
|
|
136
144
|
'publicProperties: GetPublicComponentProperties<DemoComponent>;',
|
|
137
145
|
);
|
|
@@ -172,6 +180,8 @@ describe('angular-brand-codemod', () => {
|
|
|
172
180
|
transformSourceFile(sourceFile);
|
|
173
181
|
const output = sourceFile.getFullText();
|
|
174
182
|
|
|
183
|
+
expect(output).toContain('propertiesDeps: {');
|
|
184
|
+
expect(output).toContain('store: {');
|
|
175
185
|
expect(output).toContain('UserStore: ReturnType<typeof injectUserStore>;');
|
|
176
186
|
expect(output).toContain('UserStore: ReturnType<typeof provideUserStore>;');
|
|
177
187
|
expect(output).not.toContain('missingProvider: {};');
|
|
@@ -195,6 +205,7 @@ describe('angular-brand-codemod', () => {
|
|
|
195
205
|
export declare function craftService(...args: any[]): any;
|
|
196
206
|
export declare function toCraftService(...args: any[]): any;
|
|
197
207
|
export type DerivedService<T, U> = T & U;
|
|
208
|
+
export type ExtractDeps<T> = T;
|
|
198
209
|
export type GetDeps<T> = T;
|
|
199
210
|
export type GetInjectedServiceDependencies<T> = T;
|
|
200
211
|
export type GetPublicComponentProperties<T> = T;
|
|
@@ -241,20 +252,644 @@ describe('angular-brand-codemod', () => {
|
|
|
241
252
|
transformSourceFile(sourceFile);
|
|
242
253
|
const output = sourceFile.getFullText();
|
|
243
254
|
|
|
255
|
+
expect(output).toContain('propertiesDeps: {');
|
|
256
|
+
expect(output).toContain('userId: ExtractDeps<DemoComponent["userId"]>;');
|
|
257
|
+
expect(output).toContain('apiService: {');
|
|
244
258
|
expect(output).toContain(
|
|
245
|
-
'ApiService:
|
|
259
|
+
'ApiService: ExtractDeps<typeof injectApiService>["ApiService"];',
|
|
246
260
|
);
|
|
247
261
|
expect(output).toContain(
|
|
248
262
|
'publicProperties: GetPublicComponentProperties<DemoComponent>;',
|
|
249
263
|
);
|
|
250
264
|
expect(output).toContain(
|
|
251
|
-
'Router: DerivedService<
|
|
265
|
+
'Router: DerivedService<ExtractDeps<typeof injectRouter>["Router"], {',
|
|
252
266
|
);
|
|
253
267
|
expect(output).toContain(
|
|
254
268
|
`navigate: GetServiceOutput<typeof injectRouter>["navigate"];`,
|
|
255
269
|
);
|
|
256
270
|
expect(output).not.toContain('missingProvider: {};');
|
|
257
271
|
});
|
|
272
|
+
|
|
273
|
+
it('adds Router to generated deps when RouterModule is used in component imports', async () => {
|
|
274
|
+
const project = await createRouterMetadataProjectFixture({
|
|
275
|
+
'src/app/demo.ts': `
|
|
276
|
+
import { Component } from '@angular/core';
|
|
277
|
+
import { RouterModule } from '@angular/router';
|
|
278
|
+
|
|
279
|
+
@Component({
|
|
280
|
+
standalone: true,
|
|
281
|
+
imports: [RouterModule],
|
|
282
|
+
template: '',
|
|
283
|
+
})
|
|
284
|
+
export class DemoComponent {}
|
|
285
|
+
`,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const sourceFile = getFixtureSourceFile(project, 'src/app/demo.ts');
|
|
289
|
+
transformSourceFile(sourceFile);
|
|
290
|
+
const output = sourceFile.getFullText();
|
|
291
|
+
|
|
292
|
+
expect(output).toMatch(
|
|
293
|
+
/import \{[^}]*type Router[^}]*\} from ['"]@angular\/router['"];/,
|
|
294
|
+
);
|
|
295
|
+
expect(output).toContain('RouterModule: RouterModule;');
|
|
296
|
+
expect(output).toContain('Router: Router;');
|
|
297
|
+
expect(output).toMatch(/missingProvider: \{[\s\S]*Router: Router;/);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('adds Router to generated deps when standalone router declarables are used in metadata imports', async () => {
|
|
301
|
+
const project = await createRouterMetadataProjectFixture({
|
|
302
|
+
'src/app/demo.ts': `
|
|
303
|
+
import { Component } from '@angular/core';
|
|
304
|
+
import { RouterOutlet } from '@angular/router';
|
|
305
|
+
|
|
306
|
+
@Component({
|
|
307
|
+
standalone: true,
|
|
308
|
+
imports: [RouterOutlet],
|
|
309
|
+
template: '',
|
|
310
|
+
})
|
|
311
|
+
export class DemoComponent {}
|
|
312
|
+
`,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const sourceFile = getFixtureSourceFile(project, 'src/app/demo.ts');
|
|
316
|
+
transformSourceFile(sourceFile);
|
|
317
|
+
const output = sourceFile.getFullText();
|
|
318
|
+
|
|
319
|
+
expect(output).toContain('RouterOutlet: RouterOutlet;');
|
|
320
|
+
expect(output).toContain('Router: Router;');
|
|
321
|
+
expect(output).toMatch(/missingProvider: \{[\s\S]*Router: Router;/);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('emits Router only once when multiple router metadata imports are present', async () => {
|
|
325
|
+
const project = await createRouterMetadataProjectFixture({
|
|
326
|
+
'src/app/demo.ts': `
|
|
327
|
+
import { Component } from '@angular/core';
|
|
328
|
+
import { RouterModule, RouterOutlet } from '@angular/router';
|
|
329
|
+
|
|
330
|
+
@Component({
|
|
331
|
+
standalone: true,
|
|
332
|
+
imports: [RouterModule, RouterOutlet],
|
|
333
|
+
template: '',
|
|
334
|
+
})
|
|
335
|
+
export class DemoComponent {}
|
|
336
|
+
`,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const sourceFile = getFixtureSourceFile(project, 'src/app/demo.ts');
|
|
340
|
+
transformSourceFile(sourceFile);
|
|
341
|
+
const output = sourceFile.getFullText();
|
|
342
|
+
|
|
343
|
+
expect(output).toContain('RouterModule: RouterModule;');
|
|
344
|
+
expect(output).toContain('RouterOutlet: RouterOutlet;');
|
|
345
|
+
expect(output.match(/Router: Router;/g) ?? []).toHaveLength(2);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('does not add Router for non-router metadata imports', async () => {
|
|
349
|
+
const project = await createRouterMetadataProjectFixture({
|
|
350
|
+
'src/app/child.ts': `
|
|
351
|
+
import { Component } from '@angular/core';
|
|
352
|
+
|
|
353
|
+
@Component({
|
|
354
|
+
standalone: true,
|
|
355
|
+
template: '',
|
|
356
|
+
})
|
|
357
|
+
export class ChildComponent {}
|
|
358
|
+
`,
|
|
359
|
+
'src/app/demo.ts': `
|
|
360
|
+
import { Component } from '@angular/core';
|
|
361
|
+
import { ChildComponent } from './child';
|
|
362
|
+
|
|
363
|
+
@Component({
|
|
364
|
+
standalone: true,
|
|
365
|
+
imports: [ChildComponent],
|
|
366
|
+
template: '',
|
|
367
|
+
})
|
|
368
|
+
export class DemoComponent {}
|
|
369
|
+
`,
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
transformSourceFile(getFixtureSourceFile(project, 'src/app/child.ts'));
|
|
373
|
+
const sourceFile = getFixtureSourceFile(project, 'src/app/demo.ts');
|
|
374
|
+
transformSourceFile(sourceFile);
|
|
375
|
+
const output = sourceFile.getFullText();
|
|
376
|
+
|
|
377
|
+
expect(output).toContain('GenDeps_ChildComponent: GenDeps_ChildComponent;');
|
|
378
|
+
expect(output).not.toContain('Router: Router;');
|
|
379
|
+
expect(output).not.toContain('type Router');
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('suppresses missingProvider.Router when Router is already provided locally', async () => {
|
|
383
|
+
const project = await createRouterMetadataProjectFixture({
|
|
384
|
+
'src/app/demo.ts': `
|
|
385
|
+
import { Component } from '@angular/core';
|
|
386
|
+
import { Router, RouterModule } from '@angular/router';
|
|
387
|
+
|
|
388
|
+
@Component({
|
|
389
|
+
standalone: true,
|
|
390
|
+
imports: [RouterModule],
|
|
391
|
+
providers: [Router],
|
|
392
|
+
template: '',
|
|
393
|
+
})
|
|
394
|
+
export class DemoComponent {}
|
|
395
|
+
`,
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const sourceFile = getFixtureSourceFile(project, 'src/app/demo.ts');
|
|
399
|
+
transformSourceFile(sourceFile);
|
|
400
|
+
const output = sourceFile.getFullText();
|
|
401
|
+
|
|
402
|
+
expect(output).toMatch(
|
|
403
|
+
/import \{[^}]*Router[^}]*\} from ['"]@angular\/router['"];/,
|
|
404
|
+
);
|
|
405
|
+
expect(output).not.toContain('type Router');
|
|
406
|
+
expect(output).toContain('Router: Router;');
|
|
407
|
+
expect(output).toMatch(/provided: \{[\s\S]*Router: Router;/);
|
|
408
|
+
expect(output).not.toMatch(/missingProvider: \{[\s\S]*Router: Router;/);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('emits FormField<any> when FormField is imported in metadata imports', async () => {
|
|
412
|
+
const project = await createSignalFormsMetadataProjectFixture({
|
|
413
|
+
'src/app/status.component.ts': `
|
|
414
|
+
import { Component } from '@angular/core';
|
|
415
|
+
|
|
416
|
+
@Component({
|
|
417
|
+
standalone: true,
|
|
418
|
+
template: '',
|
|
419
|
+
})
|
|
420
|
+
export class StatusComponent {}
|
|
421
|
+
`,
|
|
422
|
+
'src/app/demo.ts': `
|
|
423
|
+
import { CommonModule } from '@angular/common';
|
|
424
|
+
import { Component } from '@angular/core';
|
|
425
|
+
import { FormField } from '@angular/forms/signals';
|
|
426
|
+
import { StatusComponent } from './status.component';
|
|
427
|
+
|
|
428
|
+
@Component({
|
|
429
|
+
standalone: true,
|
|
430
|
+
imports: [CommonModule, StatusComponent, FormField],
|
|
431
|
+
template: '',
|
|
432
|
+
})
|
|
433
|
+
export class DemoComponent {}
|
|
434
|
+
`,
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
transformSourceFile(
|
|
438
|
+
getFixtureSourceFile(project, 'src/app/status.component.ts'),
|
|
439
|
+
);
|
|
440
|
+
const sourceFile = getFixtureSourceFile(project, 'src/app/demo.ts');
|
|
441
|
+
transformSourceFile(sourceFile);
|
|
442
|
+
const output = sourceFile.getFullText();
|
|
443
|
+
const depsSection = extractGeneratedSection(output, 'deps');
|
|
444
|
+
|
|
445
|
+
expect(depsSection).toContain('CommonModule: CommonModule;');
|
|
446
|
+
expect(depsSection).toContain(
|
|
447
|
+
'GenDeps_StatusComponent: GenDeps_StatusComponent;',
|
|
448
|
+
);
|
|
449
|
+
expect(depsSection).toContain('FormField: FormField<any>;');
|
|
450
|
+
expect(depsSection).toMatch(
|
|
451
|
+
/CommonModule: CommonModule;[\s\S]*GenDeps_StatusComponent: GenDeps_StatusComponent;[\s\S]*FormField: FormField<any>;/,
|
|
452
|
+
);
|
|
453
|
+
expect(output).not.toContain('type FormField');
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('applies inline project config rules for metadata imports', async () => {
|
|
457
|
+
const project = await createTranslateProjectFixture({
|
|
458
|
+
'src/app/demo.ts': `
|
|
459
|
+
import { Component } from '@angular/core';
|
|
460
|
+
import { TranslatePipe } from '@ngx-translate/core';
|
|
461
|
+
|
|
462
|
+
@Component({
|
|
463
|
+
standalone: true,
|
|
464
|
+
imports: [TranslatePipe],
|
|
465
|
+
template: '',
|
|
466
|
+
})
|
|
467
|
+
export class DemoComponent {}
|
|
468
|
+
`,
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
const sourceFile = getFixtureSourceFile(project, 'src/app/demo.ts');
|
|
472
|
+
transformSourceFile(sourceFile, {
|
|
473
|
+
config: {
|
|
474
|
+
importAugmentations: [
|
|
475
|
+
{
|
|
476
|
+
match: {
|
|
477
|
+
module: '@ngx-translate/core',
|
|
478
|
+
symbols: ['TranslatePipe'],
|
|
479
|
+
metadata: ['imports'],
|
|
480
|
+
},
|
|
481
|
+
deps: [{ key: 'TranslateService', symbol: 'TranslateService' }],
|
|
482
|
+
missingProvider: [
|
|
483
|
+
{ key: 'TranslateService', symbol: 'TranslateService' },
|
|
484
|
+
],
|
|
485
|
+
},
|
|
486
|
+
],
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
const output = sourceFile.getFullText();
|
|
490
|
+
|
|
491
|
+
expect(output).toMatch(
|
|
492
|
+
/import \{[^}]*TranslatePipe[^}]*type TranslateService[^}]*\} from ['"]@ngx-translate\/core['"];/,
|
|
493
|
+
);
|
|
494
|
+
expect(extractGeneratedSection(output, 'deps')).toContain(
|
|
495
|
+
'TranslateService: TranslateService;',
|
|
496
|
+
);
|
|
497
|
+
expect(extractGeneratedSection(output, 'missingProvider')).toContain(
|
|
498
|
+
'TranslateService: TranslateService;',
|
|
499
|
+
);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('auto-discovers craft-brand.config.ts from the source file directory', async () => {
|
|
503
|
+
const project = await createTranslateProjectFixture({
|
|
504
|
+
'craft-brand.config.ts': `
|
|
505
|
+
import { defineAngularBrandConfig } from '@craft-ng/dev-tools';
|
|
506
|
+
|
|
507
|
+
export default defineAngularBrandConfig({
|
|
508
|
+
importAugmentations: [
|
|
509
|
+
{
|
|
510
|
+
match: {
|
|
511
|
+
module: '@ngx-translate/core',
|
|
512
|
+
symbols: ['TranslatePipe'],
|
|
513
|
+
metadata: ['imports'],
|
|
514
|
+
},
|
|
515
|
+
deps: [{ key: 'TranslateService', symbol: 'TranslateService' }],
|
|
516
|
+
missingProvider: [
|
|
517
|
+
{ key: 'TranslateService', symbol: 'TranslateService' },
|
|
518
|
+
],
|
|
519
|
+
},
|
|
520
|
+
],
|
|
521
|
+
});
|
|
522
|
+
`,
|
|
523
|
+
'src/app/demo.ts': `
|
|
524
|
+
import { Component } from '@angular/core';
|
|
525
|
+
import { TranslatePipe } from '@ngx-translate/core';
|
|
526
|
+
|
|
527
|
+
@Component({
|
|
528
|
+
standalone: true,
|
|
529
|
+
imports: [TranslatePipe],
|
|
530
|
+
template: '',
|
|
531
|
+
})
|
|
532
|
+
export class DemoComponent {}
|
|
533
|
+
`,
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
const rootDirectory = tempDirectories[tempDirectories.length - 1]!;
|
|
537
|
+
const sourceFile = getFixtureSourceFile(project, 'src/app/demo.ts');
|
|
538
|
+
const discoveredPath = discoverAngularBrandConfigFilePath(
|
|
539
|
+
join(rootDirectory, 'src/app'),
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
expect(discoveredPath).toBe(join(rootDirectory, 'craft-brand.config.ts'));
|
|
543
|
+
|
|
544
|
+
transformSourceFile(sourceFile);
|
|
545
|
+
const output = sourceFile.getFullText();
|
|
546
|
+
|
|
547
|
+
expect(extractGeneratedSection(output, 'deps')).toContain(
|
|
548
|
+
'TranslateService: TranslateService;',
|
|
549
|
+
);
|
|
550
|
+
expect(extractGeneratedSection(output, 'missingProvider')).toContain(
|
|
551
|
+
'TranslateService: TranslateService;',
|
|
552
|
+
);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it('does not trigger project config rules for plain TS imports outside metadata', async () => {
|
|
556
|
+
const project = await createTranslateProjectFixture({
|
|
557
|
+
'craft-brand.config.ts': `
|
|
558
|
+
import { defineAngularBrandConfig } from '@craft-ng/dev-tools';
|
|
559
|
+
|
|
560
|
+
export default defineAngularBrandConfig({
|
|
561
|
+
importAugmentations: [
|
|
562
|
+
{
|
|
563
|
+
match: {
|
|
564
|
+
module: '@ngx-translate/core',
|
|
565
|
+
symbols: ['TranslatePipe'],
|
|
566
|
+
metadata: ['imports'],
|
|
567
|
+
},
|
|
568
|
+
deps: [{ key: 'TranslateService', symbol: 'TranslateService' }],
|
|
569
|
+
missingProvider: [
|
|
570
|
+
{ key: 'TranslateService', symbol: 'TranslateService' },
|
|
571
|
+
],
|
|
572
|
+
},
|
|
573
|
+
],
|
|
574
|
+
});
|
|
575
|
+
`,
|
|
576
|
+
'src/app/demo.ts': `
|
|
577
|
+
import { Component } from '@angular/core';
|
|
578
|
+
import { TranslatePipe } from '@ngx-translate/core';
|
|
579
|
+
|
|
580
|
+
@Component({
|
|
581
|
+
standalone: true,
|
|
582
|
+
imports: [],
|
|
583
|
+
template: '',
|
|
584
|
+
})
|
|
585
|
+
export class DemoComponent {
|
|
586
|
+
protected readonly unused = TranslatePipe;
|
|
587
|
+
}
|
|
588
|
+
`,
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
const sourceFile = getFixtureSourceFile(project, 'src/app/demo.ts');
|
|
592
|
+
transformSourceFile(sourceFile);
|
|
593
|
+
const output = sourceFile.getFullText();
|
|
594
|
+
|
|
595
|
+
expect(extractGeneratedSection(output, 'deps')).not.toContain(
|
|
596
|
+
'TranslateService: TranslateService;',
|
|
597
|
+
);
|
|
598
|
+
expect(output).not.toContain('type TranslateService');
|
|
599
|
+
expect(extractGeneratedSection(output, 'missingProvider')).toBe('');
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it('supports project config rules for hostDirectives metadata', async () => {
|
|
603
|
+
const project = await createProjectFixture({
|
|
604
|
+
'src/angular-core.d.ts': `
|
|
605
|
+
declare module '@angular/core' {
|
|
606
|
+
export declare function Component(metadata: unknown): ClassDecorator;
|
|
607
|
+
export declare function Directive(metadata?: unknown): ClassDecorator;
|
|
608
|
+
}
|
|
609
|
+
`,
|
|
610
|
+
'src/acme-host.d.ts': `
|
|
611
|
+
declare module '@acme/host' {
|
|
612
|
+
export declare class HostFeatureDirective {}
|
|
613
|
+
export declare class HostFeatureService {}
|
|
614
|
+
}
|
|
615
|
+
`,
|
|
616
|
+
'craft-brand.config.ts': `
|
|
617
|
+
import { defineAngularBrandConfig } from '@craft-ng/dev-tools';
|
|
618
|
+
|
|
619
|
+
export default defineAngularBrandConfig({
|
|
620
|
+
importAugmentations: [
|
|
621
|
+
{
|
|
622
|
+
match: {
|
|
623
|
+
module: '@acme/host',
|
|
624
|
+
symbols: ['HostFeatureDirective'],
|
|
625
|
+
metadata: ['hostDirectives'],
|
|
626
|
+
},
|
|
627
|
+
deps: [{ key: 'HostFeatureService', symbol: 'HostFeatureService' }],
|
|
628
|
+
missingProvider: [
|
|
629
|
+
{ key: 'HostFeatureService', symbol: 'HostFeatureService' },
|
|
630
|
+
],
|
|
631
|
+
},
|
|
632
|
+
],
|
|
633
|
+
});
|
|
634
|
+
`,
|
|
635
|
+
'src/app/demo.ts': `
|
|
636
|
+
import { Component } from '@angular/core';
|
|
637
|
+
import { HostFeatureDirective } from '@acme/host';
|
|
638
|
+
|
|
639
|
+
@Component({
|
|
640
|
+
standalone: true,
|
|
641
|
+
hostDirectives: [HostFeatureDirective],
|
|
642
|
+
template: '',
|
|
643
|
+
})
|
|
644
|
+
export class DemoComponent {}
|
|
645
|
+
`,
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
const sourceFile = getFixtureSourceFile(project, 'src/app/demo.ts');
|
|
649
|
+
transformSourceFile(sourceFile);
|
|
650
|
+
const output = sourceFile.getFullText();
|
|
651
|
+
|
|
652
|
+
expect(extractGeneratedSection(output, 'deps')).toContain(
|
|
653
|
+
'HostFeatureService: HostFeatureService;',
|
|
654
|
+
);
|
|
655
|
+
expect(extractGeneratedSection(output, 'missingProvider')).toContain(
|
|
656
|
+
'HostFeatureService: HostFeatureService;',
|
|
657
|
+
);
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it('deduplicates project config entries when several metadata symbols match the same rule', async () => {
|
|
661
|
+
const project = await createTranslateProjectFixture({
|
|
662
|
+
'craft-brand.config.ts': `
|
|
663
|
+
import { defineAngularBrandConfig } from '@craft-ng/dev-tools';
|
|
664
|
+
|
|
665
|
+
export default defineAngularBrandConfig({
|
|
666
|
+
importAugmentations: [
|
|
667
|
+
{
|
|
668
|
+
match: {
|
|
669
|
+
module: '@ngx-translate/core',
|
|
670
|
+
metadata: ['imports', 'hostDirectives'],
|
|
671
|
+
},
|
|
672
|
+
deps: [{ key: 'TranslateService', symbol: 'TranslateService' }],
|
|
673
|
+
missingProvider: [
|
|
674
|
+
{ key: 'TranslateService', symbol: 'TranslateService' },
|
|
675
|
+
],
|
|
676
|
+
},
|
|
677
|
+
],
|
|
678
|
+
});
|
|
679
|
+
`,
|
|
680
|
+
'src/app/demo.ts': `
|
|
681
|
+
import { Component } from '@angular/core';
|
|
682
|
+
import {
|
|
683
|
+
TranslateDirective,
|
|
684
|
+
TranslatePipe,
|
|
685
|
+
} from '@ngx-translate/core';
|
|
686
|
+
|
|
687
|
+
@Component({
|
|
688
|
+
standalone: true,
|
|
689
|
+
imports: [TranslatePipe],
|
|
690
|
+
hostDirectives: [TranslateDirective],
|
|
691
|
+
template: '',
|
|
692
|
+
})
|
|
693
|
+
export class DemoComponent {}
|
|
694
|
+
`,
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
const sourceFile = getFixtureSourceFile(project, 'src/app/demo.ts');
|
|
698
|
+
transformSourceFile(sourceFile);
|
|
699
|
+
const output = sourceFile.getFullText();
|
|
700
|
+
|
|
701
|
+
expect(
|
|
702
|
+
output.match(/TranslateService: TranslateService;/g) ?? [],
|
|
703
|
+
).toHaveLength(2);
|
|
704
|
+
expect(output.match(/type TranslateService/g) ?? []).toHaveLength(1);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
it('removes configured missing providers when the key is already provided locally', async () => {
|
|
708
|
+
const project = await createTranslateProjectFixture({
|
|
709
|
+
'craft-brand.config.ts': `
|
|
710
|
+
import { defineAngularBrandConfig } from '@craft-ng/dev-tools';
|
|
711
|
+
|
|
712
|
+
export default defineAngularBrandConfig({
|
|
713
|
+
importAugmentations: [
|
|
714
|
+
{
|
|
715
|
+
match: {
|
|
716
|
+
module: '@ngx-translate/core',
|
|
717
|
+
symbols: ['TranslatePipe'],
|
|
718
|
+
metadata: ['imports'],
|
|
719
|
+
},
|
|
720
|
+
deps: [{ key: 'TranslateService', symbol: 'TranslateService' }],
|
|
721
|
+
missingProvider: [
|
|
722
|
+
{ key: 'TranslateService', symbol: 'TranslateService' },
|
|
723
|
+
],
|
|
724
|
+
},
|
|
725
|
+
],
|
|
726
|
+
});
|
|
727
|
+
`,
|
|
728
|
+
'src/app/demo.ts': `
|
|
729
|
+
import { Component } from '@angular/core';
|
|
730
|
+
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
|
731
|
+
|
|
732
|
+
@Component({
|
|
733
|
+
standalone: true,
|
|
734
|
+
imports: [TranslatePipe],
|
|
735
|
+
providers: [TranslateService],
|
|
736
|
+
template: '',
|
|
737
|
+
})
|
|
738
|
+
export class DemoComponent {}
|
|
739
|
+
`,
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
const sourceFile = getFixtureSourceFile(project, 'src/app/demo.ts');
|
|
743
|
+
transformSourceFile(sourceFile);
|
|
744
|
+
const output = sourceFile.getFullText();
|
|
745
|
+
|
|
746
|
+
expect(extractGeneratedSection(output, 'deps')).toContain(
|
|
747
|
+
'TranslateService: TranslateService;',
|
|
748
|
+
);
|
|
749
|
+
expect(extractGeneratedSection(output, 'provided')).toContain(
|
|
750
|
+
'TranslateService: TranslateService;',
|
|
751
|
+
);
|
|
752
|
+
expect(extractGeneratedSection(output, 'missingProvider')).not.toContain(
|
|
753
|
+
'TranslateService: TranslateService;',
|
|
754
|
+
);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it('adds type imports for configured missingProvider-only entries', async () => {
|
|
758
|
+
const project = await createTranslateProjectFixture({
|
|
759
|
+
'src/app/demo.ts': `
|
|
760
|
+
import { Component } from '@angular/core';
|
|
761
|
+
import { TranslatePipe } from '@ngx-translate/core';
|
|
762
|
+
|
|
763
|
+
@Component({
|
|
764
|
+
standalone: true,
|
|
765
|
+
imports: [TranslatePipe],
|
|
766
|
+
template: '',
|
|
767
|
+
})
|
|
768
|
+
export class DemoComponent {}
|
|
769
|
+
`,
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
const sourceFile = getFixtureSourceFile(project, 'src/app/demo.ts');
|
|
773
|
+
transformSourceFile(sourceFile, {
|
|
774
|
+
config: {
|
|
775
|
+
importAugmentations: [
|
|
776
|
+
{
|
|
777
|
+
match: {
|
|
778
|
+
module: '@ngx-translate/core',
|
|
779
|
+
symbols: ['TranslatePipe'],
|
|
780
|
+
metadata: ['imports'],
|
|
781
|
+
},
|
|
782
|
+
missingProvider: [
|
|
783
|
+
{ key: 'TranslateService', symbol: 'TranslateService' },
|
|
784
|
+
],
|
|
785
|
+
},
|
|
786
|
+
],
|
|
787
|
+
},
|
|
788
|
+
});
|
|
789
|
+
const output = sourceFile.getFullText();
|
|
790
|
+
|
|
791
|
+
expect(output).toMatch(
|
|
792
|
+
/import \{[^}]*TranslatePipe[^}]*type TranslateService[^}]*\} from ['"]@ngx-translate\/core['"];/,
|
|
793
|
+
);
|
|
794
|
+
expect(extractGeneratedSection(output, 'deps')).not.toContain(
|
|
795
|
+
'TranslateService: TranslateService;',
|
|
796
|
+
);
|
|
797
|
+
expect(extractGeneratedSection(output, 'missingProvider')).toContain(
|
|
798
|
+
'TranslateService: TranslateService;',
|
|
799
|
+
);
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
it('throws a clear error for invalid project config files', async () => {
|
|
803
|
+
const project = await createTranslateProjectFixture({
|
|
804
|
+
'craft-brand.config.ts': `
|
|
805
|
+
export default 42;
|
|
806
|
+
`,
|
|
807
|
+
'src/app/demo.ts': `
|
|
808
|
+
import { Component } from '@angular/core';
|
|
809
|
+
import { TranslatePipe } from '@ngx-translate/core';
|
|
810
|
+
|
|
811
|
+
@Component({
|
|
812
|
+
standalone: true,
|
|
813
|
+
imports: [TranslatePipe],
|
|
814
|
+
template: '',
|
|
815
|
+
})
|
|
816
|
+
export class DemoComponent {}
|
|
817
|
+
`,
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
const sourceFile = getFixtureSourceFile(project, 'src/app/demo.ts');
|
|
821
|
+
|
|
822
|
+
expect(() => transformSourceFile(sourceFile)).toThrow(
|
|
823
|
+
/Invalid Angular brand config at ".*craft-brand\.config\.ts": Expected the default export to be an object\./,
|
|
824
|
+
);
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
it('prefers an explicit configFilePath over auto-discovery', async () => {
|
|
828
|
+
const project = await createTranslateProjectFixture({
|
|
829
|
+
'craft-brand.config.ts': `
|
|
830
|
+
import { defineAngularBrandConfig } from '@craft-ng/dev-tools';
|
|
831
|
+
|
|
832
|
+
export default defineAngularBrandConfig({
|
|
833
|
+
importAugmentations: [
|
|
834
|
+
{
|
|
835
|
+
match: {
|
|
836
|
+
module: '@ngx-translate/core',
|
|
837
|
+
symbols: ['TranslatePipe'],
|
|
838
|
+
metadata: ['imports'],
|
|
839
|
+
},
|
|
840
|
+
deps: [{ key: 'WrongService', symbol: 'WrongService' }],
|
|
841
|
+
},
|
|
842
|
+
],
|
|
843
|
+
});
|
|
844
|
+
`,
|
|
845
|
+
'custom-brand.config.ts': `
|
|
846
|
+
import { defineAngularBrandConfig } from '@craft-ng/dev-tools';
|
|
847
|
+
|
|
848
|
+
export default defineAngularBrandConfig({
|
|
849
|
+
importAugmentations: [
|
|
850
|
+
{
|
|
851
|
+
match: {
|
|
852
|
+
module: '@ngx-translate/core',
|
|
853
|
+
symbols: ['TranslatePipe'],
|
|
854
|
+
metadata: ['imports'],
|
|
855
|
+
},
|
|
856
|
+
deps: [{ key: 'TranslateService', symbol: 'TranslateService' }],
|
|
857
|
+
},
|
|
858
|
+
],
|
|
859
|
+
});
|
|
860
|
+
`,
|
|
861
|
+
'src/app/demo.ts': `
|
|
862
|
+
import { Component } from '@angular/core';
|
|
863
|
+
import { TranslatePipe } from '@ngx-translate/core';
|
|
864
|
+
|
|
865
|
+
@Component({
|
|
866
|
+
standalone: true,
|
|
867
|
+
imports: [TranslatePipe],
|
|
868
|
+
template: '',
|
|
869
|
+
})
|
|
870
|
+
export class DemoComponent {}
|
|
871
|
+
`,
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
const rootDirectory = tempDirectories[tempDirectories.length - 1]!;
|
|
875
|
+
const explicitConfig = loadAngularBrandConfigFromFile(
|
|
876
|
+
join(rootDirectory, 'custom-brand.config.ts'),
|
|
877
|
+
);
|
|
878
|
+
const sourceFile = getFixtureSourceFile(project, 'src/app/demo.ts');
|
|
879
|
+
|
|
880
|
+
transformSourceFile(sourceFile, {
|
|
881
|
+
configFilePath: join(rootDirectory, 'custom-brand.config.ts'),
|
|
882
|
+
});
|
|
883
|
+
const output = sourceFile.getFullText();
|
|
884
|
+
|
|
885
|
+
expect(explicitConfig.importAugmentations).toHaveLength(1);
|
|
886
|
+
expect(extractGeneratedSection(output, 'deps')).toContain(
|
|
887
|
+
'TranslateService: TranslateService;',
|
|
888
|
+
);
|
|
889
|
+
expect(extractGeneratedSection(output, 'deps')).not.toContain(
|
|
890
|
+
'WrongService: WrongService;',
|
|
891
|
+
);
|
|
892
|
+
});
|
|
258
893
|
});
|
|
259
894
|
|
|
260
895
|
async function createProjectFixture(
|
|
@@ -287,6 +922,73 @@ async function createProjectFixture(
|
|
|
287
922
|
});
|
|
288
923
|
}
|
|
289
924
|
|
|
925
|
+
async function createRouterMetadataProjectFixture(
|
|
926
|
+
files: Record<string, string>,
|
|
927
|
+
): Promise<Project> {
|
|
928
|
+
return createProjectFixture({
|
|
929
|
+
'src/angular-core.d.ts': `
|
|
930
|
+
declare module '@angular/core' {
|
|
931
|
+
export declare function Component(metadata: unknown): ClassDecorator;
|
|
932
|
+
}
|
|
933
|
+
`,
|
|
934
|
+
'src/angular-router.d.ts': `
|
|
935
|
+
declare module '@angular/router' {
|
|
936
|
+
export declare class Router {
|
|
937
|
+
navigate(commands: unknown[]): void;
|
|
938
|
+
}
|
|
939
|
+
export declare class RouterModule {}
|
|
940
|
+
export declare class RouterOutlet {}
|
|
941
|
+
}
|
|
942
|
+
`,
|
|
943
|
+
...files,
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
async function createTranslateProjectFixture(
|
|
948
|
+
files: Record<string, string>,
|
|
949
|
+
): Promise<Project> {
|
|
950
|
+
return createProjectFixture({
|
|
951
|
+
'src/angular-core.d.ts': `
|
|
952
|
+
declare module '@angular/core' {
|
|
953
|
+
export declare function Component(metadata: unknown): ClassDecorator;
|
|
954
|
+
export declare function Directive(metadata?: unknown): ClassDecorator;
|
|
955
|
+
}
|
|
956
|
+
`,
|
|
957
|
+
'src/ngx-translate.d.ts': `
|
|
958
|
+
declare module '@ngx-translate/core' {
|
|
959
|
+
export declare class TranslatePipe {}
|
|
960
|
+
export declare class TranslateDirective {}
|
|
961
|
+
export declare class TranslateService {}
|
|
962
|
+
export declare class WrongService {}
|
|
963
|
+
}
|
|
964
|
+
`,
|
|
965
|
+
...files,
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
async function createSignalFormsMetadataProjectFixture(
|
|
970
|
+
files: Record<string, string>,
|
|
971
|
+
): Promise<Project> {
|
|
972
|
+
return createProjectFixture({
|
|
973
|
+
'src/angular-common.d.ts': `
|
|
974
|
+
declare module '@angular/common' {
|
|
975
|
+
export declare class CommonModule {}
|
|
976
|
+
}
|
|
977
|
+
`,
|
|
978
|
+
'src/angular-core.d.ts': `
|
|
979
|
+
declare module '@angular/core' {
|
|
980
|
+
export declare function Component(metadata: unknown): ClassDecorator;
|
|
981
|
+
}
|
|
982
|
+
`,
|
|
983
|
+
'src/angular-forms.d.ts': `
|
|
984
|
+
declare module '@angular/forms/signals' {
|
|
985
|
+
export declare class FormField<T> {}
|
|
986
|
+
}
|
|
987
|
+
`,
|
|
988
|
+
...files,
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
|
|
290
992
|
async function writeFixtureFiles(
|
|
291
993
|
rootDirectory: string,
|
|
292
994
|
files: Record<string, string>,
|
|
@@ -303,3 +1005,14 @@ function getFixtureSourceFile(project: Project, relativePath: string) {
|
|
|
303
1005
|
.getSourceFiles()
|
|
304
1006
|
.find((sourceFile) => sourceFile.getFilePath().endsWith(relativePath))!;
|
|
305
1007
|
}
|
|
1008
|
+
|
|
1009
|
+
function extractGeneratedSection(
|
|
1010
|
+
output: string,
|
|
1011
|
+
sectionName: 'deps' | 'provided' | 'missingProvider',
|
|
1012
|
+
) {
|
|
1013
|
+
const match = output.match(
|
|
1014
|
+
new RegExp(`${sectionName}: \\{([\\s\\S]*?)\\n\\s+\\};`),
|
|
1015
|
+
);
|
|
1016
|
+
|
|
1017
|
+
return match?.[1] ?? '';
|
|
1018
|
+
}
|