@craft-ng/dev-tools 0.5.0-beta.4 → 0.5.1-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +132 -23
- package/package.json +6 -2
- package/src/bin/craft-migrate-primitives.d.ts +2 -0
- package/src/bin/craft-migrate-primitives.js +71 -0
- package/src/bin/craft-migrate-primitives.js.map +1 -0
- package/src/bin/craft-migrate-routes.d.ts +2 -0
- package/src/bin/craft-migrate-routes.js +77 -0
- package/src/bin/craft-migrate-routes.js.map +1 -0
- package/src/bin/craft-migrate-services.d.ts +2 -0
- package/src/bin/craft-migrate-services.js +75 -0
- package/src/bin/craft-migrate-services.js.map +1 -0
- package/src/bin/craft-migrate.d.ts +2 -0
- package/src/bin/craft-migrate.js +90 -0
- package/src/bin/craft-migrate.js.map +1 -0
- package/src/eslint-rules/global-exception-registry-match.cjs +4 -4
- package/src/eslint-rules/index.cjs +6 -0
- package/src/eslint-rules/require-craft-exception-handler.cjs +142 -0
- package/src/eslint-rules/require-exception-component-di-check.cjs +314 -0
- package/src/eslint-rules/require-lazy-load-with-retry.cjs +148 -0
- package/src/eslint-rules/require-pending-component-di-check.cjs +3 -3
- package/src/index.d.ts +8 -0
- package/src/index.js +8 -0
- package/src/index.js.map +1 -1
- package/src/scripts/angular-brand-codemod.d.ts +17 -0
- package/src/scripts/angular-brand-codemod.js +44 -8
- package/src/scripts/angular-brand-codemod.js.map +1 -1
- package/src/scripts/angular-brand-codemod.spec.ts +3 -3
- package/src/scripts/angular-brand-codemod.ts +78 -7
- package/src/scripts/migrate.d.ts +32 -0
- package/src/scripts/migrate.js +67 -0
- package/src/scripts/migrate.js.map +1 -0
- package/src/scripts/migrate.ts +128 -0
- package/src/scripts/migration-workspace.d.ts +2 -0
- package/src/scripts/migration-workspace.js +72 -0
- package/src/scripts/migration-workspace.js.map +1 -0
- package/src/scripts/migration-workspace.ts +93 -0
- package/src/scripts/primitives/migrate-primitives.d.ts +27 -0
- package/src/scripts/primitives/migrate-primitives.js +354 -0
- package/src/scripts/primitives/migrate-primitives.js.map +1 -0
- package/src/scripts/primitives/migrate-primitives.spec.ts +279 -0
- package/src/scripts/primitives/migrate-primitives.ts +543 -0
- package/src/scripts/primitives/migration-diagnostic.d.ts +8 -0
- package/src/scripts/primitives/migration-diagnostic.js +2 -0
- package/src/scripts/primitives/migration-diagnostic.js.map +1 -0
- package/src/scripts/primitives/migration-diagnostic.ts +13 -0
- package/src/scripts/routes/migrate-routes.d.ts +34 -0
- package/src/scripts/routes/migrate-routes.js +669 -0
- package/src/scripts/routes/migrate-routes.js.map +1 -0
- package/src/scripts/routes/migrate-routes.spec.ts +264 -0
- package/src/scripts/routes/migrate-routes.ts +897 -0
- package/src/scripts/routes/migration-diagnostic.d.ts +16 -0
- package/src/scripts/routes/migration-diagnostic.js +2 -0
- package/src/scripts/routes/migration-diagnostic.js.map +1 -0
- package/src/scripts/routes/migration-diagnostic.ts +25 -0
- package/src/scripts/services/config.d.ts +2 -0
- package/src/scripts/services/config.js +39 -0
- package/src/scripts/services/config.js.map +1 -0
- package/src/scripts/services/config.ts +60 -0
- package/src/scripts/services/migrate-services.d.ts +29 -0
- package/src/scripts/services/migrate-services.js +895 -0
- package/src/scripts/services/migrate-services.js.map +1 -0
- package/src/scripts/services/migrate-services.spec.ts +719 -0
- package/src/scripts/services/migrate-services.ts +1282 -0
- package/src/scripts/services/migration-diagnostic.d.ts +8 -0
- package/src/scripts/services/migration-diagnostic.js +2 -0
- package/src/scripts/services/migration-diagnostic.js.map +1 -0
- package/src/scripts/services/migration-diagnostic.ts +27 -0
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { loadAngularBrandConfigFromFile } from '../angular-brand-codemod';
|
|
6
|
+
import { runServicesMigration } from './migrate-services';
|
|
7
|
+
|
|
8
|
+
const temporaryDirectories: string[] = [];
|
|
9
|
+
|
|
10
|
+
afterEach(async () => {
|
|
11
|
+
await Promise.all(
|
|
12
|
+
temporaryDirectories
|
|
13
|
+
.splice(0)
|
|
14
|
+
.map((directory) => rm(directory, { recursive: true, force: true })),
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
async function fixture(files: Record<string, string>): Promise<string> {
|
|
19
|
+
const root = await mkdtemp(join(tmpdir(), 'craft-services-'));
|
|
20
|
+
temporaryDirectories.push(root);
|
|
21
|
+
await Promise.all(
|
|
22
|
+
Object.entries(files).map(async ([path, contents]) => {
|
|
23
|
+
const fullPath = join(root, path);
|
|
24
|
+
const { mkdir } = await import('node:fs/promises');
|
|
25
|
+
await mkdir(join(fullPath, '..'), { recursive: true });
|
|
26
|
+
await writeFile(fullPath, contents, 'utf8');
|
|
27
|
+
}),
|
|
28
|
+
);
|
|
29
|
+
return root;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('services migration', () => {
|
|
33
|
+
it('migrates a simple root service and its component consumer', async () => {
|
|
34
|
+
const root = await fixture({
|
|
35
|
+
'tsconfig.json': JSON.stringify({
|
|
36
|
+
compilerOptions: { experimentalDecorators: true },
|
|
37
|
+
}),
|
|
38
|
+
'counter.service.ts': `
|
|
39
|
+
import { Injectable } from '@angular/core';
|
|
40
|
+
@Injectable({ providedIn: 'root' })
|
|
41
|
+
export class CounterService {
|
|
42
|
+
count = 0;
|
|
43
|
+
increment(): void { this.count += 1; }
|
|
44
|
+
}
|
|
45
|
+
`,
|
|
46
|
+
'page.ts': `
|
|
47
|
+
import { Component, inject } from '@angular/core';
|
|
48
|
+
import { CounterService } from './counter.service';
|
|
49
|
+
@Component({}) export class Page { counter = inject(CounterService); }
|
|
50
|
+
`,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const first = await runServicesMigration({
|
|
54
|
+
rootDir: root,
|
|
55
|
+
write: true,
|
|
56
|
+
eslint: false,
|
|
57
|
+
log: () => undefined,
|
|
58
|
+
});
|
|
59
|
+
const service = await readFile(join(root, 'counter.service.ts'), 'utf8');
|
|
60
|
+
const page = await readFile(join(root, 'page.ts'), 'utf8');
|
|
61
|
+
|
|
62
|
+
expect(first.diagnostics).toEqual([]);
|
|
63
|
+
expect(service).toContain(
|
|
64
|
+
"craftService({ name: 'Counter', scope: 'global' }",
|
|
65
|
+
);
|
|
66
|
+
expect(service).toContain('function increment(): void');
|
|
67
|
+
expect(page).toContain('injectCounter()');
|
|
68
|
+
|
|
69
|
+
const second = await runServicesMigration({
|
|
70
|
+
rootDir: root,
|
|
71
|
+
write: true,
|
|
72
|
+
eslint: false,
|
|
73
|
+
log: () => undefined,
|
|
74
|
+
});
|
|
75
|
+
expect(second.changedFiles).toEqual([]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('keeps unsafe inheritance and generates a searchable companion', async () => {
|
|
79
|
+
const root = await fixture({
|
|
80
|
+
'tsconfig.json': '{}',
|
|
81
|
+
'legacy.ts': `
|
|
82
|
+
import { Injectable } from '@angular/core';
|
|
83
|
+
class Base {}
|
|
84
|
+
@Injectable() export class LegacyService extends Base {}
|
|
85
|
+
`,
|
|
86
|
+
});
|
|
87
|
+
const result = await runServicesMigration({
|
|
88
|
+
rootDir: root,
|
|
89
|
+
write: true,
|
|
90
|
+
eslint: false,
|
|
91
|
+
log: () => undefined,
|
|
92
|
+
});
|
|
93
|
+
const companion = await readFile(join(root, 'legacy.craft.ts'), 'utf8');
|
|
94
|
+
|
|
95
|
+
expect(result.diagnostics[0]?.code).toBe('NON_CONVERTIBLE_CLASS');
|
|
96
|
+
expect(companion).toContain('CRAFT_IMPLEMENTATION_REQUIRED');
|
|
97
|
+
expect(await readFile(join(root, 'legacy.ts'), 'utf8')).toContain(
|
|
98
|
+
'class LegacyService extends Base',
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const second = await runServicesMigration({
|
|
102
|
+
rootDir: root,
|
|
103
|
+
write: true,
|
|
104
|
+
eslint: false,
|
|
105
|
+
log: () => undefined,
|
|
106
|
+
});
|
|
107
|
+
expect(second.changedFiles).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('migrates constructor effect statements into the craft service factory', async () => {
|
|
111
|
+
const root = await fixture({
|
|
112
|
+
'tsconfig.json': JSON.stringify({
|
|
113
|
+
compilerOptions: { experimentalDecorators: true },
|
|
114
|
+
}),
|
|
115
|
+
'wizard.ts': `
|
|
116
|
+
import { Service, effect, signal } from '@angular/core';
|
|
117
|
+
@Service({ autoProvided: false })
|
|
118
|
+
export class CheckoutWizard {
|
|
119
|
+
readonly activeStep = signal('delivery');
|
|
120
|
+
readonly visited = signal<string[]>([]);
|
|
121
|
+
constructor() {
|
|
122
|
+
effect(() => {
|
|
123
|
+
this.visited.update((items) => [...items, this.activeStep()]);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
`,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const result = await runServicesMigration({
|
|
131
|
+
rootDir: root,
|
|
132
|
+
write: true,
|
|
133
|
+
eslint: false,
|
|
134
|
+
log: () => undefined,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const output = await readFile(join(root, 'wizard.ts'), 'utf8');
|
|
138
|
+
expect(result.diagnostics).toEqual([]);
|
|
139
|
+
expect(output).toContain("craftService({ name: 'CheckoutWizard'");
|
|
140
|
+
expect(output).toContain('effect(() =>');
|
|
141
|
+
expect(output).toContain('_visited.update');
|
|
142
|
+
expect(output).toContain('_activeStep()');
|
|
143
|
+
expect(output).not.toContain('toCraftService');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('imports ToYield helpers for dependencies migrated from other files', async () => {
|
|
147
|
+
const root = await fixture({
|
|
148
|
+
'tsconfig.json': JSON.stringify({
|
|
149
|
+
compilerOptions: { experimentalDecorators: true },
|
|
150
|
+
}),
|
|
151
|
+
'cart.ts': `
|
|
152
|
+
import { Injectable } from '@angular/core';
|
|
153
|
+
@Injectable({ providedIn: 'root' })
|
|
154
|
+
export class CartStore {
|
|
155
|
+
clear(): void {}
|
|
156
|
+
}
|
|
157
|
+
`,
|
|
158
|
+
'checkout/wizard.ts': `
|
|
159
|
+
import { Injectable, inject } from '@angular/core';
|
|
160
|
+
import { CartStore } from '../cart';
|
|
161
|
+
@Injectable()
|
|
162
|
+
export class CheckoutWizard {
|
|
163
|
+
private readonly cartStore = inject(CartStore);
|
|
164
|
+
clear(): void {
|
|
165
|
+
this.cartStore.clear();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
`,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
await runServicesMigration({
|
|
172
|
+
rootDir: root,
|
|
173
|
+
write: true,
|
|
174
|
+
eslint: false,
|
|
175
|
+
log: () => undefined,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const output = await readFile(join(root, 'checkout/wizard.ts'), 'utf8');
|
|
179
|
+
expect(output).toContain(
|
|
180
|
+
"import { CartStoreToYield } from '../cart'",
|
|
181
|
+
);
|
|
182
|
+
expect(output).toContain(
|
|
183
|
+
'const _cartStore = yield* CartStoreToYield();',
|
|
184
|
+
);
|
|
185
|
+
expect(output).not.toContain('import { CartStore }');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('preserves imperative state comments while rebuilding service properties', async () => {
|
|
189
|
+
const root = await fixture({
|
|
190
|
+
'tsconfig.json': JSON.stringify({
|
|
191
|
+
compilerOptions: { experimentalDecorators: true },
|
|
192
|
+
}),
|
|
193
|
+
'wizard.ts': `
|
|
194
|
+
import { Service } from '@angular/core';
|
|
195
|
+
import { state } from '@craft-ng/core';
|
|
196
|
+
@Service({ autoProvided: false })
|
|
197
|
+
export class CheckoutWizard {
|
|
198
|
+
// CRAFT_IMPERATIVE_CODE_DETECTED: imperative code detected, prefer a declarative approach.
|
|
199
|
+
readonly activeStep = state('delivery', ({ set, update }) => ({ set, update }));
|
|
200
|
+
}
|
|
201
|
+
`,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
await runServicesMigration({
|
|
205
|
+
rootDir: root,
|
|
206
|
+
write: true,
|
|
207
|
+
eslint: false,
|
|
208
|
+
log: () => undefined,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const output = await readFile(join(root, 'wizard.ts'), 'utf8');
|
|
212
|
+
expect(output).toContain(
|
|
213
|
+
'// CRAFT_IMPERATIVE_CODE_DETECTED: imperative code detected, prefer a declarative approach.',
|
|
214
|
+
);
|
|
215
|
+
expect(output).toContain("const _activeStep = state('delivery'");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('flags imperative form workflows for a reactive orchestration review', async () => {
|
|
219
|
+
const root = await fixture({
|
|
220
|
+
'tsconfig.json': JSON.stringify({
|
|
221
|
+
compilerOptions: { experimentalDecorators: true },
|
|
222
|
+
}),
|
|
223
|
+
'wizard.ts': `
|
|
224
|
+
import { Service, signal } from '@angular/core';
|
|
225
|
+
@Service({ autoProvided: false })
|
|
226
|
+
export class CheckoutWizard {
|
|
227
|
+
readonly activeStep = signal('delivery');
|
|
228
|
+
readonly stepStatus = signal({ delivery: null });
|
|
229
|
+
readonly checkoutForm = { form: { delivery: { submit: async () => true } } };
|
|
230
|
+
readonly router = { navigate: async (_commands: unknown[]) => true };
|
|
231
|
+
|
|
232
|
+
async validateStep(step: 'delivery'): Promise<void> {
|
|
233
|
+
const success = await this.checkoutForm.form[step].submit();
|
|
234
|
+
if (!success) return;
|
|
235
|
+
this.stepStatus.update((status) => ({ ...status, [step]: 'success' }));
|
|
236
|
+
this.activeStep.set('delivery');
|
|
237
|
+
await this.router.navigate(['/checkout', step]);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
`,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const result = await runServicesMigration({
|
|
244
|
+
rootDir: root,
|
|
245
|
+
write: true,
|
|
246
|
+
eslint: false,
|
|
247
|
+
log: () => undefined,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const output = await readFile(join(root, 'wizard.ts'), 'utf8');
|
|
251
|
+
expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toContain(
|
|
252
|
+
'IMPERATIVE_WORKFLOW_REQUIRES_REVIEW',
|
|
253
|
+
);
|
|
254
|
+
expect(output).toContain(
|
|
255
|
+
'// CRAFT_REACTIVE_WORKFLOW_RECOMMENDED: workflow impératif détecté...',
|
|
256
|
+
);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('uses function scope for a service injected once by a single component', async () => {
|
|
260
|
+
const root = await fixture({
|
|
261
|
+
'tsconfig.json': JSON.stringify({
|
|
262
|
+
compilerOptions: { experimentalDecorators: true },
|
|
263
|
+
}),
|
|
264
|
+
'wizard.ts': `
|
|
265
|
+
import { Service, signal } from '@angular/core';
|
|
266
|
+
@Service({ autoProvided: false })
|
|
267
|
+
export class CheckoutWizard {
|
|
268
|
+
readonly activeStep = signal('delivery');
|
|
269
|
+
}
|
|
270
|
+
`,
|
|
271
|
+
'page.ts': `
|
|
272
|
+
import { Component, inject } from '@angular/core';
|
|
273
|
+
import { CheckoutWizard } from './wizard';
|
|
274
|
+
@Component({ providers: [CheckoutWizard] })
|
|
275
|
+
export class Page {
|
|
276
|
+
readonly wizard = inject(CheckoutWizard);
|
|
277
|
+
}
|
|
278
|
+
`,
|
|
279
|
+
'page.spec.ts': `
|
|
280
|
+
import { CheckoutWizard } from './wizard';
|
|
281
|
+
const testOnlyReference: CheckoutWizard | undefined = undefined;
|
|
282
|
+
`,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
await runServicesMigration({
|
|
286
|
+
rootDir: root,
|
|
287
|
+
write: true,
|
|
288
|
+
eslint: false,
|
|
289
|
+
log: () => undefined,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const service = await readFile(join(root, 'wizard.ts'), 'utf8');
|
|
293
|
+
const page = await readFile(join(root, 'page.ts'), 'utf8');
|
|
294
|
+
expect(service).toContain(
|
|
295
|
+
"craftService({ name: 'CheckoutWizard', scope: 'function' }",
|
|
296
|
+
);
|
|
297
|
+
expect(service).not.toContain('provideCheckoutWizard');
|
|
298
|
+
expect(page).toContain('injectCheckoutWizard()');
|
|
299
|
+
expect(page).not.toContain('import { CheckoutWizard }');
|
|
300
|
+
expect(page).not.toContain('provideCheckoutWizard');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('keeps toProvide scope when several components share the service', async () => {
|
|
304
|
+
const root = await fixture({
|
|
305
|
+
'tsconfig.json': JSON.stringify({
|
|
306
|
+
compilerOptions: { experimentalDecorators: true },
|
|
307
|
+
}),
|
|
308
|
+
'wizard.ts': `
|
|
309
|
+
import { Service, signal } from '@angular/core';
|
|
310
|
+
@Service({ autoProvided: false })
|
|
311
|
+
export class CheckoutWizard {
|
|
312
|
+
readonly activeStep = signal('delivery');
|
|
313
|
+
}
|
|
314
|
+
`,
|
|
315
|
+
'page.ts': `
|
|
316
|
+
import { Component, inject } from '@angular/core';
|
|
317
|
+
import { CheckoutWizard } from './wizard';
|
|
318
|
+
@Component({ providers: [CheckoutWizard] })
|
|
319
|
+
export class Page {
|
|
320
|
+
readonly wizard = inject(CheckoutWizard);
|
|
321
|
+
}
|
|
322
|
+
`,
|
|
323
|
+
'step.ts': `
|
|
324
|
+
import { Component, inject } from '@angular/core';
|
|
325
|
+
import { CheckoutWizard } from './wizard';
|
|
326
|
+
@Component({})
|
|
327
|
+
export class Step {
|
|
328
|
+
readonly wizard = inject(CheckoutWizard);
|
|
329
|
+
}
|
|
330
|
+
`,
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
await runServicesMigration({
|
|
334
|
+
rootDir: root,
|
|
335
|
+
write: true,
|
|
336
|
+
eslint: false,
|
|
337
|
+
log: () => undefined,
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const service = await readFile(join(root, 'wizard.ts'), 'utf8');
|
|
341
|
+
const page = await readFile(join(root, 'page.ts'), 'utf8');
|
|
342
|
+
expect(service).toContain(
|
|
343
|
+
"craftService({ name: 'CheckoutWizard', scope: 'toProvide' }",
|
|
344
|
+
);
|
|
345
|
+
expect(service).toContain('provideCheckoutWizard');
|
|
346
|
+
expect(page).toContain('provideCheckoutWizard()');
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('keeps toProvide scope for a service injected from a route function', async () => {
|
|
350
|
+
const root = await fixture({
|
|
351
|
+
'tsconfig.json': JSON.stringify({
|
|
352
|
+
compilerOptions: { experimentalDecorators: true },
|
|
353
|
+
}),
|
|
354
|
+
'wizard.ts': `
|
|
355
|
+
import { Service, signal } from '@angular/core';
|
|
356
|
+
@Service({ autoProvided: false })
|
|
357
|
+
export class CheckoutWizard {
|
|
358
|
+
readonly activeStep = signal('delivery');
|
|
359
|
+
}
|
|
360
|
+
`,
|
|
361
|
+
'guard.ts': `
|
|
362
|
+
import { inject } from '@angular/core';
|
|
363
|
+
import { CheckoutWizard } from './wizard';
|
|
364
|
+
export const guard = () => inject(CheckoutWizard).activeStep();
|
|
365
|
+
`,
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
await runServicesMigration({
|
|
369
|
+
rootDir: root,
|
|
370
|
+
write: true,
|
|
371
|
+
eslint: false,
|
|
372
|
+
log: () => undefined,
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const service = await readFile(join(root, 'wizard.ts'), 'utf8');
|
|
376
|
+
expect(service).toContain(
|
|
377
|
+
"craftService({ name: 'CheckoutWizard', scope: 'toProvide' }",
|
|
378
|
+
);
|
|
379
|
+
expect(service).toContain('provideCheckoutWizard');
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('uses the built-in CraftRouterToYield helper for Angular Router dependencies', async () => {
|
|
383
|
+
const root = await fixture({
|
|
384
|
+
'tsconfig.json': JSON.stringify({
|
|
385
|
+
compilerOptions: { experimentalDecorators: true },
|
|
386
|
+
}),
|
|
387
|
+
'wizard.ts': `
|
|
388
|
+
import { Service, effect, inject, signal } from '@angular/core';
|
|
389
|
+
import { Router } from '@angular/router';
|
|
390
|
+
@Service({ autoProvided: false })
|
|
391
|
+
export class CheckoutWizard {
|
|
392
|
+
private readonly router = inject(Router);
|
|
393
|
+
readonly activeStep = signal('delivery');
|
|
394
|
+
constructor() {
|
|
395
|
+
effect(() => {
|
|
396
|
+
this.router.navigate(['/checkout', this.activeStep()]);
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
`,
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
const result = await runServicesMigration({
|
|
404
|
+
rootDir: root,
|
|
405
|
+
write: true,
|
|
406
|
+
eslint: false,
|
|
407
|
+
log: () => undefined,
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
const output = await readFile(join(root, 'wizard.ts'), 'utf8');
|
|
411
|
+
expect(result.diagnostics).toEqual([]);
|
|
412
|
+
expect(output).toContain('CraftRouterToYield');
|
|
413
|
+
expect(output).toContain('const _router = yield* CraftRouterToYield();');
|
|
414
|
+
expect(output).toContain("_router.navigate(['/checkout', _activeStep()])");
|
|
415
|
+
expect(output).not.toContain('const { RouterToYield }');
|
|
416
|
+
expect(output).not.toContain('yield* RouterToYield');
|
|
417
|
+
expect(output).not.toContain('toCraftService');
|
|
418
|
+
expect(output).not.toContain("from '@angular/router'");
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('loads unified config overrides while preserving brand config compatibility', async () => {
|
|
422
|
+
const root = await fixture({
|
|
423
|
+
'tsconfig.json': '{}',
|
|
424
|
+
'craft-dev-tools.config.ts': `
|
|
425
|
+
import { defineCraftDevToolsConfig } from '@craft-ng/dev-tools';
|
|
426
|
+
export default defineCraftDevToolsConfig({
|
|
427
|
+
brand: { importAugmentations: [{ match: { module: 'pkg' }, deps: [{ key: 'Box', symbol: 'Box', typeText: 'Box<string>' }] }] },
|
|
428
|
+
serviceMigration: { overrides: [{ symbol: 'ApiService', name: 'Backend', scope: 'manuallyProvidedAtRoot' }] },
|
|
429
|
+
});
|
|
430
|
+
`,
|
|
431
|
+
'api.ts': `
|
|
432
|
+
import { Injectable } from '@angular/core';
|
|
433
|
+
@Injectable() export class ApiService { ping() { return true; } }
|
|
434
|
+
`,
|
|
435
|
+
});
|
|
436
|
+
await runServicesMigration({
|
|
437
|
+
rootDir: root,
|
|
438
|
+
write: true,
|
|
439
|
+
eslint: false,
|
|
440
|
+
log: () => undefined,
|
|
441
|
+
});
|
|
442
|
+
const output = await readFile(join(root, 'api.ts'), 'utf8');
|
|
443
|
+
expect(output).toContain(
|
|
444
|
+
"name: 'Backend', scope: 'manuallyProvidedAtRoot'",
|
|
445
|
+
);
|
|
446
|
+
expect(output).toContain('provideBackend');
|
|
447
|
+
expect(
|
|
448
|
+
loadAngularBrandConfigFromFile(join(root, 'craft-dev-tools.config.ts'))
|
|
449
|
+
.importAugmentations?.[0]?.deps?.[0]?.typeText,
|
|
450
|
+
).toBe('Box<string>');
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('preserves generics, avoids property shadowing, and cleans replaced imports', async () => {
|
|
454
|
+
const root = await fixture({
|
|
455
|
+
'tsconfig.json': JSON.stringify({
|
|
456
|
+
compilerOptions: { experimentalDecorators: true },
|
|
457
|
+
}),
|
|
458
|
+
'api.ts': `
|
|
459
|
+
import { Injectable, inject, signal } from '@angular/core';
|
|
460
|
+
@Injectable({ providedIn: 'root' })
|
|
461
|
+
export class ApiService {
|
|
462
|
+
private readonly user = signal<string | null>(null);
|
|
463
|
+
setUser(user: string): void { this.user.set(user); }
|
|
464
|
+
load<T>(value: T): T { return value; }
|
|
465
|
+
}
|
|
466
|
+
`,
|
|
467
|
+
'page.ts': `
|
|
468
|
+
import { Component, inject } from '@angular/core';
|
|
469
|
+
import { ApiService } from './api';
|
|
470
|
+
@Component({}) export class Page { api = inject(ApiService); }
|
|
471
|
+
`,
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
await runServicesMigration({
|
|
475
|
+
rootDir: root,
|
|
476
|
+
write: true,
|
|
477
|
+
eslint: false,
|
|
478
|
+
log: () => undefined,
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
const api = await readFile(join(root, 'api.ts'), 'utf8');
|
|
482
|
+
const page = await readFile(join(root, 'page.ts'), 'utf8');
|
|
483
|
+
expect(api).toContain('function setUser(user: string): void');
|
|
484
|
+
expect(api).toContain('_user.set(user)');
|
|
485
|
+
expect(api).toContain('function load<T>(value: T): T');
|
|
486
|
+
expect(api).not.toMatch(/\binject\b.*from '@angular\/core'/);
|
|
487
|
+
expect(page).toContain('injectApi()');
|
|
488
|
+
expect(page).not.toContain('ApiService');
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('imports unsafe service helpers from the generated companion', async () => {
|
|
492
|
+
const root = await fixture({
|
|
493
|
+
'tsconfig.json': JSON.stringify({
|
|
494
|
+
compilerOptions: { experimentalDecorators: true },
|
|
495
|
+
}),
|
|
496
|
+
'legacy.ts': `
|
|
497
|
+
import { Injectable } from '@angular/core';
|
|
498
|
+
class Base {}
|
|
499
|
+
@Injectable() export class LegacyService extends Base {}
|
|
500
|
+
`,
|
|
501
|
+
'page.ts': `
|
|
502
|
+
import { Component, inject } from '@angular/core';
|
|
503
|
+
import { LegacyService } from './legacy';
|
|
504
|
+
@Component({ providers: [LegacyService] })
|
|
505
|
+
export class Page { legacy = inject(LegacyService); }
|
|
506
|
+
`,
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
await runServicesMigration({
|
|
510
|
+
rootDir: root,
|
|
511
|
+
write: true,
|
|
512
|
+
eslint: false,
|
|
513
|
+
log: () => undefined,
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
const companion = await readFile(join(root, 'legacy.craft.ts'), 'utf8');
|
|
517
|
+
const page = await readFile(join(root, 'page.ts'), 'utf8');
|
|
518
|
+
expect(companion).toContain("scope: 'toProvide'");
|
|
519
|
+
expect(companion).toContain('provideLegacy');
|
|
520
|
+
expect(page).toContain("from './legacy.craft'");
|
|
521
|
+
expect(page).toContain('provideLegacy()');
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it('converts simple httpResource calls to query and CraftHttpClient', async () => {
|
|
525
|
+
const root = await fixture({
|
|
526
|
+
'tsconfig.json': JSON.stringify({
|
|
527
|
+
compilerOptions: { experimentalDecorators: true },
|
|
528
|
+
}),
|
|
529
|
+
'catalog.ts': `
|
|
530
|
+
import { Injectable, ResourceRef } from '@angular/core';
|
|
531
|
+
import { httpResource } from '@angular/common/http';
|
|
532
|
+
@Injectable({ providedIn: 'root' })
|
|
533
|
+
export class CatalogService {
|
|
534
|
+
products(): ResourceRef<string[]> {
|
|
535
|
+
return httpResource<string[]>(() => '/api/products', { defaultValue: [] });
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
`,
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
await runServicesMigration({
|
|
542
|
+
rootDir: root,
|
|
543
|
+
write: true,
|
|
544
|
+
eslint: false,
|
|
545
|
+
log: () => undefined,
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
const output = await readFile(join(root, 'catalog.ts'), 'utf8');
|
|
549
|
+
expect(output).toContain('return query({');
|
|
550
|
+
expect(output).toContain('yield* CraftHttpClient.request');
|
|
551
|
+
expect(output).toContain('success: response<string[]>()');
|
|
552
|
+
expect(output).not.toContain('httpResource');
|
|
553
|
+
expect(output).not.toContain('ResourceRef');
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it('tracks a dependent query created as a craftService property', async () => {
|
|
557
|
+
const root = await fixture({
|
|
558
|
+
'tsconfig.json': JSON.stringify({
|
|
559
|
+
compilerOptions: { experimentalDecorators: true },
|
|
560
|
+
}),
|
|
561
|
+
'catalog.ts': `
|
|
562
|
+
import { Injectable, ResourceRef } from '@angular/core';
|
|
563
|
+
import { httpResource } from '@angular/common/http';
|
|
564
|
+
@Injectable({ providedIn: 'root' })
|
|
565
|
+
export class CatalogService {
|
|
566
|
+
readonly products = httpResource<string[]>(() => '/api/products', {
|
|
567
|
+
defaultValue: [],
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
`,
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
await runServicesMigration({
|
|
574
|
+
rootDir: root,
|
|
575
|
+
write: true,
|
|
576
|
+
eslint: false,
|
|
577
|
+
log: () => undefined,
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
const output = await readFile(join(root, 'catalog.ts'), 'utf8');
|
|
581
|
+
expect(output).toContain('function* ()');
|
|
582
|
+
expect(output).toContain('const _products = yield* track(query({');
|
|
583
|
+
expect(output).toContain('yield* CraftHttpClient.request');
|
|
584
|
+
expect(output).toMatch(
|
|
585
|
+
/import\s*\{[^}]*\btrack\b[^}]*\}\s*from '@craft-ng\/core'/,
|
|
586
|
+
);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it('converts simple writable HttpClient service methods to CraftHttpClient generator operations', async () => {
|
|
590
|
+
const root = await fixture({
|
|
591
|
+
'tsconfig.json': JSON.stringify({
|
|
592
|
+
compilerOptions: { experimentalDecorators: true },
|
|
593
|
+
}),
|
|
594
|
+
'orders.ts': `
|
|
595
|
+
import { HttpClient } from '@angular/common/http';
|
|
596
|
+
import { Injectable, inject } from '@angular/core';
|
|
597
|
+
import { Observable } from 'rxjs';
|
|
598
|
+
type Order = { id: string };
|
|
599
|
+
type Payload = { notes: string };
|
|
600
|
+
@Injectable({ providedIn: 'root' })
|
|
601
|
+
export class OrderApiService {
|
|
602
|
+
private readonly http = inject(HttpClient);
|
|
603
|
+
createOrder(data: Payload): Observable<Order> {
|
|
604
|
+
return this.http.post<Order>('/api/orders', data);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
`,
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
await runServicesMigration({
|
|
611
|
+
rootDir: root,
|
|
612
|
+
write: true,
|
|
613
|
+
eslint: false,
|
|
614
|
+
log: () => undefined,
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
const output = await readFile(join(root, 'orders.ts'), 'utf8');
|
|
618
|
+
expect(output).toContain('function* createOrder(data: Payload)');
|
|
619
|
+
expect(output).toContain('yield* CraftHttpClient.post');
|
|
620
|
+
expect(output).toContain("url: '/api/orders'");
|
|
621
|
+
expect(output).toContain('payload: data');
|
|
622
|
+
expect(output).not.toContain('HttpClientToYield');
|
|
623
|
+
expect(output).not.toContain('inject(HttpClient)');
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it('moves simple writable subscribe calls to a component-local mutation', async () => {
|
|
627
|
+
const root = await fixture({
|
|
628
|
+
'tsconfig.json': JSON.stringify({
|
|
629
|
+
compilerOptions: { experimentalDecorators: true },
|
|
630
|
+
}),
|
|
631
|
+
'orders.ts': `
|
|
632
|
+
import { HttpClient } from '@angular/common/http';
|
|
633
|
+
import { Injectable, inject } from '@angular/core';
|
|
634
|
+
import { Observable } from 'rxjs';
|
|
635
|
+
type Order = { id: string };
|
|
636
|
+
type Payload = { notes: string };
|
|
637
|
+
@Injectable({ providedIn: 'root' })
|
|
638
|
+
export class OrderApiService {
|
|
639
|
+
private readonly http = inject(HttpClient);
|
|
640
|
+
createOrder(data: Payload): Observable<Order> {
|
|
641
|
+
return this.http.post<Order>('/api/orders', data);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
`,
|
|
645
|
+
'page.ts': `
|
|
646
|
+
import { Component, inject } from '@angular/core';
|
|
647
|
+
import { OrderApiService } from './orders';
|
|
648
|
+
@Component({})
|
|
649
|
+
export class Page {
|
|
650
|
+
private readonly orderApi = inject(OrderApiService);
|
|
651
|
+
save(data: { notes: string }): void {
|
|
652
|
+
this.orderApi.createOrder(data).subscribe();
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
`,
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
await runServicesMigration({
|
|
659
|
+
rootDir: root,
|
|
660
|
+
write: true,
|
|
661
|
+
eslint: false,
|
|
662
|
+
log: () => undefined,
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
const page = await readFile(join(root, 'page.ts'), 'utf8');
|
|
666
|
+
expect(page).toContain('createOrderMutation = mutation({');
|
|
667
|
+
expect(page).toContain('const orderApi = yield* OrderApiToYield();');
|
|
668
|
+
expect(page).toContain('return yield* orderApi.createOrder(params);');
|
|
669
|
+
expect(page).toContain('this.createOrderMutation.mutate(data)');
|
|
670
|
+
expect(page).toContain("from '@craft-ng/core'");
|
|
671
|
+
expect(page).toContain("from './orders'");
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
it('keeps complex subscribe callbacks as manual mutation diagnostics', async () => {
|
|
675
|
+
const root = await fixture({
|
|
676
|
+
'tsconfig.json': JSON.stringify({
|
|
677
|
+
compilerOptions: { experimentalDecorators: true },
|
|
678
|
+
}),
|
|
679
|
+
'orders.ts': `
|
|
680
|
+
import { HttpClient } from '@angular/common/http';
|
|
681
|
+
import { Injectable, inject } from '@angular/core';
|
|
682
|
+
import { Observable } from 'rxjs';
|
|
683
|
+
type Order = { id: string };
|
|
684
|
+
@Injectable({ providedIn: 'root' })
|
|
685
|
+
export class OrderApiService {
|
|
686
|
+
private readonly http = inject(HttpClient);
|
|
687
|
+
cancelOrder(id: string): Observable<Order> {
|
|
688
|
+
return this.http.patch<Order>('/api/orders/' + id + '/cancel', {});
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
`,
|
|
692
|
+
'page.ts': `
|
|
693
|
+
import { Component, inject } from '@angular/core';
|
|
694
|
+
import { OrderApiService } from './orders';
|
|
695
|
+
@Component({})
|
|
696
|
+
export class Page {
|
|
697
|
+
private readonly orderApi = inject(OrderApiService);
|
|
698
|
+
cancel(id: string): void {
|
|
699
|
+
this.orderApi.cancelOrder(id).subscribe({ next: () => console.log('done') });
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
`,
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
const result = await runServicesMigration({
|
|
706
|
+
rootDir: root,
|
|
707
|
+
write: true,
|
|
708
|
+
eslint: false,
|
|
709
|
+
log: () => undefined,
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toContain(
|
|
713
|
+
'MUTATION_SUBSCRIBE_REQUIRES_REWRITE',
|
|
714
|
+
);
|
|
715
|
+
const page = await readFile(join(root, 'page.ts'), 'utf8');
|
|
716
|
+
expect(page).toContain('subscribe({ next:');
|
|
717
|
+
expect(page).not.toContain('cancelOrderMutation = mutation');
|
|
718
|
+
});
|
|
719
|
+
});
|