From 8a337861959ace3e9c51ccd1dbe32b7bbe065f21 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Wed, 11 Feb 2026 11:10:29 -0500 Subject: [PATCH 1/7] ENG-219: Add i18n command Co-Authored-By: Claude Opus 4.6 --- src/cli.ts | 3 + src/commands/i18n.ts | 145 ++++++++++++++++++++++++++++++++++++++ src/models/i18n-config.ts | 14 ++++ 3 files changed, 162 insertions(+) create mode 100644 src/commands/i18n.ts create mode 100644 src/models/i18n-config.ts diff --git a/src/cli.ts b/src/cli.ts index 50ff300..29684e5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,10 @@ import { createApp } from './app.js'; +import { registerI18nCommand } from './commands/i18n.js'; const program = createApp(); +registerI18nCommand(program); + program.parseAsync(process.argv).catch((err) => { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); diff --git a/src/commands/i18n.ts b/src/commands/i18n.ts new file mode 100644 index 0000000..fce7423 --- /dev/null +++ b/src/commands/i18n.ts @@ -0,0 +1,145 @@ +import type { Command } from 'commander'; +import fs from 'fs-extra'; +import path from 'node:path'; +import { getConfig } from '../config.js'; +import * as output from '../utils/output.js'; +import type { I18nResolvedConfig } from '../types.js'; + +/** + * Registers the `i18n` command with the CLI program. + * + * @since TBD + * + * @param {Command} program - The Commander.js program instance. + * + * @returns {void} + */ +export function registerI18nCommand(program: Command): void { + program + .command('i18n') + .description('Fetches language files from a translation API.') + .option('--retries ', 'Number of retries for failed downloads.', '3') + .option('--root ', 'Set the root directory for running commands.') + .action(async (options: { retries?: string; root?: string }) => { + const config = getConfig(options.root); + const i18nConfigs = config.getI18n(); + const cwd = options.root ?? config.getWorkingDir(); + const maxRetries = parseInt(options.retries ?? '3', 10); + + if (i18nConfigs.length === 0) { + output.log('No i18n configuration found. Skipping.'); + return; + } + + for (const i18nConfig of i18nConfigs) { + await fetchTranslations(i18nConfig, cwd, maxRetries); + } + }); +} + +/** + * Fetches and downloads translation files from a GlotPress-compatible API. + * + * @since TBD + * + * @param {I18nResolvedConfig} config - The resolved i18n configuration for this translation source. + * @param {string} cwd - The current working directory. + * @param {number} maxRetries - The maximum number of retry attempts for failed downloads. + * + * @returns {Promise} + */ +async function fetchTranslations( + config: I18nResolvedConfig, + cwd: string, + maxRetries: number +): Promise { + const url = config.url + .replace('{slug}', config.slug) + .replace('%slug%', config.slug); + + output.section(`Fetching translations from ${url}...`); + + let data: TranslationApiResponse; + + try { + const response = await fetch(url); + if (!response.ok) { + output.error(`Failed to fetch translations: HTTP ${response.status}`); + return; + } + data = (await response.json()) as TranslationApiResponse; + } catch (err) { + output.error(`Failed to fetch translation data: ${err}`); + return; + } + + if (!data.translation_sets || !Array.isArray(data.translation_sets)) { + output.error('Invalid translation API response.'); + return; + } + + const minimumPercentage = config.filter.minimum_percentage; + const filteredSets = data.translation_sets.filter( + (set: TranslationSet) => + set.percent_translated >= minimumPercentage + ); + + output.log( + `Found ${filteredSets.length} translations meeting ${minimumPercentage}% threshold.` + ); + + const langDir = path.resolve(cwd, config.path); + await fs.mkdirp(langDir); + + for (const set of filteredSets) { + for (const format of config.formats) { + const filename = config.file_format + .replace('%textdomain%', config.textdomain) + .replace('%locale%', set.locale) + .replace('%wp_locale%', set.wp_locale || set.locale) + .replace('%format%', format); + + const downloadUrl = `${url}/${set.locale}/default/export-translations?format=${format}`; + const destPath = path.join(langDir, filename); + + let success = false; + for (let attempt = 0; attempt < maxRetries && !success; attempt++) { + try { + const response = await fetch(downloadUrl); + if (!response.ok) { + output.warning( + `Failed to download ${filename} (attempt ${attempt + 1}/${maxRetries}): HTTP ${response.status}` + ); + continue; + } + + const buffer = await response.arrayBuffer(); + if (buffer.byteLength === 0) { + output.warning(`Empty response for ${filename}, skipping.`); + break; + } + + await fs.writeFile(destPath, Buffer.from(buffer)); + output.log(`Downloaded: ${filename}`); + success = true; + } catch (err) { + output.warning( + `Error downloading ${filename} (attempt ${attempt + 1}/${maxRetries}): ${err}` + ); + } + } + } + } + + output.success('Translation downloads complete.'); +} + +interface TranslationApiResponse { + translation_sets: TranslationSet[]; +} + +interface TranslationSet { + locale: string; + wp_locale?: string; + percent_translated: number; +} diff --git a/src/models/i18n-config.ts b/src/models/i18n-config.ts new file mode 100644 index 0000000..fc67ea5 --- /dev/null +++ b/src/models/i18n-config.ts @@ -0,0 +1,14 @@ +import type { I18nResolvedConfig } from '../types.js'; + +/** + * Creates a resolved i18n configuration object. + * + * @since TBD + * + * @param {I18nResolvedConfig} config - The i18n configuration to clone. + * + * @returns {I18nResolvedConfig} A new copy of the i18n configuration object. + */ +export function createI18nConfig(config: I18nResolvedConfig): I18nResolvedConfig { + return { ...config }; +} From 177b84d7e6e94394bceecfbb259a73093c5d9769 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Wed, 11 Feb 2026 11:51:19 -0500 Subject: [PATCH 2/7] ENG-219: Add i18n command tests Co-Authored-By: Claude Opus 4.6 --- tests/commands/i18n.test.ts | 238 ++++++++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 tests/commands/i18n.test.ts diff --git a/tests/commands/i18n.test.ts b/tests/commands/i18n.test.ts new file mode 100644 index 0000000..4164313 --- /dev/null +++ b/tests/commands/i18n.test.ts @@ -0,0 +1,238 @@ +import http from 'node:http'; +import path from 'node:path'; +import fs from 'fs-extra'; +import { + runPup, + resetFixtures, + writePuprc, + getPuprc, + fakeProjectDir, +} from '../helpers/setup.js'; + +let server: http.Server; +let serverUrl: string; + +// Routes for the mock GlotPress API +const routes: Record = { + '/api/projects/wp-plugins/fake-project/stable': { + status: 200, + body: JSON.stringify({ + translation_sets: [ + { locale: 'de', wp_locale: 'de_DE', percent_translated: 80 }, + { locale: 'fr', wp_locale: 'fr_FR', percent_translated: 50 }, + { locale: 'ja', wp_locale: 'ja', percent_translated: 10 }, + ], + }), + contentType: 'application/json', + }, + '/not-found': { + status: 404, + body: 'Not Found', + }, + '/bad-json': { + status: 200, + body: 'this is not json', + contentType: 'text/plain', + }, + '/no-sets': { + status: 200, + body: JSON.stringify({ something_else: true }), + contentType: 'application/json', + }, +}; + +beforeAll((done) => { + server = http.createServer((req, res) => { + // Check for translation file download requests (contain "export-translations") + if (req.url?.includes('export-translations')) { + res.writeHead(200, { 'Content-Type': 'application/octet-stream' }); + res.end(Buffer.from('fake translation content')); + return; + } + + const route = routes[req.url ?? '']; + if (route) { + res.writeHead(route.status, { 'Content-Type': route.contentType ?? 'text/plain' }); + res.end(route.body); + } else { + res.writeHead(404); + res.end('Unknown route'); + } + }); + + server.listen(0, '127.0.0.1', () => { + const addr = server.address(); + if (addr && typeof addr === 'object') { + serverUrl = `http://127.0.0.1:${addr.port}`; + } + done(); + }); +}); + +afterAll((done) => { + server.close(done); +}); + +describe('i18n command', () => { + afterEach(() => { + resetFixtures(); + const langDir = path.join(fakeProjectDir, 'lang'); + if (fs.existsSync(langDir)) { + fs.removeSync(langDir); + } + }); + + it('should skip when no i18n config is set', async () => { + const puprc = getPuprc(); + writePuprc(puprc); + + const result = await runPup('i18n'); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('No i18n configuration found. Skipping.'); + }); + + it('should skip when i18n is an empty array', async () => { + const puprc = getPuprc({ i18n: [] }); + writePuprc(puprc); + + const result = await runPup('i18n'); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('No i18n configuration found. Skipping.'); + }); + + it('should skip i18n entries missing required fields', async () => { + const puprc = getPuprc({ + i18n: [{ path: 'lang' }], + }); + writePuprc(puprc); + + const result = await runPup('i18n'); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('No i18n configuration found. Skipping.'); + }); + + it('should accept a single i18n object instead of an array', async () => { + const puprc = getPuprc({ + i18n: { + url: `${serverUrl}/api/projects/wp-plugins/%slug%/stable`, + textdomain: 'fake-project', + slug: 'fake-project', + }, + }); + writePuprc(puprc); + + const result = await runPup('i18n'); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('Fetching translations from'); + }); + + it('should handle non-200 HTTP response gracefully', async () => { + const puprc = getPuprc({ + i18n: [{ + url: `${serverUrl}/not-found`, + textdomain: 'fake-project', + slug: 'fake-project', + }], + }); + writePuprc(puprc); + + const result = await runPup('i18n'); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('Failed to fetch translations: HTTP 404'); + }); + + it('should handle invalid JSON response gracefully', async () => { + const puprc = getPuprc({ + i18n: [{ + url: `${serverUrl}/bad-json`, + textdomain: 'fake-project', + slug: 'fake-project', + }], + }); + writePuprc(puprc); + + const result = await runPup('i18n'); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('Failed to fetch translation data'); + }); + + it('should handle response missing translation_sets', async () => { + const puprc = getPuprc({ + i18n: [{ + url: `${serverUrl}/no-sets`, + textdomain: 'fake-project', + slug: 'fake-project', + }], + }); + writePuprc(puprc); + + const result = await runPup('i18n'); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('Invalid translation API response'); + }); + + it('should fetch and download translations meeting threshold', async () => { + const puprc = getPuprc({ + i18n: [{ + url: `${serverUrl}/api/projects/wp-plugins/%slug%/stable`, + textdomain: 'fake-project', + slug: 'fake-project', + path: 'lang', + }], + }); + writePuprc(puprc); + + const result = await runPup('i18n'); + expect(result.exitCode).toBe(0); + // Default threshold is 30%, so de (80%) and fr (50%) should pass, ja (10%) should not + expect(result.output).toContain('Found 2 translations meeting 30% threshold'); + expect(result.output).toContain('Translation downloads complete'); + + // Verify files were downloaded + const langDir = path.join(fakeProjectDir, 'lang'); + expect(fs.existsSync(langDir)).toBe(true); + + const files = fs.readdirSync(langDir); + // Should have po + mo for de_DE and fr_FR = 4 files + expect(files).toContain('fake-project-de_DE.po'); + expect(files).toContain('fake-project-de_DE.mo'); + expect(files).toContain('fake-project-fr_FR.po'); + expect(files).toContain('fake-project-fr_FR.mo'); + // ja should be excluded (10% < 30% threshold) + expect(files).not.toContain('fake-project-ja.po'); + }); + + it('should respect custom minimum_percentage filter', async () => { + const puprc = getPuprc({ + i18n: [{ + url: `${serverUrl}/api/projects/wp-plugins/%slug%/stable`, + textdomain: 'fake-project', + slug: 'fake-project', + path: 'lang', + filter: { minimum_percentage: 70 }, + }], + }); + writePuprc(puprc); + + const result = await runPup('i18n'); + expect(result.exitCode).toBe(0); + // Only de (80%) meets 70% threshold + expect(result.output).toContain('Found 1 translations meeting 70% threshold'); + }); + + it('should handle unreachable URL gracefully', async () => { + const puprc = getPuprc({ + i18n: [{ + url: 'http://127.0.0.1:1/does-not-exist', + textdomain: 'fake-project', + slug: 'fake-project', + }], + }); + writePuprc(puprc); + + const result = await runPup('i18n'); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('Fetching translations from'); + expect(result.output).toContain('Failed to fetch translation data'); + }); +}); From 07f5ceb16f5c8fcac98803b7579a5803335c275f Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Wed, 11 Feb 2026 12:47:13 -0500 Subject: [PATCH 3/7] ENG-219: Use createTempProject in i18n tests for isolation Co-Authored-By: Claude Opus 4.6 --- tests/commands/i18n.test.ts | 58 +++++++++++++++++++------------------ 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/tests/commands/i18n.test.ts b/tests/commands/i18n.test.ts index 4164313..1bd22de 100644 --- a/tests/commands/i18n.test.ts +++ b/tests/commands/i18n.test.ts @@ -3,10 +3,10 @@ import path from 'node:path'; import fs from 'fs-extra'; import { runPup, - resetFixtures, writePuprc, getPuprc, - fakeProjectDir, + createTempProject, + cleanupTempProjects, } from '../helpers/setup.js'; let server: http.Server; @@ -74,28 +74,30 @@ afterAll((done) => { }); describe('i18n command', () => { + let projectDir: string; + + beforeEach(() => { + projectDir = createTempProject(); + }); + afterEach(() => { - resetFixtures(); - const langDir = path.join(fakeProjectDir, 'lang'); - if (fs.existsSync(langDir)) { - fs.removeSync(langDir); - } + cleanupTempProjects(); }); it('should skip when no i18n config is set', async () => { const puprc = getPuprc(); - writePuprc(puprc); + writePuprc(puprc, projectDir); - const result = await runPup('i18n'); + const result = await runPup('i18n', { cwd: projectDir }); expect(result.exitCode).toBe(0); expect(result.output).toContain('No i18n configuration found. Skipping.'); }); it('should skip when i18n is an empty array', async () => { const puprc = getPuprc({ i18n: [] }); - writePuprc(puprc); + writePuprc(puprc, projectDir); - const result = await runPup('i18n'); + const result = await runPup('i18n', { cwd: projectDir }); expect(result.exitCode).toBe(0); expect(result.output).toContain('No i18n configuration found. Skipping.'); }); @@ -104,9 +106,9 @@ describe('i18n command', () => { const puprc = getPuprc({ i18n: [{ path: 'lang' }], }); - writePuprc(puprc); + writePuprc(puprc, projectDir); - const result = await runPup('i18n'); + const result = await runPup('i18n', { cwd: projectDir }); expect(result.exitCode).toBe(0); expect(result.output).toContain('No i18n configuration found. Skipping.'); }); @@ -119,9 +121,9 @@ describe('i18n command', () => { slug: 'fake-project', }, }); - writePuprc(puprc); + writePuprc(puprc, projectDir); - const result = await runPup('i18n'); + const result = await runPup('i18n', { cwd: projectDir }); expect(result.exitCode).toBe(0); expect(result.output).toContain('Fetching translations from'); }); @@ -134,9 +136,9 @@ describe('i18n command', () => { slug: 'fake-project', }], }); - writePuprc(puprc); + writePuprc(puprc, projectDir); - const result = await runPup('i18n'); + const result = await runPup('i18n', { cwd: projectDir }); expect(result.exitCode).toBe(0); expect(result.output).toContain('Failed to fetch translations: HTTP 404'); }); @@ -149,9 +151,9 @@ describe('i18n command', () => { slug: 'fake-project', }], }); - writePuprc(puprc); + writePuprc(puprc, projectDir); - const result = await runPup('i18n'); + const result = await runPup('i18n', { cwd: projectDir }); expect(result.exitCode).toBe(0); expect(result.output).toContain('Failed to fetch translation data'); }); @@ -164,9 +166,9 @@ describe('i18n command', () => { slug: 'fake-project', }], }); - writePuprc(puprc); + writePuprc(puprc, projectDir); - const result = await runPup('i18n'); + const result = await runPup('i18n', { cwd: projectDir }); expect(result.exitCode).toBe(0); expect(result.output).toContain('Invalid translation API response'); }); @@ -180,16 +182,16 @@ describe('i18n command', () => { path: 'lang', }], }); - writePuprc(puprc); + writePuprc(puprc, projectDir); - const result = await runPup('i18n'); + const result = await runPup('i18n', { cwd: projectDir }); expect(result.exitCode).toBe(0); // Default threshold is 30%, so de (80%) and fr (50%) should pass, ja (10%) should not expect(result.output).toContain('Found 2 translations meeting 30% threshold'); expect(result.output).toContain('Translation downloads complete'); // Verify files were downloaded - const langDir = path.join(fakeProjectDir, 'lang'); + const langDir = path.join(projectDir, 'lang'); expect(fs.existsSync(langDir)).toBe(true); const files = fs.readdirSync(langDir); @@ -212,9 +214,9 @@ describe('i18n command', () => { filter: { minimum_percentage: 70 }, }], }); - writePuprc(puprc); + writePuprc(puprc, projectDir); - const result = await runPup('i18n'); + const result = await runPup('i18n', { cwd: projectDir }); expect(result.exitCode).toBe(0); // Only de (80%) meets 70% threshold expect(result.output).toContain('Found 1 translations meeting 70% threshold'); @@ -228,9 +230,9 @@ describe('i18n command', () => { slug: 'fake-project', }], }); - writePuprc(puprc); + writePuprc(puprc, projectDir); - const result = await runPup('i18n'); + const result = await runPup('i18n', { cwd: projectDir }); expect(result.exitCode).toBe(0); expect(result.output).toContain('Fetching translations from'); expect(result.output).toContain('Failed to fetch translation data'); From 010a0aab4b994051aa7b912fbec53db5502782ab Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Thu, 12 Feb 2026 12:58:35 -0500 Subject: [PATCH 4/7] ENG-219: Align i18n command behavior with PHP implementation Co-Authored-By: Claude Opus 4.6 --- src/commands/i18n.ts | 206 +++++++++++++++++++++++++----------- tests/commands/i18n.test.ts | 75 ++++++++++--- 2 files changed, 203 insertions(+), 78 deletions(-) diff --git a/src/commands/i18n.ts b/src/commands/i18n.ts index fce7423..9e18609 100644 --- a/src/commands/i18n.ts +++ b/src/commands/i18n.ts @@ -17,8 +17,8 @@ import type { I18nResolvedConfig } from '../types.js'; export function registerI18nCommand(program: Command): void { program .command('i18n') - .description('Fetches language files from a translation API.') - .option('--retries ', 'Number of retries for failed downloads.', '3') + .description('Fetches language files for the project.') + .option('--retries ', 'How many retries we do for each file.', '3') .option('--root ', 'Set the root directory for running commands.') .action(async (options: { retries?: string; root?: string }) => { const config = getConfig(options.root); @@ -32,13 +32,18 @@ export function registerI18nCommand(program: Command): void { } for (const i18nConfig of i18nConfigs) { - await fetchTranslations(i18nConfig, cwd, maxRetries); + const result = await downloadLanguageFiles(i18nConfig, cwd, maxRetries); + + if (result !== 0) { + process.exitCode = result; + return; + } } }); } /** - * Fetches and downloads translation files from a GlotPress-compatible API. + * Downloads language files for a single i18n configuration. * * @since TBD * @@ -46,92 +51,169 @@ export function registerI18nCommand(program: Command): void { * @param {string} cwd - The current working directory. * @param {number} maxRetries - The maximum number of retry attempts for failed downloads. * - * @returns {Promise} + * @returns {Promise} 0 on success, 1 on failure. */ -async function fetchTranslations( +async function downloadLanguageFiles( config: I18nResolvedConfig, cwd: string, maxRetries: number -): Promise { - const url = config.url +): Promise { + const projectUrl = config.url .replace('{slug}', config.slug) .replace('%slug%', config.slug); - output.section(`Fetching translations from ${url}...`); + output.log(`Fetching language files for ${config.textdomain} from ${projectUrl}`); let data: TranslationApiResponse; try { - const response = await fetch(url); + const response = await fetch(projectUrl); if (!response.ok) { - output.error(`Failed to fetch translations: HTTP ${response.status}`); - return; + output.error(`Failed to fetch project data from ${projectUrl}`); + return 1; } data = (await response.json()) as TranslationApiResponse; } catch (err) { output.error(`Failed to fetch translation data: ${err}`); - return; + return 1; } - if (!data.translation_sets || !Array.isArray(data.translation_sets)) { - output.error('Invalid translation API response.'); - return; + if ( + !data.translation_sets || + !Array.isArray(data.translation_sets) || + data.translation_sets.length === 0 + ) { + output.error(`Failed to fetch translation sets from ${projectUrl}`); + return 1; } const minimumPercentage = config.filter.minimum_percentage; - const filteredSets = data.translation_sets.filter( - (set: TranslationSet) => - set.percent_translated >= minimumPercentage - ); - - output.log( - `Found ${filteredSets.length} translations meeting ${minimumPercentage}% threshold.` - ); const langDir = path.resolve(cwd, config.path); await fs.mkdirp(langDir); - for (const set of filteredSets) { + const promises: Promise[] = []; + + for (const translation of data.translation_sets) { + // Skip when translations are zero. + if (translation.current_count === 0) { + continue; + } + + // Skip any translation set that doesn't match our min translated. + if (minimumPercentage > translation.percent_translated) { + continue; + } + for (const format of config.formats) { - const filename = config.file_format - .replace('%textdomain%', config.textdomain) - .replace('%locale%', set.locale) - .replace('%wp_locale%', set.wp_locale || set.locale) - .replace('%format%', format); - - const downloadUrl = `${url}/${set.locale}/default/export-translations?format=${format}`; - const destPath = path.join(langDir, filename); - - let success = false; - for (let attempt = 0; attempt < maxRetries && !success; attempt++) { - try { - const response = await fetch(downloadUrl); - if (!response.ok) { - output.warning( - `Failed to download ${filename} (attempt ${attempt + 1}/${maxRetries}): HTTP ${response.status}` - ); - continue; - } - - const buffer = await response.arrayBuffer(); - if (buffer.byteLength === 0) { - output.warning(`Empty response for ${filename}, skipping.`); - break; - } - - await fs.writeFile(destPath, Buffer.from(buffer)); - output.log(`Downloaded: ${filename}`); - success = true; - } catch (err) { - output.warning( - `Error downloading ${filename} (attempt ${attempt + 1}/${maxRetries}): ${err}` - ); - } - } + const promise = downloadAndSaveTranslation( + config, + translation, + format, + projectUrl, + langDir, + maxRetries + ); + promises.push(promise); } } - output.success('Translation downloads complete.'); + await Promise.all(promises); + + return 0; +} + +/** + * Downloads and saves a single translation file with retry support. + * + * @since TBD + * + * @param {I18nResolvedConfig} config - The resolved i18n configuration. + * @param {TranslationSet} translation - The translation set to download. + * @param {string} format - The file format to download (e.g. "po", "mo"). + * @param {string} projectUrl - The base project API URL. + * @param {string} langDir - The absolute path to the language files directory. + * @param {number} maxRetries - The maximum number of retry attempts. + * @param {number} tried - The current attempt count. + * + * @returns {Promise} + */ +async function downloadAndSaveTranslation( + config: I18nResolvedConfig, + translation: TranslationSet, + format: string, + projectUrl: string, + langDir: string, + maxRetries: number, + tried = 0 +): Promise { + const translationUrl = `${projectUrl}/${translation.locale}/${translation.slug}/export-translations?format=${format}`; + + if (tried >= maxRetries) { + output.error( + `Failed to fetch translation from ${translationUrl} too many times, bailing on ${translation.slug}` + ); + return; + } + + tried++; + + try { + const response = await fetch(translationUrl); + + const filename = config.file_format + .replace('%domainPath%', config.path) + .replace('%textdomain%', config.textdomain) + .replace('%locale%', translation.locale ?? '') + .replace('%wp_locale%', translation.wp_locale ?? '') + .replace('%format%', format); + + const buffer = Buffer.from(await response.arrayBuffer()); + + if (buffer.byteLength < 200) { + output.error(`Failed to fetch translation from ${translationUrl}`); + + // Not sure if 2 seconds is needed, but it prevents the firewall from catching us. + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Retries to download this file. + return downloadAndSaveTranslation( + config, translation, format, projectUrl, langDir, maxRetries, tried + ); + } + + const filePath = path.join(langDir, filename); + await fs.writeFile(filePath, buffer); + + // Verify the written file size matches the response size. + const stat = await fs.stat(filePath); + if (stat.size !== buffer.byteLength) { + output.error( + `Failed to save the translation from ${translationUrl} to ${filePath}` + ); + + // Delete the file in that case. + await fs.unlink(filePath).catch(() => {}); + + // Not sure if 2 seconds is needed, but it prevents the firewall from catching us. + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Retries to download this file. + return downloadAndSaveTranslation( + config, translation, format, projectUrl, langDir, maxRetries, tried + ); + } + + output.log(`* Translation created for ${filePath}`); + } catch { + output.error(`Failed to fetch translation from ${translationUrl}`); + + await new Promise(resolve => setTimeout(resolve, 2000)); + + return downloadAndSaveTranslation( + config, translation, format, projectUrl, langDir, maxRetries, tried + ); + } } interface TranslationApiResponse { @@ -141,5 +223,7 @@ interface TranslationApiResponse { interface TranslationSet { locale: string; wp_locale?: string; + slug: string; + current_count: number; percent_translated: number; } diff --git a/tests/commands/i18n.test.ts b/tests/commands/i18n.test.ts index 1bd22de..ce1d35f 100644 --- a/tests/commands/i18n.test.ts +++ b/tests/commands/i18n.test.ts @@ -12,15 +12,28 @@ import { let server: http.Server; let serverUrl: string; +// A response body >= 200 bytes so it passes the minimum size check. +const fakeTranslationBody = Buffer.alloc(250, 0x78); + // Routes for the mock GlotPress API const routes: Record = { '/api/projects/wp-plugins/fake-project/stable': { status: 200, body: JSON.stringify({ translation_sets: [ - { locale: 'de', wp_locale: 'de_DE', percent_translated: 80 }, - { locale: 'fr', wp_locale: 'fr_FR', percent_translated: 50 }, - { locale: 'ja', wp_locale: 'ja', percent_translated: 10 }, + { locale: 'de', wp_locale: 'de_DE', slug: 'default', current_count: 100, percent_translated: 80 }, + { locale: 'fr', wp_locale: 'fr_FR', slug: 'default', current_count: 50, percent_translated: 50 }, + { locale: 'ja', wp_locale: 'ja', slug: 'default', current_count: 5, percent_translated: 10 }, + ], + }), + contentType: 'application/json', + }, + '/api/projects/wp-plugins/fake-project/with-zero-count': { + status: 200, + body: JSON.stringify({ + translation_sets: [ + { locale: 'de', wp_locale: 'de_DE', slug: 'default', current_count: 100, percent_translated: 80 }, + { locale: 'es', wp_locale: 'es_ES', slug: 'default', current_count: 0, percent_translated: 90 }, ], }), contentType: 'application/json', @@ -46,7 +59,7 @@ beforeAll((done) => { // Check for translation file download requests (contain "export-translations") if (req.url?.includes('export-translations')) { res.writeHead(200, { 'Content-Type': 'application/octet-stream' }); - res.end(Buffer.from('fake translation content')); + res.end(fakeTranslationBody); return; } @@ -125,7 +138,7 @@ describe('i18n command', () => { const result = await runPup('i18n', { cwd: projectDir }); expect(result.exitCode).toBe(0); - expect(result.output).toContain('Fetching translations from'); + expect(result.output).toContain('Fetching language files for'); }); it('should handle non-200 HTTP response gracefully', async () => { @@ -139,8 +152,8 @@ describe('i18n command', () => { writePuprc(puprc, projectDir); const result = await runPup('i18n', { cwd: projectDir }); - expect(result.exitCode).toBe(0); - expect(result.output).toContain('Failed to fetch translations: HTTP 404'); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Failed to fetch project data from'); }); it('should handle invalid JSON response gracefully', async () => { @@ -154,7 +167,7 @@ describe('i18n command', () => { writePuprc(puprc, projectDir); const result = await runPup('i18n', { cwd: projectDir }); - expect(result.exitCode).toBe(0); + expect(result.exitCode).toBe(1); expect(result.output).toContain('Failed to fetch translation data'); }); @@ -169,8 +182,8 @@ describe('i18n command', () => { writePuprc(puprc, projectDir); const result = await runPup('i18n', { cwd: projectDir }); - expect(result.exitCode).toBe(0); - expect(result.output).toContain('Invalid translation API response'); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Failed to fetch translation sets from'); }); it('should fetch and download translations meeting threshold', async () => { @@ -186,16 +199,14 @@ describe('i18n command', () => { const result = await runPup('i18n', { cwd: projectDir }); expect(result.exitCode).toBe(0); - // Default threshold is 30%, so de (80%) and fr (50%) should pass, ja (10%) should not - expect(result.output).toContain('Found 2 translations meeting 30% threshold'); - expect(result.output).toContain('Translation downloads complete'); + expect(result.output).toContain('Fetching language files for fake-project from'); // Verify files were downloaded const langDir = path.join(projectDir, 'lang'); expect(fs.existsSync(langDir)).toBe(true); const files = fs.readdirSync(langDir); - // Should have po + mo for de_DE and fr_FR = 4 files + // Default threshold is 30%, so de (80%) and fr (50%) should pass, ja (10%) should not expect(files).toContain('fake-project-de_DE.po'); expect(files).toContain('fake-project-de_DE.mo'); expect(files).toContain('fake-project-fr_FR.po'); @@ -218,8 +229,38 @@ describe('i18n command', () => { const result = await runPup('i18n', { cwd: projectDir }); expect(result.exitCode).toBe(0); + // Only de (80%) meets 70% threshold - expect(result.output).toContain('Found 1 translations meeting 70% threshold'); + const langDir = path.join(projectDir, 'lang'); + const files = fs.readdirSync(langDir); + expect(files).toContain('fake-project-de_DE.po'); + expect(files).toContain('fake-project-de_DE.mo'); + expect(files).not.toContain('fake-project-fr_FR.po'); + expect(files).not.toContain('fake-project-ja.po'); + }); + + it('should skip translations with zero current_count', async () => { + const puprc = getPuprc({ + i18n: [{ + url: `${serverUrl}/api/projects/wp-plugins/%slug%/with-zero-count`, + textdomain: 'fake-project', + slug: 'fake-project', + path: 'lang', + }], + }); + writePuprc(puprc, projectDir); + + const result = await runPup('i18n', { cwd: projectDir }); + expect(result.exitCode).toBe(0); + + const langDir = path.join(projectDir, 'lang'); + const files = fs.readdirSync(langDir); + // de has current_count: 100 and 80% translated, should be downloaded + expect(files).toContain('fake-project-de_DE.po'); + expect(files).toContain('fake-project-de_DE.mo'); + // es has current_count: 0, should be skipped even though 90% translated + expect(files).not.toContain('fake-project-es_ES.po'); + expect(files).not.toContain('fake-project-es_ES.mo'); }); it('should handle unreachable URL gracefully', async () => { @@ -233,8 +274,8 @@ describe('i18n command', () => { writePuprc(puprc, projectDir); const result = await runPup('i18n', { cwd: projectDir }); - expect(result.exitCode).toBe(0); - expect(result.output).toContain('Fetching translations from'); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Fetching language files for'); expect(result.output).toContain('Failed to fetch translation data'); }); }); From 5636ad2530647039cf74b7f415499f17c158a5bb Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Thu, 12 Feb 2026 13:11:40 -0500 Subject: [PATCH 5/7] ENG-219: Fix --root flag to only affect download directory, not config loading Co-Authored-By: Claude Opus 4.6 --- src/commands/i18n.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/i18n.ts b/src/commands/i18n.ts index 9e18609..6317758 100644 --- a/src/commands/i18n.ts +++ b/src/commands/i18n.ts @@ -19,9 +19,9 @@ export function registerI18nCommand(program: Command): void { .command('i18n') .description('Fetches language files for the project.') .option('--retries ', 'How many retries we do for each file.', '3') - .option('--root ', 'Set the root directory for running commands.') + .option('--root ', 'Set the root directory for downloading language files.') .action(async (options: { retries?: string; root?: string }) => { - const config = getConfig(options.root); + const config = getConfig(); const i18nConfigs = config.getI18n(); const cwd = options.root ?? config.getWorkingDir(); const maxRetries = parseInt(options.retries ?? '3', 10); From 2dae484acfc368f7cad0f8875d4b3987b1a5ccf9 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Thu, 12 Feb 2026 13:11:44 -0500 Subject: [PATCH 6/7] ENG-219: Add tests for --retries and --root flags Co-Authored-By: Claude Opus 4.6 --- tests/commands/i18n.test.ts | 53 +++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/commands/i18n.test.ts b/tests/commands/i18n.test.ts index ce1d35f..a6e10dc 100644 --- a/tests/commands/i18n.test.ts +++ b/tests/commands/i18n.test.ts @@ -263,6 +263,59 @@ describe('i18n command', () => { expect(files).not.toContain('fake-project-es_ES.mo'); }); + it('should respect --retries 0 and bail without downloading', async () => { + const puprc = getPuprc({ + i18n: [{ + url: `${serverUrl}/api/projects/wp-plugins/%slug%/stable`, + textdomain: 'fake-project', + slug: 'fake-project', + path: 'lang', + }], + }); + writePuprc(puprc, projectDir); + + const result = await runPup('i18n --retries 0', { cwd: projectDir }); + expect(result.exitCode).toBe(0); + // de and fr pass filters but retries=0 means every download bails immediately + expect(result.output).toContain('too many times, bailing'); + + // No files should have been downloaded + const langDir = path.join(projectDir, 'lang'); + if (fs.existsSync(langDir)) { + expect(fs.readdirSync(langDir)).toHaveLength(0); + } + }); + + it('should use --root to download files to the specified directory', async () => { + // rootDir is an empty subdirectory — no .puprc, no project files. + // This proves config is loaded from projectDir (the cwd) and --root only + // controls where downloaded files are saved. + const rootDir = path.join(projectDir, 'alt-root'); + fs.mkdirpSync(rootDir); + + writePuprc(getPuprc({ + i18n: [{ + url: `${serverUrl}/api/projects/wp-plugins/%slug%/stable`, + textdomain: 'fake-project', + slug: 'fake-project', + path: 'custom/translations', + }], + }), projectDir); + + const result = await runPup(`i18n --root ${rootDir}`, { cwd: projectDir }); + expect(result.exitCode).toBe(0); + + // Files should be in rootDir/custom/translations + const rootLangDir = path.join(rootDir, 'custom', 'translations'); + expect(fs.existsSync(rootLangDir)).toBe(true); + const files = fs.readdirSync(rootLangDir); + expect(files).toContain('fake-project-de_DE.po'); + expect(files).toContain('fake-project-de_DE.mo'); + + // And NOT directly in projectDir/custom/translations + expect(fs.existsSync(path.join(projectDir, 'custom'))).toBe(false); + }); + it('should handle unreachable URL gracefully', async () => { const puprc = getPuprc({ i18n: [{ From 167f40dbd29f72313a23c8064758066f935bf046 Mon Sep 17 00:00:00 2001 From: Eric Defore Date: Mon, 23 Feb 2026 09:15:47 -0500 Subject: [PATCH 7/7] ENG-219: Port i18n 429 retry logic from PR #53 Replace parallel Promise.all downloads with sequential batch processing and add smarter HTTP 429 rate-limit handling with exponential backoff. - Add --delay and --batch-size CLI options - Clamp --retries to 1-5 range - Use backoff multipliers [16, 31, 91, 151] for 429 responses - Respect Retry-After header capped to backoff schedule - Extract saveTranslationFile helper - Add User-Agent header to all fetch requests - Track failed downloads and return exit code 1 on any failure - Add 429 recovery and exhaustion tests --- src/commands/i18n.ts | 242 +++++++++++++++++++++++------------ tests/commands/i18n.test.ts | 245 ++++++++++++++++++++++++------------ 2 files changed, 331 insertions(+), 156 deletions(-) diff --git a/src/commands/i18n.ts b/src/commands/i18n.ts index aec82fc..ea66e4d 100644 --- a/src/commands/i18n.ts +++ b/src/commands/i18n.ts @@ -2,9 +2,19 @@ import type { Command } from 'commander'; import fs from 'fs-extra'; import path from 'node:path'; import { getConfig } from '../config.ts'; +import { PUP_VERSION } from '../app.ts'; import * as output from '../utils/output.ts'; import type { I18nResolvedConfig } from '../types.ts'; +/** + * Backoff multipliers for HTTP 429 rate limit errors. + * Index corresponds to the 429 occurrence count (0-indexed). + * Applied as: delay * multiplier. + * + * @since TBD + */ +const HTTP_429_BACKOFF_MULTIPLIERS = [16, 31, 91, 151]; + /** * Registers the `i18n` command with the CLI program. * @@ -18,13 +28,17 @@ export function registerI18nCommand(program: Command): void { program .command('i18n') .description('Fetches language files for the project.') - .option('--retries ', 'How many retries we do for each file.', '3') + .option('--retries ', 'How many retries per translation file.', '3') + .option('--delay ', 'Delay (seconds) between retries and for 429 backoff.', '2') + .option('--batch-size ', 'Batch size for grouping downloads.', '3') .option('--root ', 'Set the root directory for downloading language files.') - .action(async (options: { retries?: string; root?: string }) => { + .action(async (options: { retries?: string; delay?: string; batchSize?: string; root?: string }) => { const config = getConfig(); const i18nConfigs = config.getI18n(); const cwd = options.root ?? config.getWorkingDir(); - const maxRetries = parseInt(options.retries ?? '3', 10); + const maxRetries = Math.max(1, Math.min(5, parseInt(options.retries ?? '3', 10))); + const delay = Math.max(1, parseInt(options.delay ?? '2', 10)); + const batchSize = Math.max(1, parseInt(options.batchSize ?? '3', 10)); if (i18nConfigs.length === 0) { output.log('No i18n configuration found. Skipping.'); @@ -32,9 +46,12 @@ export function registerI18nCommand(program: Command): void { } for (const i18nConfig of i18nConfigs) { - const result = await downloadLanguageFiles(i18nConfig, cwd, maxRetries); + const result = await downloadLanguageFiles(i18nConfig, cwd, maxRetries, delay, batchSize); if (result !== 0) { + output.error('Failed to download language files.'); + output.warning('Config:'); + output.log(JSON.stringify(i18nConfig, null, 2)); process.exitCode = result; return; } @@ -42,21 +59,55 @@ export function registerI18nCommand(program: Command): void { }); } +/** + * Extracts the wait time from the Retry-After header if present. + * Respects the server hint but caps it to the backoff schedule for that attempt. + * + * @since TBD + * + * @param {Response} response - The HTTP response containing potential Retry-After header. + * @param {number} backoffWait - The computed backoff wait time in seconds. + * + * @returns {number} The wait time in seconds. + */ +function getWaitTimeFor429(response: Response, backoffWait: number): number { + const retryAfter = response.headers.get('Retry-After'); + + if (!retryAfter) { + return backoffWait; + } + + // Retry-After can be numeric seconds or an HTTP-date; parse numeric only. + if (/^\d+$/.test(retryAfter)) { + const serverWait = parseInt(retryAfter, 10); + // Use the server hint but cap at our backoff (don't wait longer than we're willing to). + return Math.max(1, Math.min(serverWait, backoffWait)); + } + + // HTTP-date format is complex to parse; fall back to backoff schedule. + return backoffWait; +} + /** * Downloads language files for a single i18n configuration. + * Processes downloads sequentially with deterministic retry logic. * * @since TBD * * @param {I18nResolvedConfig} config - The resolved i18n configuration for this translation source. * @param {string} cwd - The current working directory. * @param {number} maxRetries - The maximum number of retry attempts for failed downloads. + * @param {number} delay - The base delay in seconds between retries and for 429 backoff. + * @param {number} batchSize - The batch size for grouping downloads. * * @returns {Promise} 0 on success, 1 on failure. */ async function downloadLanguageFiles( config: I18nResolvedConfig, cwd: string, - maxRetries: number + maxRetries: number, + delay: number, + batchSize: number ): Promise { const projectUrl = config.url .replace('{slug}', config.slug) @@ -67,7 +118,9 @@ async function downloadLanguageFiles( let data: TranslationApiResponse; try { - const response = await fetch(projectUrl); + const response = await fetch(projectUrl, { + headers: { 'User-Agent': `StellarWP PUP/${PUP_VERSION}` }, + }); if (!response.ok) { output.error(`Failed to fetch project data from ${projectUrl}`); return 1; @@ -92,7 +145,8 @@ async function downloadLanguageFiles( const langDir = path.resolve(cwd, config.path); await fs.mkdirp(langDir); - const promises: Promise[] = []; + // Build a list of (translation, format) pairs to download. + const downloadItems: [TranslationSet, string][] = []; for (const translation of data.translation_sets) { // Skip when translations are zero. @@ -100,31 +154,45 @@ async function downloadLanguageFiles( continue; } - // Skip any translation set that doesn't match our min translated. + // Skip any translation set that doesn't match the minimum percentage. if (minimumPercentage > translation.percent_translated) { continue; } for (const format of config.formats) { - const promise = downloadAndSaveTranslation( - config, - translation, - format, - projectUrl, - langDir, - maxRetries - ); - promises.push(promise); + downloadItems.push([translation, format]); } } - await Promise.all(promises); + if (downloadItems.length === 0) { + return 0; + } - return 0; + // Process downloads sequentially in batches (for grouping/visibility). + let failedCount = 0; + + for (let offset = 0; offset < downloadItems.length; offset += batchSize) { + const batch = downloadItems.slice(offset, offset + batchSize); + + // Process each item in the batch sequentially. + for (const [translation, format] of batch) { + try { + await downloadAndSaveTranslationSync( + config, translation, format, projectUrl, langDir, maxRetries, delay + ); + } catch (err) { + output.error(`Download failed: ${err instanceof Error ? err.message : String(err)}`); + failedCount++; + } + } + } + + return failedCount > 0 ? 1 : 0; } /** - * Downloads and saves a single translation file with retry support. + * Synchronously downloads and saves a translation with retry logic. + * Retries consume the standard retry budget; 429 responses use smarter delay logic. * * @since TBD * @@ -134,86 +202,106 @@ async function downloadLanguageFiles( * @param {string} projectUrl - The base project API URL. * @param {string} langDir - The absolute path to the language files directory. * @param {number} maxRetries - The maximum number of retry attempts. - * @param {number} tried - The current attempt count. + * @param {number} delay - The base delay in seconds between retries and for 429 backoff. * * @returns {Promise} */ -async function downloadAndSaveTranslation( +async function downloadAndSaveTranslationSync( config: I18nResolvedConfig, translation: TranslationSet, format: string, projectUrl: string, langDir: string, maxRetries: number, - tried = 0 + delay: number ): Promise { const translationUrl = `${projectUrl}/${translation.locale}/${translation.slug}/export-translations?format=${format}`; + let http429Count = 0; - if (tried >= maxRetries) { - output.error( - `Failed to fetch translation from ${translationUrl} too many times, bailing on ${translation.slug}` - ); - return; - } - - tried++; - - try { - const response = await fetch(translationUrl); - - const filename = config.file_format - .replace('%domainPath%', config.path) - .replace('%textdomain%', config.textdomain) - .replace('%locale%', translation.locale ?? '') - .replace('%wp_locale%', translation.wp_locale ?? '') - .replace('%format%', format); - + for (let tried = 0; tried < maxRetries; tried++) { + const response = await fetch(translationUrl, { + headers: { 'User-Agent': `StellarWP PUP/${PUP_VERSION}` }, + }); + const statusCode = response.status; const buffer = Buffer.from(await response.arrayBuffer()); + const bodySize = buffer.byteLength; - if (buffer.byteLength < 200) { - output.error(`Failed to fetch translation from ${translationUrl}`); + // Handle HTTP 429 (Too Many Requests) with smarter delay. + if (statusCode === 429) { + const multiplier = HTTP_429_BACKOFF_MULTIPLIERS[http429Count] ?? + HTTP_429_BACKOFF_MULTIPLIERS[HTTP_429_BACKOFF_MULTIPLIERS.length - 1]; + const backoffWait = delay * multiplier; + const waitTime = getWaitTimeFor429(response, backoffWait); - // Not sure if 2 seconds is needed, but it prevents the firewall from catching us. - await new Promise(resolve => setTimeout(resolve, 2000)); - - // Retries to download this file. - return downloadAndSaveTranslation( - config, translation, format, projectUrl, langDir, maxRetries, tried + output.warning( + `Rate limited (HTTP 429) on ${translation.slug}. Waiting ${waitTime}s before retry...` ); - } - const filePath = path.join(langDir, filename); - await fs.writeFile(filePath, buffer); - - // Verify the written file size matches the response size. - const stat = await fs.stat(filePath); - if (stat.size !== buffer.byteLength) { - output.error( - `Failed to save the translation from ${translationUrl} to ${filePath}` - ); - - // Delete the file in that case. - await fs.unlink(filePath).catch(() => {}); - - // Not sure if 2 seconds is needed, but it prevents the firewall from catching us. - await new Promise(resolve => setTimeout(resolve, 2000)); + await new Promise(resolve => setTimeout(resolve, waitTime * 1000)); + http429Count++; + continue; + } - // Retries to download this file. - return downloadAndSaveTranslation( - config, translation, format, projectUrl, langDir, maxRetries, tried - ); + // Check for valid response (non-429 case). + if (statusCode !== 200 || bodySize < 200) { + // Non-429 failure: use standard delay and retry. + if (tried < maxRetries - 1) { + output.error( + `Invalid response from ${translationUrl} (status: ${statusCode}, size: ${bodySize}). Retrying...` + ); + await new Promise(resolve => setTimeout(resolve, delay * 1000)); + continue; + } + break; } - output.log(`* Translation created for ${filePath}`); - } catch { - output.error(`Failed to fetch translation from ${translationUrl}`); + // Success: save and return. + saveTranslationFile(buffer, config, translation, format, langDir); + return; + } - await new Promise(resolve => setTimeout(resolve, 2000)); + // All retries exhausted. + throw new Error(`Failed to download ${translation.slug} after ${maxRetries} retries`); +} - return downloadAndSaveTranslation( - config, translation, format, projectUrl, langDir, maxRetries, tried - ); +/** + * Saves a translation file to disk. + * + * @since TBD + * + * @param {Buffer} content - The translation file content. + * @param {I18nResolvedConfig} config - The resolved i18n configuration. + * @param {TranslationSet} translation - The translation set metadata. + * @param {string} format - The file format (e.g. "po", "mo"). + * @param {string} langDir - The absolute path to the language files directory. + * + * @returns {void} + */ +function saveTranslationFile( + content: Buffer, + config: I18nResolvedConfig, + translation: TranslationSet, + format: string, + langDir: string +): void { + const filename = config.file_format + .replace('%domainPath%', config.path) + .replace('%textdomain%', config.textdomain) + .replace('%locale%', translation.locale ?? '') + .replace('%wp_locale%', translation.wp_locale ?? '') + .replace('%format%', format); + + const filePath = path.join(langDir, filename); + fs.writeFileSync(filePath, content); + + // Verify the written file size matches the response size. + const stat = fs.statSync(filePath); + if (stat.size !== content.byteLength) { + fs.unlinkSync(filePath); + throw new Error(`Failed to write translation to ${filePath}`); } + + output.log(`* Translation created for ${filePath}`); } interface TranslationApiResponse { diff --git a/tests/commands/i18n.test.ts b/tests/commands/i18n.test.ts index a6e10dc..dcb6d6f 100644 --- a/tests/commands/i18n.test.ts +++ b/tests/commands/i18n.test.ts @@ -9,88 +9,134 @@ import { cleanupTempProjects, } from '../helpers/setup.js'; -let server: http.Server; -let serverUrl: string; - // A response body >= 200 bytes so it passes the minimum size check. const fakeTranslationBody = Buffer.alloc(250, 0x78); -// Routes for the mock GlotPress API -const routes: Record = { - '/api/projects/wp-plugins/fake-project/stable': { - status: 200, - body: JSON.stringify({ - translation_sets: [ - { locale: 'de', wp_locale: 'de_DE', slug: 'default', current_count: 100, percent_translated: 80 }, - { locale: 'fr', wp_locale: 'fr_FR', slug: 'default', current_count: 50, percent_translated: 50 }, - { locale: 'ja', wp_locale: 'ja', slug: 'default', current_count: 5, percent_translated: 10 }, - ], - }), - contentType: 'application/json', - }, - '/api/projects/wp-plugins/fake-project/with-zero-count': { - status: 200, - body: JSON.stringify({ - translation_sets: [ - { locale: 'de', wp_locale: 'de_DE', slug: 'default', current_count: 100, percent_translated: 80 }, - { locale: 'es', wp_locale: 'es_ES', slug: 'default', current_count: 0, percent_translated: 90 }, - ], - }), - contentType: 'application/json', - }, - '/not-found': { - status: 404, - body: 'Not Found', - }, - '/bad-json': { - status: 200, - body: 'this is not json', - contentType: 'text/plain', - }, - '/no-sets': { - status: 200, - body: JSON.stringify({ something_else: true }), - contentType: 'application/json', - }, -}; - -beforeAll((done) => { - server = http.createServer((req, res) => { - // Check for translation file download requests (contain "export-translations") - if (req.url?.includes('export-translations')) { - res.writeHead(200, { 'Content-Type': 'application/octet-stream' }); - res.end(fakeTranslationBody); - return; - } +describe('i18n command', () => { + let server: http.Server; + let serverUrl: string; + let projectDir: string; + const requestCounts: Record = {}; + + // Routes for the mock GlotPress API + const routes: Record = { + '/api/projects/wp-plugins/fake-project/stable': { + status: 200, + body: JSON.stringify({ + translation_sets: [ + { locale: 'de', wp_locale: 'de_DE', slug: 'default', current_count: 100, percent_translated: 80 }, + { locale: 'fr', wp_locale: 'fr_FR', slug: 'default', current_count: 50, percent_translated: 50 }, + { locale: 'ja', wp_locale: 'ja', slug: 'default', current_count: 5, percent_translated: 10 }, + ], + }), + contentType: 'application/json', + }, + '/api/projects/wp-plugins/fake-project/with-zero-count': { + status: 200, + body: JSON.stringify({ + translation_sets: [ + { locale: 'de', wp_locale: 'de_DE', slug: 'default', current_count: 100, percent_translated: 80 }, + { locale: 'es', wp_locale: 'es_ES', slug: 'default', current_count: 0, percent_translated: 90 }, + ], + }), + contentType: 'application/json', + }, + '/api/projects/wp-plugins/fake-project/rate-limited': { + status: 200, + body: JSON.stringify({ + translation_sets: [ + { locale: 'de', wp_locale: 'de_DE', slug: 'default', current_count: 100, percent_translated: 80 }, + ], + }), + contentType: 'application/json', + }, + '/api/projects/wp-plugins/fake-project/always-rate-limited': { + status: 200, + body: JSON.stringify({ + translation_sets: [ + { locale: 'de', wp_locale: 'de_DE', slug: 'default', current_count: 100, percent_translated: 80 }, + ], + }), + contentType: 'application/json', + }, + '/not-found': { + status: 404, + body: 'Not Found', + }, + '/bad-json': { + status: 200, + body: 'this is not json', + contentType: 'text/plain', + }, + '/no-sets': { + status: 200, + body: JSON.stringify({ something_else: true }), + contentType: 'application/json', + }, + }; + + beforeAll((done) => { + server = http.createServer((req, res) => { + const url = req.url ?? ''; + + // Track request counts per URL. + requestCounts[url] = (requestCounts[url] ?? 0) + 1; + + // Check for translation file download requests (contain "export-translations") + if (url.includes('export-translations')) { + // Always return 429 for the always-rate-limited project. + if (url.includes('always-rate-limited')) { + res.writeHead(429, { + 'Content-Type': 'text/plain', + 'Retry-After': '1', + }); + res.end('Too Many Requests'); + return; + } + + // Return 429 on first request for the rate-limited project, then 200. + if (url.includes('rate-limited') && requestCounts[url] === 1) { + res.writeHead(429, { + 'Content-Type': 'text/plain', + 'Retry-After': '1', + }); + res.end('Too Many Requests'); + return; + } + + res.writeHead(200, { 'Content-Type': 'application/octet-stream' }); + res.end(fakeTranslationBody); + return; + } + + const route = routes[url]; + if (route) { + res.writeHead(route.status, { 'Content-Type': route.contentType ?? 'text/plain' }); + res.end(route.body); + } else { + res.writeHead(404); + res.end('Unknown route'); + } + }); - const route = routes[req.url ?? '']; - if (route) { - res.writeHead(route.status, { 'Content-Type': route.contentType ?? 'text/plain' }); - res.end(route.body); - } else { - res.writeHead(404); - res.end('Unknown route'); - } + server.listen(0, '127.0.0.1', () => { + const addr = server.address(); + if (addr && typeof addr === 'object') { + serverUrl = `http://127.0.0.1:${addr.port}`; + } + done(); + }); }); - server.listen(0, '127.0.0.1', () => { - const addr = server.address(); - if (addr && typeof addr === 'object') { - serverUrl = `http://127.0.0.1:${addr.port}`; - } - done(); + afterAll((done) => { + server.close(done); }); -}); - -afterAll((done) => { - server.close(done); -}); - -describe('i18n command', () => { - let projectDir: string; beforeEach(() => { projectDir = createTempProject(); + for (const key of Object.keys(requestCounts)) { + delete requestCounts[key]; + } }); afterEach(() => { @@ -263,7 +309,7 @@ describe('i18n command', () => { expect(files).not.toContain('fake-project-es_ES.mo'); }); - it('should respect --retries 0 and bail without downloading', async () => { + it('should clamp --retries to minimum of 1', async () => { const puprc = getPuprc({ i18n: [{ url: `${serverUrl}/api/projects/wp-plugins/%slug%/stable`, @@ -274,16 +320,14 @@ describe('i18n command', () => { }); writePuprc(puprc, projectDir); + // --retries 0 is clamped to 1, so downloads should succeed const result = await runPup('i18n --retries 0', { cwd: projectDir }); expect(result.exitCode).toBe(0); - // de and fr pass filters but retries=0 means every download bails immediately - expect(result.output).toContain('too many times, bailing'); - // No files should have been downloaded const langDir = path.join(projectDir, 'lang'); - if (fs.existsSync(langDir)) { - expect(fs.readdirSync(langDir)).toHaveLength(0); - } + const files = fs.readdirSync(langDir); + expect(files).toContain('fake-project-de_DE.po'); + expect(files).toContain('fake-project-de_DE.mo'); }); it('should use --root to download files to the specified directory', async () => { @@ -331,4 +375,47 @@ describe('i18n command', () => { expect(result.output).toContain('Fetching language files for'); expect(result.output).toContain('Failed to fetch translation data'); }); + + it('should recover from HTTP 429 and download successfully', async () => { + const puprc = getPuprc({ + i18n: [{ + url: `${serverUrl}/api/projects/wp-plugins/%slug%/rate-limited`, + textdomain: 'fake-project', + slug: 'fake-project', + path: 'lang', + }], + }); + writePuprc(puprc, projectDir); + + // Server returns 429 on first translation request, then 200 on retry. + // Retry-After: 1 is capped to min(1, backoffWait), so effective wait = 1s. + const result = await runPup('i18n --retries 2 --delay 1', { cwd: projectDir }); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('Rate limited (HTTP 429)'); + + // Files should still be downloaded after recovery + const langDir = path.join(projectDir, 'lang'); + const files = fs.readdirSync(langDir); + expect(files).toContain('fake-project-de_DE.po'); + expect(files).toContain('fake-project-de_DE.mo'); + }, 30000); + + it('should fail after exhausting retries on persistent 429', async () => { + const puprc = getPuprc({ + i18n: [{ + url: `${serverUrl}/api/projects/wp-plugins/%slug%/always-rate-limited`, + textdomain: 'fake-project', + slug: 'fake-project', + path: 'lang', + }], + }); + writePuprc(puprc, projectDir); + + // With --retries 1 --delay 1, it tries once, gets 429, waits, then exhausts retries. + const result = await runPup('i18n --retries 1 --delay 1', { cwd: projectDir }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Rate limited (HTTP 429)'); + expect(result.output).toContain('Download failed'); + expect(result.output).toContain('Failed to download language files'); + }, 30000); });