@fgv/ts-res-cli 5.0.0-3 → 5.0.0-30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1084 +0,0 @@
1
- /*
2
- * Copyright (c) 2025 Erik Fortune
3
- *
4
- * Permission is hereby granted, free of charge, to any person obtaining a copy
5
- * of this software and associated documentation files (the "Software"), to deal
6
- * in the Software without restriction, including without limitation the rights
7
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
- * copies of the Software, and to permit persons to whom the Software is
9
- * furnished to do so, subject to the following conditions:
10
- *
11
- * The above copyright notice and this permission notice shall be included in all
12
- * copies or substantial portions of the Software.
13
- *
14
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
- * SOFTWARE.
21
- */
22
-
23
- import '@fgv/ts-utils-jest';
24
- import { ResourceCompiler } from '../../src/compiler';
25
- import { ICompileOptions } from '../../src/options';
26
- import { promises as fs } from 'fs';
27
- import * as path from 'path';
28
- import * as TsRes from '@fgv/ts-res';
29
-
30
- describe('ResourceCompiler', () => {
31
- let tempDir: string;
32
- let inputFile: string;
33
- let outputFile: string;
34
-
35
- beforeEach(async () => {
36
- // Create temporary directory for test files
37
- tempDir = await fs.mkdtemp(path.join(__dirname, '../../temp/test-temp-'));
38
- inputFile = path.join(tempDir, 'input.json');
39
- outputFile = path.join(tempDir, 'output.json');
40
-
41
- // Create test input file in ResourceCollection format
42
- const testResources = {
43
- resources: [
44
- {
45
- id: 'test.message',
46
- resourceTypeName: 'json',
47
- candidates: [
48
- {
49
- json: { text: 'Hello' },
50
- conditions: { language: 'en' }
51
- },
52
- {
53
- json: { text: 'Hola' },
54
- conditions: { language: 'es' }
55
- }
56
- ]
57
- }
58
- ]
59
- };
60
- await fs.writeFile(inputFile, JSON.stringify(testResources, null, 2));
61
- });
62
-
63
- afterEach(async () => {
64
- // Clean up temporary directory
65
- await fs.rm(tempDir, { recursive: true, force: true });
66
- });
67
-
68
- describe('constructor', () => {
69
- test('creates a compiler with options', () => {
70
- const options: ICompileOptions = {
71
- input: inputFile,
72
- output: outputFile,
73
- format: 'source',
74
- debug: false,
75
- verbose: false,
76
- quiet: false,
77
- validate: true,
78
- includeMetadata: false
79
- };
80
-
81
- const compiler = new ResourceCompiler(options);
82
- expect(compiler).toBeDefined();
83
- });
84
- });
85
-
86
- describe('compile method', () => {
87
- test('compiles resources to compiled format by default', async () => {
88
- const options: ICompileOptions = {
89
- input: inputFile,
90
- output: outputFile,
91
- format: 'compiled',
92
- debug: false,
93
- verbose: false,
94
- quiet: false,
95
- validate: true,
96
- includeMetadata: false
97
- };
98
-
99
- const compiler = new ResourceCompiler(options);
100
- const result = await compiler.compile();
101
-
102
- expect(result).toSucceed();
103
-
104
- // Check that output file was created
105
- const outputExists = await fs.stat(outputFile).then(
106
- () => true,
107
- () => false
108
- );
109
- expect(outputExists).toBe(true);
110
-
111
- // Check compiled output structure
112
- const outputContent = await fs.readFile(outputFile, 'utf-8');
113
- const compiledCollectionData = JSON.parse(outputContent);
114
-
115
- // Use the ts-res converter to validate the compiled resource collection
116
- const validationResult =
117
- TsRes.ResourceJson.Compiled.Convert.compiledResourceCollection.convert(compiledCollectionData);
118
- expect(validationResult).toSucceedAndSatisfy((compiledCollection) => {
119
- // Verify it's a properly structured compiled resource collection
120
- expect(compiledCollection.qualifierTypes).toBeDefined();
121
- expect(compiledCollection.qualifiers).toBeDefined();
122
- expect(compiledCollection.resourceTypes).toBeDefined();
123
- expect(compiledCollection.conditions).toBeDefined();
124
- expect(compiledCollection.conditionSets).toBeDefined();
125
- expect(compiledCollection.decisions).toBeDefined();
126
- expect(compiledCollection.resources).toBeDefined();
127
-
128
- // Verify these are arrays with expected content
129
- expect(Array.isArray(compiledCollection.qualifierTypes)).toBe(true);
130
- expect(Array.isArray(compiledCollection.qualifiers)).toBe(true);
131
- expect(Array.isArray(compiledCollection.resourceTypes)).toBe(true);
132
- expect(Array.isArray(compiledCollection.conditions)).toBe(true);
133
- expect(Array.isArray(compiledCollection.conditionSets)).toBe(true);
134
- expect(Array.isArray(compiledCollection.decisions)).toBe(true);
135
- expect(Array.isArray(compiledCollection.resources)).toBe(true);
136
-
137
- // Verify we have some resources
138
- expect(compiledCollection.resources.length).toBeGreaterThan(0);
139
-
140
- // Verify basic structural integrity
141
- expect(compiledCollection.qualifierTypes.length).toBeGreaterThan(0);
142
- expect(compiledCollection.qualifiers.length).toBeGreaterThan(0);
143
- expect(compiledCollection.resourceTypes.length).toBeGreaterThan(0);
144
- });
145
- });
146
-
147
- test('compiles resources to compiled format with metadata', async () => {
148
- const options: ICompileOptions = {
149
- input: inputFile,
150
- output: outputFile,
151
- format: 'compiled',
152
- debug: false,
153
- verbose: false,
154
- quiet: false,
155
- validate: true,
156
- includeMetadata: true
157
- };
158
-
159
- const compiler = new ResourceCompiler(options);
160
- const result = await compiler.compile();
161
-
162
- expect(result).toSucceed();
163
-
164
- const outputContent = await fs.readFile(outputFile, 'utf-8');
165
- const outputData = JSON.parse(outputContent);
166
-
167
- // When includeMetadata is true and format is compiled, it should wrap the compiled collection in a blob
168
- expect(outputData).toHaveProperty('compiledCollection');
169
- expect(outputData).toHaveProperty('metadata');
170
-
171
- // Check metadata structure
172
- expect(outputData.metadata).toHaveProperty('totalResources');
173
- expect(outputData.metadata).toHaveProperty('totalCandidates');
174
- expect(outputData.metadata).toHaveProperty('resourceTypes');
175
- expect(outputData.metadata).toHaveProperty('qualifiers');
176
-
177
- // Validate the compiled collection using ts-res converter
178
- const compiledCollection = outputData.compiledCollection;
179
- const validationResult =
180
- TsRes.ResourceJson.Compiled.Convert.compiledResourceCollection.convert(compiledCollection);
181
- expect(validationResult).toSucceedAndSatisfy((validCollection) => {
182
- expect(validCollection.qualifierTypes).toBeDefined();
183
- expect(validCollection.qualifiers).toBeDefined();
184
- expect(validCollection.resourceTypes).toBeDefined();
185
- expect(validCollection.conditions).toBeDefined();
186
- expect(validCollection.conditionSets).toBeDefined();
187
- expect(validCollection.decisions).toBeDefined();
188
- expect(validCollection.resources).toBeDefined();
189
- expect(validCollection.resources.length).toBeGreaterThan(0);
190
- });
191
- });
192
-
193
- test('compiles resources to source format', async () => {
194
- const options: ICompileOptions = {
195
- input: inputFile,
196
- output: outputFile,
197
- format: 'source',
198
- debug: false,
199
- verbose: false,
200
- quiet: false,
201
- validate: true,
202
- includeMetadata: false
203
- };
204
-
205
- const compiler = new ResourceCompiler(options);
206
- const result = await compiler.compile();
207
-
208
- expect(result).toSucceed();
209
-
210
- // Check that output file was created
211
- const outputExists = await fs.stat(outputFile).then(
212
- () => true,
213
- () => false
214
- );
215
- expect(outputExists).toBe(true);
216
-
217
- // Check output content
218
- const outputContent = await fs.readFile(outputFile, 'utf-8');
219
- const outputData = JSON.parse(outputContent);
220
-
221
- expect(outputData).toHaveProperty('resources');
222
- expect(
223
- TsRes.ResourceJson.Convert.resourceCollectionDecl.convert(outputData.resources)
224
- ).toSucceedAndSatisfy((resources) => {
225
- expect(resources?.resources?.length).toBeGreaterThan(0);
226
- expect(resources?.resources?.some((r) => r.id === 'input.test.message')).toBe(true);
227
- return true;
228
- });
229
- });
230
-
231
- test('compiles with context filtering and excludes non-matching variants', async () => {
232
- const options: ICompileOptions = {
233
- input: inputFile,
234
- output: outputFile,
235
- contextFilter: 'language=en-US',
236
- format: 'source',
237
- debug: false,
238
- verbose: false,
239
- quiet: false,
240
- validate: true,
241
- includeMetadata: true
242
- };
243
-
244
- const compiler = new ResourceCompiler(options);
245
- const result = await compiler.compile();
246
-
247
- expect(result).toSucceed();
248
-
249
- const outputContent = await fs.readFile(outputFile, 'utf-8');
250
- const outputData = JSON.parse(outputContent);
251
-
252
- expect(
253
- TsRes.ResourceJson.Convert.resourceCollectionDecl.convert(outputData.resources)
254
- ).toSucceedAndSatisfy((resources) => {
255
- const messageResource = resources?.resources?.find((r) => r.id === 'input.test.message');
256
- expect(messageResource).toBeDefined();
257
-
258
- // Should only contain English variants
259
- const hasEnglish = messageResource?.candidates?.some((c) =>
260
- c.conditions?.some((cond) => cond.qualifierName === 'language' && cond.value.includes('en'))
261
- );
262
- expect(hasEnglish).toBe(true);
263
-
264
- // Should NOT contain Spanish variants
265
- const hasSpanish = messageResource?.candidates?.some((c) =>
266
- c.conditions?.some((cond) => cond.qualifierName === 'language' && cond.value.includes('es'))
267
- );
268
- expect(hasSpanish).toBe(false);
269
-
270
- return true;
271
- });
272
-
273
- // Verify metadata shows filtering occurred
274
- expect(outputData.metadata).toBeDefined();
275
- expect(outputData.metadata.totalCandidates).toBe(2); // Both en and es
276
- expect(outputData.metadata.filteredCandidates).toBe(1); // Only en after filtering
277
- });
278
-
279
- test('context filtering with multiple resources shows selective filtering', async () => {
280
- // Create a more complex test file with multiple resources and languages
281
- const complexTestFile = path.join(tempDir, 'complex-input.json');
282
- const complexTestResources = {
283
- resources: [
284
- {
285
- id: 'greeting.hello',
286
- resourceTypeName: 'json',
287
- candidates: [
288
- {
289
- json: { text: 'Hello' },
290
- conditions: { language: 'en' }
291
- },
292
- {
293
- json: { text: 'Hola' },
294
- conditions: { language: 'es' }
295
- }
296
- ]
297
- },
298
- {
299
- id: 'button.ok',
300
- resourceTypeName: 'json',
301
- candidates: [
302
- {
303
- json: { label: 'OK' },
304
- conditions: { language: 'en' }
305
- }
306
- ]
307
- },
308
- {
309
- id: 'error.notfound',
310
- resourceTypeName: 'json',
311
- candidates: [
312
- {
313
- json: { message: 'Not found' },
314
- conditions: { language: 'en' }
315
- },
316
- {
317
- json: { message: 'No encontrado' },
318
- conditions: { language: 'es' }
319
- }
320
- ]
321
- },
322
- {
323
- id: 'app.name',
324
- resourceTypeName: 'json',
325
- candidates: [
326
- {
327
- json: { title: 'My App' }
328
- }
329
- ]
330
- }
331
- ]
332
- };
333
-
334
- await fs.writeFile(complexTestFile, JSON.stringify(complexTestResources, null, 2));
335
-
336
- const complexOutputFile = path.join(tempDir, 'complex-output.json');
337
- const options: ICompileOptions = {
338
- input: complexTestFile,
339
- output: complexOutputFile,
340
- contextFilter: 'language=en',
341
- format: 'source',
342
- debug: false,
343
- verbose: false,
344
- quiet: false,
345
- validate: true,
346
- includeMetadata: true
347
- };
348
-
349
- const compiler = new ResourceCompiler(options);
350
- const result = await compiler.compile();
351
- expect(result).toSucceed();
352
-
353
- const outputContent = await fs.readFile(complexOutputFile, 'utf-8');
354
- const outputData = JSON.parse(outputContent);
355
-
356
- // Verify what should be included using ResourceCollectionDecl format
357
- expect(
358
- TsRes.ResourceJson.Convert.resourceCollectionDecl.convert(outputData.resources)
359
- ).toSucceedAndSatisfy((resources) => {
360
- const resourceIds = resources?.resources?.map((r) => r.id) || [];
361
- expect(resourceIds).toContain('complex-input.greeting.hello'); // Has en variant
362
- expect(resourceIds).toContain('complex-input.button.ok'); // Only has en
363
- expect(resourceIds).toContain('complex-input.app.name'); // No language condition (default)
364
- expect(resourceIds).toContain('complex-input.error.notfound'); // Now has en
365
-
366
- // Verify greeting.hello only has English, not Spanish
367
- const greetingResource = resources?.resources?.find((r) => r.id === 'complex-input.greeting.hello');
368
- expect(greetingResource).toBeDefined();
369
-
370
- const hasEnglish = greetingResource?.candidates?.some((c) =>
371
- c.conditions?.some((cond) => cond.qualifierName === 'language' && cond.value.includes('en'))
372
- );
373
- expect(hasEnglish).toBe(true);
374
-
375
- const hasSpanish = greetingResource?.candidates?.some((c) =>
376
- c.conditions?.some((cond) => cond.qualifierName === 'language' && cond.value.includes('es'))
377
- );
378
- expect(hasSpanish).toBe(false);
379
-
380
- return true;
381
- });
382
-
383
- // Verify metadata
384
- expect(outputData.metadata.totalCandidates).toBe(6); // All 6 candidates (added one more to error.notfound)
385
- expect(outputData.metadata.filteredCandidates).toBe(4); // greeting.hello[en], button.ok[en], error.notfound[en], app.name[default]
386
- expect(outputData.metadata.totalResources).toBe(4); // 4 unique resource IDs
387
- expect(outputData.metadata.filteredResources).toBe(4); // All 4 resources have English or default candidates
388
- });
389
-
390
- test.skip('context filtering with no matches produces empty output', async () => {
391
- const options: ICompileOptions = {
392
- input: inputFile,
393
- output: outputFile,
394
- contextFilter: 'language=fr', // French - not in our test data
395
- format: 'source',
396
- debug: false,
397
- verbose: false,
398
- quiet: false,
399
- validate: true,
400
- includeMetadata: true
401
- };
402
-
403
- const compiler = new ResourceCompiler(options);
404
- const result = await compiler.compile();
405
- expect(result).toSucceed();
406
-
407
- const outputContent = await fs.readFile(outputFile, 'utf-8');
408
- const outputData = JSON.parse(outputContent);
409
-
410
- // Should have no resources in output using ResourceCollectionDecl format
411
- expect(
412
- TsRes.ResourceJson.Convert.resourceCollectionDecl.convert(outputData.resources)
413
- ).toSucceedAndSatisfy((resources) => {
414
- expect(resources?.resources?.length || 0).toBe(0);
415
- return true;
416
- });
417
-
418
- // Metadata should show filtering occurred
419
- expect(outputData.metadata.totalCandidates).toBe(2); // Both en and es in input
420
- expect(outputData.metadata.filteredCandidates).toBe(0); // None match French
421
- expect(outputData.metadata.totalResources).toBe(1); // 1 resource ID in input
422
- expect(outputData.metadata.filteredResources).toBe(0); // No resources match
423
- });
424
-
425
- test('context filtering with language variants uses fuzzy matching', async () => {
426
- // Create test data with various English variants and other languages
427
- const variantTestFile = path.join(tempDir, 'variant-input.json');
428
- const variantTestResources = {
429
- resources: [
430
- {
431
- id: 'msg.hello',
432
- resourceTypeName: 'json',
433
- candidates: [
434
- {
435
- json: { text: 'Hello (US)' },
436
- conditions: { language: 'en-US' }
437
- },
438
- {
439
- json: { text: 'Hello (CA)' },
440
- conditions: { language: 'en-CA' }
441
- },
442
- {
443
- json: { text: 'Hello (AU)' },
444
- conditions: { language: 'en-AU' }
445
- },
446
- {
447
- json: { text: 'Hello (generic)' },
448
- conditions: { language: 'en' }
449
- },
450
- {
451
- json: { text: 'Bonjour (FR)' },
452
- conditions: { language: 'fr-FR' }
453
- },
454
- {
455
- json: { text: 'Bonjour (CA)' },
456
- conditions: { language: 'fr-CA' }
457
- },
458
- {
459
- json: { text: 'Bonjour (generic)' },
460
- conditions: { language: 'fr' }
461
- },
462
- {
463
- json: { text: 'Hola (ES)' },
464
- conditions: { language: 'es-ES' }
465
- },
466
- {
467
- json: { text: 'Hola (MX)' },
468
- conditions: { language: 'es-MX' }
469
- }
470
- ]
471
- },
472
- {
473
- id: 'btn.save',
474
- resourceTypeName: 'json',
475
- candidates: [
476
- {
477
- json: { label: 'Save (US)' },
478
- conditions: { language: 'en-US' }
479
- },
480
- {
481
- json: { label: 'Save (FR)' },
482
- conditions: { language: 'fr-FR' }
483
- }
484
- ]
485
- }
486
- ]
487
- };
488
-
489
- await fs.writeFile(variantTestFile, JSON.stringify(variantTestResources, null, 2));
490
-
491
- const variantOutputFile = path.join(tempDir, 'variant-output.json');
492
- const options: ICompileOptions = {
493
- input: variantTestFile,
494
- output: variantOutputFile,
495
- contextFilter: 'language=en-GB', // British English - should match other English variants
496
- format: 'source',
497
- debug: false,
498
- verbose: false,
499
- quiet: false,
500
- validate: true,
501
- includeMetadata: true
502
- };
503
-
504
- const compiler = new ResourceCompiler(options);
505
- const result = await compiler.compile();
506
- expect(result).toSucceed();
507
-
508
- const outputContent = await fs.readFile(variantOutputFile, 'utf-8');
509
- const outputData = JSON.parse(outputContent);
510
-
511
- // Verify all resources that should match are included using ResourceCollectionDecl format
512
- expect(
513
- TsRes.ResourceJson.Convert.resourceCollectionDecl.convert(outputData.resources)
514
- ).toSucceedAndSatisfy((resources) => {
515
- const resourceIds = resources?.resources?.map((r) => r.id) || [];
516
- expect(resourceIds).toContain('variant-input.msg.hello');
517
- expect(resourceIds).toContain('variant-input.btn.save');
518
-
519
- // Verify msg.hello contains English variants but not French/Spanish
520
- const helloResource = resources?.resources?.find((r) => r.id === 'variant-input.msg.hello');
521
- expect(helloResource).toBeDefined();
522
-
523
- // Should include English variants (fuzzy matching)
524
- const hasEnUS = helloResource?.candidates?.some((c) =>
525
- c.conditions?.some((cond) => cond.qualifierName === 'language' && cond.value === 'en-US')
526
- );
527
- expect(hasEnUS).toBe(true);
528
-
529
- const hasEnCA = helloResource?.candidates?.some((c) =>
530
- c.conditions?.some((cond) => cond.qualifierName === 'language' && cond.value === 'en-CA')
531
- );
532
- expect(hasEnCA).toBe(true);
533
-
534
- const hasEnAU = helloResource?.candidates?.some((c) =>
535
- c.conditions?.some((cond) => cond.qualifierName === 'language' && cond.value === 'en-AU')
536
- );
537
- expect(hasEnAU).toBe(true);
538
-
539
- const hasGenericEn = helloResource?.candidates?.some((c) =>
540
- c.conditions?.some((cond) => cond.qualifierName === 'language' && cond.value === 'en')
541
- );
542
- expect(hasGenericEn).toBe(true);
543
-
544
- // Should NOT include French or Spanish variants
545
- const hasFrench = helloResource?.candidates?.some((c) =>
546
- c.conditions?.some((cond) => cond.qualifierName === 'language' && cond.value.includes('fr'))
547
- );
548
- expect(hasFrench).toBe(false);
549
-
550
- const hasSpanish = helloResource?.candidates?.some((c) =>
551
- c.conditions?.some((cond) => cond.qualifierName === 'language' && cond.value.includes('es'))
552
- );
553
- expect(hasSpanish).toBe(false);
554
-
555
- // Verify btn.save only has English variants
556
- const saveResource = resources?.resources?.find((r) => r.id === 'variant-input.btn.save');
557
- expect(saveResource).toBeDefined();
558
-
559
- const saveHasEnUS = saveResource?.candidates?.some((c) =>
560
- c.conditions?.some((cond) => cond.qualifierName === 'language' && cond.value === 'en-US')
561
- );
562
- expect(saveHasEnUS).toBe(true);
563
-
564
- const saveHasFrench = saveResource?.candidates?.some((c) =>
565
- c.conditions?.some((cond) => cond.qualifierName === 'language' && cond.value.includes('fr'))
566
- );
567
- expect(saveHasFrench).toBe(false);
568
-
569
- return true;
570
- });
571
-
572
- // Verify metadata shows appropriate filtering
573
- expect(outputData.metadata.totalCandidates).toBe(11); // All input candidates
574
- // Should filter to: 4 English msg.hello + 1 English btn.save = 5
575
- expect(outputData.metadata.filteredCandidates).toBe(5);
576
- expect(outputData.metadata.totalResources).toBe(2); // 2 unique resource IDs
577
- expect(outputData.metadata.filteredResources).toBe(2); // All 2 resources have matching candidates
578
- });
579
- test('compiles with metadata included', async () => {
580
- const options: ICompileOptions = {
581
- input: inputFile,
582
- output: outputFile,
583
- format: 'source',
584
- debug: false,
585
- verbose: false,
586
- quiet: false,
587
- validate: true,
588
- includeMetadata: true
589
- };
590
-
591
- const compiler = new ResourceCompiler(options);
592
- const result = await compiler.compile();
593
-
594
- expect(result).toSucceed();
595
-
596
- const outputContent = await fs.readFile(outputFile, 'utf-8');
597
- const outputData = JSON.parse(outputContent);
598
-
599
- expect(outputData).toHaveProperty('metadata');
600
- expect(outputData.metadata).toHaveProperty('totalResources');
601
- expect(outputData.metadata).toHaveProperty('totalCandidates');
602
- });
603
-
604
- test('compiles to JavaScript format', async () => {
605
- const jsOutputFile = path.join(tempDir, 'output.js');
606
- const options: ICompileOptions = {
607
- input: inputFile,
608
- output: jsOutputFile,
609
- format: 'js',
610
- debug: false,
611
- verbose: false,
612
- quiet: false,
613
- validate: true,
614
- includeMetadata: false
615
- };
616
-
617
- const compiler = new ResourceCompiler(options);
618
- const result = await compiler.compile();
619
-
620
- expect(result).toSucceed();
621
-
622
- const outputContent = await fs.readFile(jsOutputFile, 'utf-8');
623
- expect(outputContent).toMatch(/^module\.exports = /);
624
- });
625
-
626
- test('compiles to TypeScript format', async () => {
627
- const tsOutputFile = path.join(tempDir, 'output.ts');
628
- const options: ICompileOptions = {
629
- input: inputFile,
630
- output: tsOutputFile,
631
- format: 'ts',
632
-
633
- debug: false,
634
- verbose: false,
635
- quiet: false,
636
- validate: true,
637
- includeMetadata: false
638
- };
639
-
640
- const compiler = new ResourceCompiler(options);
641
- const result = await compiler.compile();
642
-
643
- expect(result).toSucceed();
644
-
645
- const outputContent = await fs.readFile(tsOutputFile, 'utf-8');
646
- expect(outputContent).toMatch(/^export const resources = /);
647
- expect(outputContent).toMatch(/ as const;$/);
648
- });
649
-
650
- test('compiled format can be reconstructed as runtime ICompiledResourceCollection', async () => {
651
- const options: ICompileOptions = {
652
- input: inputFile,
653
- output: outputFile,
654
- format: 'compiled',
655
- debug: false,
656
- verbose: false,
657
- quiet: false,
658
- validate: true,
659
- includeMetadata: false
660
- };
661
-
662
- const compiler = new ResourceCompiler(options);
663
- const result = await compiler.compile();
664
- expect(result).toSucceed();
665
-
666
- const outputContent = await fs.readFile(outputFile, 'utf-8');
667
- const compiledCollectionData = JSON.parse(outputContent);
668
-
669
- // Validate the structure using the converter
670
- expect(
671
- TsRes.ResourceJson.Compiled.Convert.compiledResourceCollection.convert(compiledCollectionData)
672
- ).toSucceed();
673
- });
674
-
675
- test('compiled format produces different output than source format', async () => {
676
- const compiledOutputFile = path.join(tempDir, 'compiled.json');
677
- const sourceOutputFile = path.join(tempDir, 'source.json');
678
-
679
- const baseOptions: ICompileOptions = {
680
- input: inputFile,
681
- output: '',
682
- format: 'compiled',
683
- debug: false,
684
- verbose: false,
685
- quiet: false,
686
- validate: true,
687
- includeMetadata: false
688
- };
689
-
690
- // Compile to compiled format
691
- const compiledCompiler = new ResourceCompiler({
692
- ...baseOptions,
693
- output: compiledOutputFile,
694
- format: 'compiled'
695
- });
696
- const compiledResult = await compiledCompiler.compile();
697
- expect(compiledResult).toSucceed();
698
-
699
- // Compile to source format
700
- const sourceCompiler = new ResourceCompiler({
701
- ...baseOptions,
702
- output: sourceOutputFile,
703
- format: 'source'
704
- });
705
- const sourceResult = await sourceCompiler.compile();
706
- expect(sourceResult).toSucceed();
707
-
708
- // Read both outputs
709
- const compiledContent = await fs.readFile(compiledOutputFile, 'utf-8');
710
- const sourceContent = await fs.readFile(sourceOutputFile, 'utf-8');
711
-
712
- const compiledData = JSON.parse(compiledContent);
713
- const sourceData = JSON.parse(sourceContent);
714
-
715
- // They should be different formats
716
- expect(compiledData).not.toEqual(sourceData);
717
-
718
- // Validate compiled format using ts-res converter
719
- const compiledValidation =
720
- TsRes.ResourceJson.Compiled.Convert.compiledResourceCollection.convert(compiledData);
721
- expect(compiledValidation).toSucceedAndSatisfy((validCompiledCollection) => {
722
- expect(validCompiledCollection.qualifierTypes).toBeDefined();
723
- expect(validCompiledCollection.qualifiers).toBeDefined();
724
- expect(validCompiledCollection.resourceTypes).toBeDefined();
725
- expect(validCompiledCollection.conditions).toBeDefined();
726
- expect(validCompiledCollection.conditionSets).toBeDefined();
727
- expect(validCompiledCollection.decisions).toBeDefined();
728
- expect(validCompiledCollection.resources).toBeDefined();
729
- expect(validCompiledCollection.resources.length).toBeGreaterThan(0);
730
- });
731
-
732
- // Source format should have legacy blob structure
733
- expect(sourceData).toHaveProperty('resources');
734
- expect(sourceData).not.toHaveProperty('qualifierTypes');
735
- expect(sourceData).not.toHaveProperty('conditions');
736
- expect(sourceData).not.toHaveProperty('conditionSets');
737
- expect(sourceData).not.toHaveProperty('decisions');
738
-
739
- // Source format resources should be in ResourceCollectionDecl format
740
- expect(
741
- TsRes.ResourceJson.Convert.resourceCollectionDecl.convert(sourceData.resources)
742
- ).toSucceedAndSatisfy((resources) => {
743
- const messageResource = resources?.resources?.find((r) => r.id === 'input.test.message');
744
- expect(messageResource).toBeDefined();
745
- return true;
746
- });
747
- });
748
- test('fails with invalid input file', async () => {
749
- const options: ICompileOptions = {
750
- input: '/nonexistent/file.json',
751
- output: outputFile,
752
- format: 'source',
753
- debug: false,
754
- verbose: false,
755
- quiet: false,
756
- validate: true,
757
- includeMetadata: false
758
- };
759
-
760
- const compiler = new ResourceCompiler(options);
761
- const result = await compiler.compile();
762
-
763
- expect(result).toFailWith(/Failed to load resources/);
764
- });
765
-
766
- test('handles directory input', async () => {
767
- const inputDir = path.join(tempDir, 'resources');
768
- await fs.mkdir(inputDir);
769
-
770
- const resource1 = path.join(inputDir, 'messages.json');
771
- const resource2 = path.join(inputDir, 'buttons.json');
772
-
773
- await fs.writeFile(
774
- resource1,
775
- JSON.stringify({
776
- resources: [
777
- {
778
- id: 'msg.hello',
779
- resourceTypeName: 'json',
780
- candidates: [
781
- {
782
- json: { text: 'Hello' },
783
- conditions: { language: 'en' }
784
- }
785
- ]
786
- }
787
- ]
788
- })
789
- );
790
-
791
- await fs.writeFile(
792
- resource2,
793
- JSON.stringify({
794
- resources: [
795
- {
796
- id: 'btn.ok',
797
- resourceTypeName: 'json',
798
- candidates: [
799
- {
800
- json: { label: 'OK' },
801
- conditions: { language: 'en' }
802
- }
803
- ]
804
- }
805
- ]
806
- })
807
- );
808
-
809
- const options: ICompileOptions = {
810
- input: inputDir,
811
- output: outputFile,
812
- format: 'source',
813
- debug: false,
814
- verbose: false,
815
- quiet: false,
816
- validate: true,
817
- includeMetadata: false
818
- };
819
-
820
- const compiler = new ResourceCompiler(options);
821
- const result = await compiler.compile();
822
-
823
- expect(result).toSucceed();
824
-
825
- const outputContent = await fs.readFile(outputFile, 'utf-8');
826
- const outputData = JSON.parse(outputContent);
827
-
828
- expect(
829
- TsRes.ResourceJson.Convert.resourceCollectionDecl.convert(outputData.resources)
830
- ).toSucceedAndSatisfy((resources) => {
831
- const resourceIds = resources?.resources?.map((r) => r.id) || [];
832
- expect(resourceIds).toContain('resources.messages.msg.hello');
833
- expect(resourceIds).toContain('resources.buttons.btn.ok');
834
- return true;
835
- });
836
- });
837
- });
838
-
839
- describe('idempotency and stability tests', () => {
840
- test('produces identical output when compiled multiple times', async () => {
841
- const options: ICompileOptions = {
842
- input: inputFile,
843
- output: outputFile,
844
- format: 'source',
845
- debug: false,
846
- verbose: false,
847
- quiet: false,
848
- validate: true,
849
- includeMetadata: true
850
- };
851
-
852
- // First compilation
853
- const compiler1 = new ResourceCompiler(options);
854
- const result1 = await compiler1.compile();
855
- expect(result1).toSucceed();
856
-
857
- const output1 = await fs.readFile(outputFile, 'utf-8');
858
- const data1 = JSON.parse(output1);
859
-
860
- // Second compilation with same input
861
- const outputFile2 = path.join(tempDir, 'output2.json');
862
- const options2 = { ...options, output: outputFile2 };
863
- const compiler2 = new ResourceCompiler(options2);
864
- const result2 = await compiler2.compile();
865
- expect(result2).toSucceed();
866
-
867
- const output2 = await fs.readFile(outputFile2, 'utf-8');
868
- const data2 = JSON.parse(output2);
869
-
870
- // Outputs should be identical
871
- expect(data1).toEqual(data2);
872
- });
873
-
874
- test('produces deterministic output regardless of compilation order', async () => {
875
- // Test with a directory containing multiple files to ensure
876
- // file processing order doesn't affect output
877
- const testDir = path.join(tempDir, 'multi-files');
878
- await fs.mkdir(testDir, { recursive: true });
879
-
880
- // Create multiple resource files
881
- const file1 = path.join(testDir, 'first.json');
882
- const file2 = path.join(testDir, 'second.json');
883
- const file3 = path.join(testDir, 'third.json');
884
-
885
- await fs.writeFile(
886
- file1,
887
- JSON.stringify({
888
- resources: [
889
- {
890
- id: 'msg.hello',
891
- resourceTypeName: 'json',
892
- candidates: [
893
- {
894
- json: { text: 'Hello' },
895
- conditions: { language: 'en' }
896
- }
897
- ]
898
- }
899
- ]
900
- })
901
- );
902
- await fs.writeFile(
903
- file2,
904
- JSON.stringify({
905
- resources: [
906
- {
907
- id: 'msg.goodbye',
908
- resourceTypeName: 'json',
909
- candidates: [
910
- {
911
- json: { text: 'Goodbye' },
912
- conditions: { language: 'en' }
913
- }
914
- ]
915
- }
916
- ]
917
- })
918
- );
919
- await fs.writeFile(
920
- file3,
921
- JSON.stringify({
922
- resources: [
923
- {
924
- id: 'msg.welcome',
925
- resourceTypeName: 'json',
926
- candidates: [
927
- {
928
- json: { text: 'Welcome' },
929
- conditions: { language: 'en' }
930
- }
931
- ]
932
- }
933
- ]
934
- })
935
- );
936
-
937
- const options: ICompileOptions = {
938
- input: testDir,
939
- output: path.join(tempDir, 'deterministic-output.json'),
940
- format: 'source',
941
- debug: false,
942
- verbose: false,
943
- quiet: false,
944
- validate: true,
945
- includeMetadata: false
946
- };
947
-
948
- // Compile multiple times
949
- const outputs: string[] = [];
950
- for (let i = 0; i < 3; i++) {
951
- const outputFile = path.join(tempDir, `deterministic-${i}.json`);
952
- const compiler = new ResourceCompiler({ ...options, output: outputFile });
953
- const result = await compiler.compile();
954
- expect(result).toSucceed();
955
-
956
- const output = await fs.readFile(outputFile, 'utf-8');
957
- outputs.push(output);
958
- }
959
-
960
- // All outputs should be identical
961
- expect(outputs[0]).toBe(outputs[1]);
962
- expect(outputs[1]).toBe(outputs[2]);
963
- });
964
-
965
- test('produces identical output across different format conversions', async () => {
966
- const baseOptions: ICompileOptions = {
967
- input: inputFile,
968
- output: '', // Will be set per format
969
- format: 'source',
970
- debug: false,
971
- verbose: false,
972
- quiet: false,
973
- validate: true,
974
- includeMetadata: false
975
- };
976
-
977
- // Test source format
978
- const sourceOutput = path.join(tempDir, 'format-test.json');
979
- const sourceCompiler = new ResourceCompiler({ ...baseOptions, output: sourceOutput, format: 'source' });
980
- const sourceResult = await sourceCompiler.compile();
981
- expect(sourceResult).toSucceed();
982
-
983
- // Test JS format
984
- const jsOutput = path.join(tempDir, 'format-test.js');
985
- const jsCompiler = new ResourceCompiler({ ...baseOptions, output: jsOutput, format: 'js' });
986
- const jsResult = await jsCompiler.compile();
987
- expect(jsResult).toSucceed();
988
-
989
- // Test TS format
990
- const tsOutput = path.join(tempDir, 'format-test.ts');
991
- const tsCompiler = new ResourceCompiler({ ...baseOptions, output: tsOutput, format: 'ts' });
992
- const tsResult = await tsCompiler.compile();
993
- expect(tsResult).toSucceed();
994
-
995
- // Parse the source output as baseline
996
- const sourceContent = await fs.readFile(sourceOutput, 'utf-8');
997
- const sourceData = JSON.parse(sourceContent);
998
-
999
- // Verify JS format contains same data
1000
- const jsContent = await fs.readFile(jsOutput, 'utf-8');
1001
- expect(jsContent).toMatch(/^module\.exports = /);
1002
- // Extract the data part from "module.exports = {...};"
1003
- const jsDataStr = jsContent.replace(/^module\.exports = /, '').replace(/;$/, '');
1004
- const jsData = JSON.parse(jsDataStr);
1005
- expect(jsData).toEqual(sourceData);
1006
-
1007
- // Verify TS format contains same data
1008
- const tsContent = await fs.readFile(tsOutput, 'utf-8');
1009
- expect(tsContent).toMatch(/^export const resources = /);
1010
- // Extract the data part from "export const resources = {...} as const;"
1011
- const tsDataStr = tsContent.replace(/^export const resources = /, '').replace(/ as const;$/, '');
1012
- const tsData = JSON.parse(tsDataStr);
1013
- expect(tsData).toEqual(sourceData);
1014
- });
1015
- });
1016
-
1017
- describe('validate method', () => {
1018
- test('validates resources successfully', async () => {
1019
- const options: ICompileOptions = {
1020
- input: inputFile,
1021
- output: outputFile,
1022
- format: 'source',
1023
- debug: false,
1024
- verbose: false,
1025
- quiet: false,
1026
- validate: true,
1027
- includeMetadata: false
1028
- };
1029
-
1030
- const compiler = new ResourceCompiler(options);
1031
- const result = await compiler.validate();
1032
-
1033
- expect(result).toSucceed();
1034
- });
1035
- });
1036
-
1037
- describe('getInfo method', () => {
1038
- test('returns resource information', async () => {
1039
- const options: ICompileOptions = {
1040
- input: inputFile,
1041
- output: outputFile,
1042
- format: 'source',
1043
- debug: false,
1044
- verbose: false,
1045
- quiet: false,
1046
- validate: true,
1047
- includeMetadata: true
1048
- };
1049
-
1050
- const compiler = new ResourceCompiler(options);
1051
- const result = await compiler.getInfo();
1052
-
1053
- expect(result).toSucceedAndSatisfy((info) => {
1054
- expect(info.totalResources).toBeGreaterThan(0);
1055
- expect(info.totalCandidates).toBeGreaterThan(0);
1056
- expect(info.resourceTypes).toContain('json');
1057
- expect(info.qualifiers).toContain('language');
1058
- });
1059
- });
1060
-
1061
- test('returns filtered resource information', async () => {
1062
- const options: ICompileOptions = {
1063
- input: inputFile,
1064
- output: outputFile,
1065
- contextFilter: 'language=en',
1066
- format: 'source',
1067
- debug: false,
1068
- verbose: false,
1069
- quiet: false,
1070
- validate: true,
1071
- includeMetadata: true
1072
- };
1073
-
1074
- const compiler = new ResourceCompiler(options);
1075
- const result = await compiler.getInfo();
1076
-
1077
- expect(result).toSucceedAndSatisfy((info) => {
1078
- expect(info.totalResources).toBeGreaterThan(0);
1079
- expect(info.filteredResources).toBeGreaterThan(0);
1080
- expect(info.context).toEqual({ language: 'en' });
1081
- });
1082
- });
1083
- });
1084
- });