diff --git a/packages/angular/build/src/builders/application/i18n.ts b/packages/angular/build/src/builders/application/i18n.ts index 137336497885..f212659a14be 100644 --- a/packages/angular/build/src/builders/application/i18n.ts +++ b/packages/angular/build/src/builders/application/i18n.ts @@ -8,6 +8,7 @@ import { BuilderContext } from '@angular-devkit/architect'; import type { Metafile } from 'esbuild'; +import assert from 'node:assert'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { @@ -74,7 +75,7 @@ export async function inlineI18n( ); try { - for (const locale of i18nOptions.inlineLocales) { + const localesToInline = Array.from(i18nOptions.inlineLocales, (locale) => { const localeDescription = i18nOptions.locales[locale]; let translationIntegrity: string | undefined = ''; for (const file of localeDescription.files) { @@ -85,12 +86,18 @@ export async function inlineI18n( translationIntegrity += (translationIntegrity ? '|' : '') + file.integrity; } - // A locale specific set of files is returned from the inliner. - const localeInlineResult = await inliner.inlineForLocale( + return { locale, - localeDescription.translation, + translation: localeDescription.translation, translationIntegrity, - ); + }; + }); + + const inlinedLocales = await inliner.inlineAll(localesToInline); + + for (const locale of i18nOptions.inlineLocales) { + const localeInlineResult = inlinedLocales.get(locale); + assert(localeInlineResult !== undefined, 'Inlined result must exist for locale: ' + locale); const localeOutputFiles = localeInlineResult.outputFiles; inlineResult.errors.push(...localeInlineResult.errors); inlineResult.warnings.push(...localeInlineResult.warnings); diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts index b95f4eae53a9..379368088ad0 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts @@ -64,10 +64,45 @@ interface InlineCodeRequest { translation?: Blob; } +/** + * The options passed to the inliner for a batch file request + */ +interface InlineFileBatchRequest { + /** + * The filename that should be processed. The data for the file is provided to the Worker + * during Worker initialization. + */ + filename: string; + + /** + * The locale specifiers or locale objects that should be used during the inlining process of the file. + */ + locales: (string | { locale: string; translation?: Blob })[]; +} + +/** + * The result for a single locale within a batch file request. + */ +interface InlineLocaleResult { + locale: string; + code: string; + map?: string; + messages: { type: 'error' | 'warning'; message: string }[]; +} + +/** + * The response returned from a batch file request. + */ +interface InlineFileBatchResult { + file: string; + results: InlineLocaleResult[]; +} + // Extract the application files and common options used for inline requests from the Worker context -const { files, missingTranslation } = (workerData || {}) as { +const { files, missingTranslation, translations } = (workerData || {}) as { files: ReadonlyMap; missingTranslation: 'error' | 'warning' | 'ignore'; + translations?: ReadonlyMap; }; /** @@ -113,24 +148,30 @@ function getFileData(filename: string): Promise { } /** - * Deserializes the translation messages for an inline request, reusing the result for any + * Deserializes the translation messages for a locale, reusing the result for any * subsequent request that targets the same locale. - * @param request An inline request containing the locale and its serialized messages. + * @param locale The locale identifier. + * @param translation Optional serialized translation messages. If omitted, workerData.translations is used. * @returns The translation messages, or undefined if the locale has no translations. */ function loadTranslation( - request: InlineFileRequest | InlineCodeRequest, + locale: string, + translation?: Blob, ): Promise> | undefined { - const { locale, translation } = request; - if (!translation) { + const translationBlob = translation ?? translations?.get(locale); + if (!translationBlob) { return undefined; } let messagesPromise = deserializedTranslations.get(locale); if (!messagesPromise) { - messagesPromise = translation + messagesPromise = translationBlob .arrayBuffer() - .then((buffer) => deserialize(new Uint8Array(buffer)) as Record); + .then((buffer) => deserialize(new Uint8Array(buffer)) as Record) + .catch((error) => { + deserializedTranslations.delete(locale); + throw error; + }); deserializedTranslations.set(locale, messagesPromise); } @@ -149,8 +190,6 @@ export default async function inlineFile(request: InlineFileRequest) { // Sourcemaps are parsed on demand per request rather than cached long-term to prevent // monotonic memory growth as a worker processes multiple files across the build. - // When multi-locale batching is implemented, the sourcemap can be parsed once per batch and released - // upon batch completion. const rawMap = await files.get(request.filename + '.map')?.text(); const map = rawMap ? (JSON.parse(rawMap) as SourceMapInput) : undefined; @@ -159,7 +198,7 @@ export default async function inlineFile(request: InlineFileRequest) { map, metadata, request.locale, - await loadTranslation(request), + await loadTranslation(request.locale, request.translation), request.filename, ); @@ -171,6 +210,50 @@ export default async function inlineFile(request: InlineFileRequest) { }; } +/** + * Inlines multiple locales and translations into a JavaScript file that contains `$localize` usage. + * + * @param request An InlineFileBatchRequest object representing the options for inlining. + * @returns An object containing the inlined results for each requested locale. + */ +export async function inlineFileBatch( + request: InlineFileBatchRequest, +): Promise { + const { code, metadata } = await getFileData(request.filename); + + // Parse the sourcemap once for the entire batch. + // It will naturally be garbage-collected after this batch action returns. + const rawMap = await files.get(request.filename + '.map')?.text(); + const map = rawMap ? (JSON.parse(rawMap) as SourceMapInput) : undefined; + + const results = await Promise.all( + request.locales.map(async (entry) => { + const locale = typeof entry === 'string' ? entry : entry.locale; + const translation = typeof entry === 'string' ? undefined : entry.translation; + const result = await inlineLocalize( + code, + map, + metadata, + locale, + await loadTranslation(locale, translation), + request.filename, + ); + + return { + locale, + code: result.code, + map: result.map, + messages: result.diagnostics.messages, + }; + }), + ); + + return { + file: request.filename, + results, + }; +} + /** * Inlines the provided locale and translation into JavaScript code that contains `$localize` usage. * This function is a secondary entry primarily for use with component HMR update modules. @@ -185,7 +268,7 @@ export async function inlineCode(request: InlineCodeRequest) { undefined, metadata, request.locale, - await loadTranslation(request), + await loadTranslation(request.locale, request.translation), request.filename, ); diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts index a65d28516cce..535aae960b34 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner.ts @@ -43,6 +43,71 @@ export interface I18nInlinerOptions { shouldOptimize?: boolean; persistentCachePath?: string; localizeVersion?: string; + translations?: ReadonlyMap; +} + +/** + * Options for inlining a specific locale. + */ +export interface LocaleInlineOptions { + /** + * The locale specifier string. + */ + locale: string; + + /** + * The translation messages for the locale, or undefined for the source/untranslated locale. + */ + translation?: Record; + + /** + * An optional content integrity hash of the translation file(s) for fast cache key calculation. + */ + translationIntegrity?: string; +} + +/** + * Result of inlining for a specific locale. + */ +export interface LocaleInlineResult { + outputFiles: BuildOutputFile[]; + errors: string[]; + warnings: string[]; +} + +/** + * Transformation result for a single file and locale combination. + */ +interface TransformedFileResult { + file: string; + code: string; + map?: string; + messages: { type: 'error' | 'warning'; message: string }[]; +} + +/** + * Represents an in-flight asynchronous cache lookup for a single (file x locale) transformation. + */ +interface CacheCheckItem { + /** + * The relative file path of the JavaScript file to transform. + */ + filename: string; + + /** + * The locale specifier being targeted for translation. + */ + locale: string; + + /** + * The computed cache key hash, or undefined if persistent caching is not configured. + */ + cacheKey: string | undefined; + + /** + * A promise that resolves to the cached transform result, or null if uncached or on lookup failure. + */ + cachedResult: Promise; } /** @@ -63,7 +128,7 @@ export class I18nInliner { maxThreads?: number, ) { this.#unmodifiedFiles = []; - const { outputFiles, shouldOptimize, missingTranslation } = options; + const { outputFiles, shouldOptimize, missingTranslation, translations } = options; const files = new Map(); const pendingMaps = []; @@ -115,6 +180,7 @@ export class I18nInliner { workerData: { missingTranslation, shouldOptimize, + translations, // A Blob is an immutable data structure that allows sharing the data between workers // without copying until the data is actually used within a Worker. This is useful here // since each file may not actually be processed in each Worker and the Blob avoids @@ -127,120 +193,259 @@ export class I18nInliner { } /** - * Performs inlining of translations for the provided locale and translations. The files that - * are processed originate from the files passed to the class constructor and filter by presence - * of the localize function keyword. - * @param locale The string representing the locale to inline. - * @param translation The translation messages to use when inlining. - * @param translationIntegrity An optional integrity value for the translation messages to use for caching. - * @returns A promise that resolves to an array of OutputFiles representing a translated result. + * Performs inlining of translations across multiple locales in parallel. + * + * An adaptive 2D task-partitioning algorithm distributes (files x locales) work units + * across all worker threads while caching AST metadata and sourcemaps in worker memory. + * + * @param locales The locales and translations to inline. + * @returns A map of locale names to their inlined output files and diagnostics. */ - async inlineForLocale( - locale: string, - translation: Record | undefined, - translationIntegrity?: string, - ): Promise<{ outputFiles: BuildOutputFile[]; errors: string[]; warnings: string[] }> { + async inlineAll( + locales: Iterable, + ): Promise> { await this.initCache(); - const { shouldOptimize, missingTranslation } = this.options; + const { shouldOptimize, missingTranslation, localizeVersion } = this.options; + const localeList = Array.from(locales); - // Serialized once here and then shared by the request for every file of this locale - const translationBlob = serializeTranslation(translation); + if (localeList.length === 0) { + return new Map(); + } - // Request inlining for each file that contains localize calls - const requests = []; + const fileResultsByLocale = new Map>(); + for (const { locale } of localeList) { + fileResultsByLocale.set(locale, new Map()); + } - let fileCacheKeyBase: string | undefined; + // Pre-calculate cache key bases and serialized Blobs for each requested locale + const localeCacheBases = new Map(); + const localeBlobs = new Map(); - for (const [filename, file] of this.#localizeFiles) { - let cacheKey: string | undefined; - if (filename.endsWith('.map')) { - continue; - } + for (const { locale, translation, translationIntegrity } of localeList) { + localeBlobs.set(locale, serializeTranslation(translation)); - let cacheResultPromise = Promise.resolve(null); if (this.#cache) { - // The options are digested here so that each file's key is derived from a fixed number - // of bytes. Hashing the options directly would re-hash the full set of messages, which - // can be several megabytes, once for every file. - fileCacheKeyBase ??= calculateHash( - JSON.stringify({ - locale, - translation: translationIntegrity ?? translation, - missingTranslation, - shouldOptimize, - localizeVersion: this.options.localizeVersion, - }), + localeCacheBases.set( + locale, + calculateHash( + JSON.stringify({ + locale, + translation: translationIntegrity || translation, + missingTranslation, + shouldOptimize, + localizeVersion, + }), + ), ); + } + } - // NOTE: If additional options are added, this may need to be updated. - const hasher = createContentHash(); - hasher.update(file.hash); - hasher.update(filename); - hasher.update(fileCacheKeyBase); - cacheKey = hasher.digest(); + const filenames = Array.from(this.#localizeFiles.keys()).filter( + (name) => !name.endsWith('.map'), + ); - // Failure to get the value should not fail the transform - cacheResultPromise = this.#cache.get(cacheKey).catch(() => null); - } + const cacheChecks: CacheCheckItem[] = []; - const fileResult = cacheResultPromise.then(async (cachedResult) => { - if (cachedResult) { - return cachedResult; + for (const filename of filenames) { + const file = this.#localizeFiles.get(filename); + assert(file !== undefined, 'Localize file must exist: ' + filename); + + for (const { locale } of localeList) { + let cacheKey: string | undefined; + let cachedResultPromise: Promise = Promise.resolve(null); + + if (this.#cache) { + const fileCacheKeyBase = localeCacheBases.get(locale); + assert(fileCacheKeyBase !== undefined, 'Cache base must exist for locale: ' + locale); + + const hasher = createContentHash(); + hasher.update(file.hash); + hasher.update(filename); + hasher.update(fileCacheKeyBase); + cacheKey = hasher.digest(); + + cachedResultPromise = this.#cache.get(cacheKey).catch(() => null); } - const result = await this.#workerPool.run({ + cacheChecks.push({ filename, locale, - translation: translationBlob, + cacheKey, + cachedResult: cachedResultPromise, }); - if (this.#cache && cacheKey) { - try { - // Failure to set the value should not fail the transform - await this.#cache.set(cacheKey, result); - } catch {} - } + } + } - return result; - }); + // Await all cache checks + const resolvedChecks = await Promise.all( + cacheChecks.map(async (item) => ({ + ...item, + result: await item.cachedResult, + })), + ); + + // Group uncached items by filename + const uncachedByFile = new Map< + string, + Array<{ locale: string; cacheKey?: string; translation?: Blob }> + >(); - requests.push(fileResult); + for (const item of resolvedChecks) { + if (item.result) { + // Cache hit: store directly in locale file results + fileResultsByLocale.get(item.locale)?.set(item.filename, item.result); + } else { + // Cache miss: needs worker processing + let fileEntries = uncachedByFile.get(item.filename); + if (!fileEntries) { + fileEntries = []; + uncachedByFile.set(item.filename, fileEntries); + } + fileEntries.push({ + locale: item.locale, + cacheKey: item.cacheKey, + translation: localeBlobs.get(item.locale), + }); + } } - // Wait for all file requests to complete - const rawResults = await Promise.all(requests); + // Adaptive 2D Sharding for uncached tasks + if (uncachedByFile.size > 0) { + await this.#processUncachedBatches(uncachedByFile, localeList.length, fileResultsByLocale); + } - // Convert raw results to output file objects and include all unmodified files - const errors: string[] = []; - const warnings: string[] = []; - const outputFiles = [ - ...rawResults.flatMap(({ file, code, map, messages }) => { - const type = this.#localizeFiles.get(file)?.type; - assert(type !== undefined, 'localized file should always have a type' + file); - - const resultFiles = [createOutputFile(file, code, type)]; - if (map) { - resultFiles.push(createOutputFile(file + '.map', map, type)); - } + // Assemble final results in deterministic order per locale + const resultsByLocale = new Map(); + + for (const { locale } of localeList) { + const fileResults = fileResultsByLocale.get(locale); + const outputFiles: BuildOutputFile[] = []; + const errors: string[] = []; + const warnings: string[] = []; + + if (fileResults) { + for (const filename of filenames) { + const fileResult = fileResults.get(filename); + if (!fileResult) { + continue; + } + + const type = this.#localizeFiles.get(filename)?.type; + assert(type !== undefined, 'localized file should always have a type: ' + filename); - for (const message of messages) { - if (message.type === 'error') { - errors.push(message.message); - } else { - warnings.push(message.message); + outputFiles.push(createOutputFile(filename, fileResult.code, type)); + if (fileResult.map) { + outputFiles.push(createOutputFile(filename + '.map', fileResult.map, type)); + } + + for (const message of fileResult.messages) { + if (message.type === 'error') { + errors.push(message.message); + } else { + warnings.push(message.message); + } } } + } - return resultFiles; - }), - ...this.#unmodifiedFiles.map((file) => file.clone()), - ]; + // Include cloned unmodified files for every locale + outputFiles.push(...this.#unmodifiedFiles.map((file) => file.clone())); - return { - outputFiles, - errors, - warnings, - }; + resultsByLocale.set(locale, { + outputFiles, + errors, + warnings, + }); + } + + return resultsByLocale; + } + + async #processUncachedBatches( + uncachedByFile: Map>, + localeCount: number, + fileResultsByLocale: Map>, + ): Promise { + const workerCount = this.#workerPool.maxThreads || 1; + const targetTaskCount = Math.max(uncachedByFile.size, workerCount * 2); + const localesPerBatch = Math.max( + 1, + Math.ceil(localeCount / (targetTaskCount / (uncachedByFile.size || 1))), + ); + + const workerTasks: Promise[] = []; + + for (const [filename, entries] of uncachedByFile) { + for (let i = 0; i < entries.length; i += localesPerBatch) { + const batchEntries = entries.slice(i, i + localesPerBatch); + const task = (async () => { + const batchResult = (await this.#workerPool.run( + { + filename, + locales: batchEntries.map((e) => ({ + locale: e.locale, + translation: e.translation, + })), + }, + { name: 'inlineFileBatch' }, + )) as { + file: string; + results: Array; + }; + + const cachePromises: Promise[] = []; + for (const res of batchResult.results) { + const matchingEntry = batchEntries.find((e) => e.locale === res.locale); + const cacheKey = matchingEntry?.cacheKey; + + if (this.#cache && cacheKey) { + // `CacheStore.set` may return `this` synchronously or a `Promise`. + // `Promise.resolve` normalizes both return values into a Promise so `Promise.allSettled` + // can safely handle any synchronous or asynchronous cache store errors. + cachePromises.push( + Promise.resolve( + this.#cache.set(cacheKey, { + file: filename, + code: res.code, + map: res.map, + messages: res.messages, + }), + ), + ); + } + + fileResultsByLocale.get(res.locale)?.set(filename, res); + } + await Promise.allSettled(cachePromises); + })(); + + workerTasks.push(task); + } + } + + await Promise.all(workerTasks); + } + + /** + * Performs inlining of translations for the provided locale and translations. The files that + * are processed originate from the files passed to the class constructor and filter by presence + * of the localize function keyword. + * @param locale The string representing the locale to inline. + * @param translation The translation messages to use when inlining. + * @param translationIntegrity An optional integrity value for the translation messages to use for caching. + * @returns A promise that resolves to an array of OutputFiles representing a translated result. + */ + async inlineForLocale( + locale: string, + translation: Record | undefined, + translationIntegrity?: string, + ): Promise { + const results = await this.inlineAll([{ locale, translation, translationIntegrity }]); + const result = results.get(locale); + assert(result !== undefined, `Result for locale '${locale}' should be present.`); + + return result; } async inlineTemplateUpdate( diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts index 94f390e2334c..d71f9c0a8830 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner_spec.ts @@ -7,6 +7,9 @@ */ import { transform } from 'esbuild'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; import { type BuildOutputFile, BuildOutputFileType, createOutputFile } from './bundler-files'; import { I18nInliner } from './i18n-inliner'; @@ -340,4 +343,172 @@ describe('I18nInliner', () => { expect(findFile(outputFiles, 'main.js').text).toContain('"Bonjour"'); expect(findFile(outputFiles, 'main.js').text).not.toContain('$localize'); }); + + it('inlines multiple locales in parallel via inlineAll', async () => { + const localeInliner = createInliner([ + browserFile('main.js', GREETING_SOURCE), + browserFile('chunk.js', GREETING_SOURCE), + browserFile('other.js', 'export const answer = 42;\n'), + ]); + + const results = await localeInliner.inlineAll([ + { locale: 'fr', translation: { greeting: translationFor('Bonjour') } }, + { locale: 'de', translation: { greeting: translationFor('Hallo') } }, + { locale: 'es', translation: { greeting: translationFor('Hola') } }, + { locale: 'en-US', translation: undefined }, + ]); + + expect(results.size).toBe(4); + + const fr = results.get('fr'); + expect(fr).toBeDefined(); + expect(fr?.errors).toEqual([]); + expect(fr?.warnings).toEqual([]); + expect(findFile(fr?.outputFiles ?? [], 'main.js').text).toContain('"Bonjour"'); + expect(findFile(fr?.outputFiles ?? [], 'chunk.js').text).toContain('"Bonjour"'); + expect(findFile(fr?.outputFiles ?? [], 'other.js').text).toBe('export const answer = 42;\n'); + + const de = results.get('de'); + expect(de).toBeDefined(); + expect(de?.errors).toEqual([]); + expect(findFile(de?.outputFiles ?? [], 'main.js').text).toContain('"Hallo"'); + + const es = results.get('es'); + expect(es).toBeDefined(); + expect(es?.errors).toEqual([]); + expect(findFile(es?.outputFiles ?? [], 'main.js').text).toContain('"Hola"'); + + const en = results.get('en-US'); + expect(en).toBeDefined(); + expect(en?.errors).toEqual([]); + expect(findFile(en?.outputFiles ?? [], 'main.js').text).toContain('"Hello"'); + }); + + it('inlines multiple locales with sourcemaps in parallel via inlineAll', async () => { + const { code, map } = await transform(GREETING_SOURCE, { + sourcefile: 'greeting.ts', + loader: 'ts', + sourcemap: 'external', + }); + + const localeInliner = createInliner([ + browserFile('main.js', code), + browserFile('main.js.map', map), + browserFile('other.js', 'export const answer = 42;\n'), + ]); + + const results = await localeInliner.inlineAll([ + { locale: 'fr', translation: { greeting: translationFor('Bonjour') } }, + { locale: 'de', translation: { greeting: translationFor('Hallo') } }, + { locale: 'en-US', translation: undefined }, + ]); + + expect(results.size).toBe(3); + + for (const [locale, greeting] of [ + ['fr', 'Bonjour'], + ['de', 'Hallo'], + ['en-US', 'Hello'], + ] as const) { + const localeResult = results.get(locale); + expect(localeResult).toBeDefined(); + expect(localeResult?.errors).toEqual([]); + expect(localeResult?.warnings).toEqual([]); + + const mainJs = findFile(localeResult?.outputFiles ?? [], 'main.js'); + expect(mainJs.text).toContain(`"${greeting}"`); + + const mainMap = findFile(localeResult?.outputFiles ?? [], 'main.js.map'); + const outputMap = JSON.parse(mainMap.text) as { + version: number; + sources: string[]; + mappings: string; + }; + expect(outputMap.version).toBe(3); + expect(outputMap.sources).toContain('greeting.ts'); + expect(outputMap.mappings.length).toBeGreaterThan(0); + + const otherJs = findFile(localeResult?.outputFiles ?? [], 'other.js'); + expect(otherJs.text).toBe('export const answer = 42;\n'); + } + }); + + it('inlines multiple locales with partial cache hits and misses via inlineAll', async () => { + const cacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'i18n-cache-test-')); + + try { + const initialInliner = new I18nInliner( + { + missingTranslation: 'warning', + outputFiles: [ + browserFile('main.js', GREETING_SOURCE), + browserFile('other.js', 'export const answer = 42;\n'), + ], + persistentCachePath: cacheDir, + }, + 2, + ); + + // Pre-populate cache for 'fr' + await initialInliner.inlineForLocale( + 'fr', + { greeting: translationFor('Bonjour') }, + 'integrity-fr-1', + ); + await initialInliner.close(); + + // Create new inliner with same cache path, inlining cached 'fr' alongside uncached 'de' and 'es' + inliner = new I18nInliner( + { + missingTranslation: 'warning', + outputFiles: [ + browserFile('main.js', GREETING_SOURCE), + browserFile('other.js', 'export const answer = 42;\n'), + ], + persistentCachePath: cacheDir, + }, + 2, + ); + + const results = await inliner.inlineAll([ + { + locale: 'fr', + translation: { greeting: translationFor('Bonjour') }, + translationIntegrity: 'integrity-fr-1', + }, + { + locale: 'de', + translation: { greeting: translationFor('Hallo') }, + translationIntegrity: 'integrity-de-1', + }, + { + locale: 'es', + translation: { greeting: translationFor('Hola') }, + translationIntegrity: 'integrity-es-1', + }, + ]); + + expect(results.size).toBe(3); + + const fr = results.get('fr'); + expect(fr?.errors).toEqual([]); + expect(findFile(fr?.outputFiles ?? [], 'main.js').text).toContain('"Bonjour"'); + expect(findFile(fr?.outputFiles ?? [], 'other.js').text).toBe('export const answer = 42;\n'); + + const de = results.get('de'); + expect(de?.errors).toEqual([]); + expect(findFile(de?.outputFiles ?? [], 'main.js').text).toContain('"Hallo"'); + + const es = results.get('es'); + expect(es?.errors).toEqual([]); + expect(findFile(es?.outputFiles ?? [], 'main.js').text).toContain('"Hola"'); + + // Verify deterministic file order matching input order + for (const localeResult of results.values()) { + expect(localeResult.outputFiles.map((f) => f.path)).toEqual(['main.js', 'other.js']); + } + } finally { + await fs.rm(cacheDir, { recursive: true, force: true }); + } + }); });