Skip to content

Commit 3d7e363

Browse files
mattlewis92alan-agius4
authored andcommitted
test(@angular/build): add unit tests for the i18n inliner
The inliner is covered end to end by the localized builder specs and the `i18n` e2e suite, but `I18nInliner` itself has no unit tests, so behaviour that only shows up across several inline requests is untested. These add that layer: each locale gets its own translations when several are inlined in sequence through one pool, a locale without translations keeps the original messages without reporting them as missing, a locale with translations reports the ones it is missing, a modified file's source map is remapped back to the original sources, and template updates are inlined for both a translated and an untranslated locale. A single thread is used so that every request of every locale is served by the same Worker, which is what makes translation state retained between requests observable. (cherry picked from commit 3c8a54a)
1 parent 689b5b1 commit 3d7e363

1 file changed

Lines changed: 202 additions & 0 deletions

File tree

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { transform } from 'esbuild';
10+
import { type BuildOutputFile, BuildOutputFileType, createOutputFile } from './bundler-files';
11+
import { I18nInliner } from './i18n-inliner';
12+
13+
/**
14+
* A module that uses a `$localize` message with an explicit message identifier so that the
15+
* translations for a test can be keyed by a known name.
16+
*/
17+
const GREETING_SOURCE = 'export const greeting = $localize`:@@greeting:Hello`;\n';
18+
19+
/**
20+
* Creates the parsed translation form that `@angular/localize` expects for a message without
21+
* placeholders.
22+
*/
23+
function translationFor(message: string): Record<string, unknown> {
24+
return { messageParts: [message], placeholderNames: [], text: message };
25+
}
26+
27+
function browserFile(path: string, contents: string): BuildOutputFile {
28+
return createOutputFile(path, contents, BuildOutputFileType.Browser);
29+
}
30+
31+
function findFile(outputFiles: BuildOutputFile[], path: string): BuildOutputFile {
32+
const file = outputFiles.find((output) => output.path === path);
33+
if (!file) {
34+
throw new Error(`Expected output files to contain '${path}'.`);
35+
}
36+
37+
return file;
38+
}
39+
40+
describe('I18nInliner', () => {
41+
let inliner: I18nInliner | undefined;
42+
43+
// A single thread is used throughout so that every file of every locale is inlined by the same
44+
// Worker. Any translation state that a Worker retains between requests is then observable.
45+
function createInliner(outputFiles: BuildOutputFile[]): I18nInliner {
46+
inliner = new I18nInliner({ missingTranslation: 'warning', outputFiles }, 1);
47+
48+
return inliner;
49+
}
50+
51+
afterEach(async () => {
52+
await inliner?.close();
53+
inliner = undefined;
54+
});
55+
56+
it('inlines the translations of a locale', async () => {
57+
const { outputFiles, errors, warnings } = await createInliner([
58+
browserFile('main.js', GREETING_SOURCE),
59+
]).inlineForLocale('fr', { greeting: translationFor('Bonjour') });
60+
61+
expect(errors).toEqual([]);
62+
expect(warnings).toEqual([]);
63+
expect(findFile(outputFiles, 'main.js').text).toContain('"Bonjour"');
64+
expect(findFile(outputFiles, 'main.js').text).not.toContain('$localize');
65+
});
66+
67+
it('inlines the translations of each locale when several are inlined in sequence', async () => {
68+
const localeInliner = createInliner([browserFile('main.js', GREETING_SOURCE)]);
69+
70+
const french = await localeInliner.inlineForLocale('fr', {
71+
greeting: translationFor('Bonjour'),
72+
});
73+
const german = await localeInliner.inlineForLocale('de', { greeting: translationFor('Hallo') });
74+
// Repeats the first locale to cover a locale being inlined again after another has been.
75+
const frenchAgain = await localeInliner.inlineForLocale('fr', {
76+
greeting: translationFor('Bonjour'),
77+
});
78+
79+
expect(findFile(french.outputFiles, 'main.js').text).toContain('"Bonjour"');
80+
expect(findFile(german.outputFiles, 'main.js').text).toContain('"Hallo"');
81+
expect(findFile(frenchAgain.outputFiles, 'main.js').text).toContain('"Bonjour"');
82+
});
83+
84+
it('inlines the translations of a locale into every file that uses them', async () => {
85+
const { outputFiles } = await createInliner([
86+
browserFile('main.js', GREETING_SOURCE),
87+
browserFile('chunk.js', GREETING_SOURCE),
88+
]).inlineForLocale('fr', { greeting: translationFor('Bonjour') });
89+
90+
expect(findFile(outputFiles, 'main.js').text).toContain('"Bonjour"');
91+
expect(findFile(outputFiles, 'chunk.js').text).toContain('"Bonjour"');
92+
});
93+
94+
it('retains the original messages for a locale without translations', async () => {
95+
const { outputFiles, errors, warnings } = await createInliner([
96+
browserFile('main.js', GREETING_SOURCE),
97+
]).inlineForLocale('en-US', undefined);
98+
99+
// A locale without translations is the source locale, so its messages are not missing.
100+
expect(errors).toEqual([]);
101+
expect(warnings).toEqual([]);
102+
expect(findFile(outputFiles, 'main.js').text).toContain('"Hello"');
103+
});
104+
105+
it('warns and retains the original message when a locale is missing a translation', async () => {
106+
const { outputFiles, errors, warnings } = await createInliner([
107+
browserFile('main.js', GREETING_SOURCE),
108+
]).inlineForLocale('fr', { unrelated: translationFor('Sans rapport') });
109+
110+
expect(errors).toEqual([]);
111+
expect(warnings.length).toBe(1);
112+
expect(warnings[0]).toContain('greeting');
113+
expect(findFile(outputFiles, 'main.js').text).toContain('"Hello"');
114+
});
115+
116+
it('replaces the locale placeholder with the locale being inlined', async () => {
117+
// The placeholder is only inlined for files that use `$localize`, which is where the build
118+
// inserts it, so the message is present alongside it here.
119+
const { outputFiles } = await createInliner([
120+
browserFile('main.js', `export const locale = "___NG_LOCALE_INSERT___";\n${GREETING_SOURCE}`),
121+
]).inlineForLocale('fr', { greeting: translationFor('Bonjour') });
122+
123+
expect(findFile(outputFiles, 'main.js').text).toContain('"fr"');
124+
expect(findFile(outputFiles, 'main.js').text).not.toContain('___NG_LOCALE_INSERT___');
125+
});
126+
127+
it('remaps the source map of a file it modifies', async () => {
128+
// esbuild provides a map from the emitted code back to an original file, matching what the
129+
// inliner receives during a build.
130+
const { code, map } = await transform(GREETING_SOURCE, {
131+
sourcefile: 'greeting.ts',
132+
loader: 'ts',
133+
sourcemap: 'external',
134+
});
135+
136+
const { outputFiles } = await createInliner([
137+
browserFile('main.js', code),
138+
browserFile('main.js.map', map),
139+
]).inlineForLocale('fr', { greeting: translationFor('Bonjour') });
140+
141+
const outputMap = JSON.parse(findFile(outputFiles, 'main.js.map').text) as {
142+
version: number;
143+
sources: string[];
144+
mappings: string;
145+
};
146+
147+
expect(outputMap.version).toBe(3);
148+
// The map must still resolve to the original file rather than to the inliner's input.
149+
expect(outputMap.sources).toContain('greeting.ts');
150+
expect(outputMap.mappings.length).toBeGreaterThan(0);
151+
});
152+
153+
describe('inlineTemplateUpdate', () => {
154+
it('inlines the translations of a locale into a template update', async () => {
155+
const { code, errors, warnings } = await createInliner([]).inlineTemplateUpdate(
156+
'fr',
157+
{ greeting: translationFor('Bonjour') },
158+
GREETING_SOURCE,
159+
'template-id',
160+
);
161+
162+
expect(errors).toEqual([]);
163+
expect(warnings).toEqual([]);
164+
expect(code).toContain('"Bonjour"');
165+
expect(code).not.toContain('$localize');
166+
});
167+
168+
it('retains the original messages for a locale without translations', async () => {
169+
const { code, errors, warnings } = await createInliner([]).inlineTemplateUpdate(
170+
'en-US',
171+
undefined,
172+
GREETING_SOURCE,
173+
'template-id',
174+
);
175+
176+
expect(errors).toEqual([]);
177+
expect(warnings).toEqual([]);
178+
expect(code).toContain('"Hello"');
179+
});
180+
181+
it('returns the code untouched when it has no localize calls', async () => {
182+
const source = 'export const answer = 42;\n';
183+
const { code } = await createInliner([]).inlineTemplateUpdate(
184+
'fr',
185+
{ greeting: translationFor('Bonjour') },
186+
source,
187+
'template-id',
188+
);
189+
190+
expect(code).toBe(source);
191+
});
192+
});
193+
194+
it('leaves files without localize calls unmodified', async () => {
195+
const { outputFiles } = await createInliner([
196+
browserFile('main.js', GREETING_SOURCE),
197+
browserFile('other.js', 'export const answer = 42;\n'),
198+
]).inlineForLocale('fr', { greeting: translationFor('Bonjour') });
199+
200+
expect(findFile(outputFiles, 'other.js').text).toBe('export const answer = 42;\n');
201+
});
202+
});

0 commit comments

Comments
 (0)