i18n-ai-translate
Version:
AI-powered localization CLI, Node library, and GitHub Action. Translate i18next JSON, Gettext PO, Java .properties, and iOS .strings with ChatGPT, Claude, Gemini, or local Ollama models.
868 lines • 41.6 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const fr = (v) => `${v}_fr`;
const es = (v) => `${v}_es`;
function fakeTranslateCtx(ctx) {
const translateFn = ctx.options.outputLanguageCode === "fr" ? fr : es;
return Object.fromEntries(Object.entries(ctx.flatInput).map(([k, v]) => [
k,
translateFn(v),
]));
}
// These tests exercise the translate / translateFile / translateDirectory
// orchestration around the pipelines, not the pipelines themselves.
// Stubbing the CSV and JSON pipelines keeps the tests fast and
// deterministic. End-to-end coverage of the real pipelines lives in
// concurrency.spec.ts.
jest.mock("../generate_json/generate", () => ({
__esModule: true,
default: class GenerateTranslationJSON {
translateJSON(ctx) {
return fakeTranslateCtx(ctx);
}
},
}));
jest.mock("../generate_csv/generate", () => ({
__esModule: true,
default: (ctx) => fakeTranslateCtx(ctx),
}));
// eslint-disable-next-line import/first
const fs_1 = __importDefault(require("fs"));
// eslint-disable-next-line import/first
const os_1 = __importDefault(require("os"));
// eslint-disable-next-line import/first
const path_1 = __importDefault(require("path"));
// eslint-disable-next-line import/first
const utils = __importStar(require("../utils"));
// eslint-disable-next-line import/first
const translate_1 = require("../translate");
// eslint-disable-next-line import/first
const translate_directory_1 = require("../translate_directory");
// eslint-disable-next-line import/first
const translate_file_1 = require("../translate_file");
// eslint-disable-next-line import/first
const engine_1 = __importDefault(require("../enums/engine"));
// eslint-disable-next-line import/first
const prompt_mode_1 = __importDefault(require("../enums/prompt_mode"));
// eslint-disable-next-line import/first
const rate_limiter_1 = __importDefault(require("../rate_limiter"));
// eslint-disable-next-line import/first
const gettext_parser_1 = require("gettext-parser");
// eslint-disable-next-line import/first
const cache_1 = require("../cache");
const mkCaseDir = () => fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), "i18n-case-"));
// Minimal PO file: a header plus one msgid/msgstr per pair. Source
// files pass "" for the msgstr; target files carry existing values.
const poFile = (lang, pairs) => [
"msgid \"\"",
"msgstr \"\"",
"\"Content-Type: text/plain; charset=UTF-8\\n\"",
`"Language: ${lang}\\n"`,
"",
...pairs.flatMap(([id, str]) => [
`msgid "${id}"`,
`msgstr "${str}"`,
"",
]),
].join("\n");
describe.each(Object.values(prompt_mode_1.default))("translate (promptMode=%s)", (promptMode) => {
it("translates a flat JSON object", async () => {
const result = await (0, translate_1.translate)({
engine: engine_1.default.ChatGPT,
inputJSON: { hello: "Hello" },
inputLanguageCode: "en",
model: "gpt-4o",
outputLanguageCode: "fr",
promptMode,
rateLimitMs: 0,
});
expect(result).toEqual({ hello: fr("Hello") });
});
it("translates a nested JSON object", async () => {
const result = await (0, translate_1.translate)({
engine: engine_1.default.ChatGPT,
inputJSON: { greeting: { text: "Hello" } },
inputLanguageCode: "en",
model: "gpt-4o",
outputLanguageCode: "fr",
promptMode,
rateLimitMs: 0,
});
expect(result).toEqual({ greeting: { text: fr("Hello") } });
});
it("de-duplicates identical strings and includes them all in output", async () => {
const input = { a: "Hello", b: "Hello", c: { d: "Hello" } };
const result = await (0, translate_1.translate)({
engine: engine_1.default.ChatGPT,
inputJSON: input,
inputLanguageCode: "en",
model: "gpt-4o",
outputLanguageCode: "fr",
promptMode,
rateLimitMs: 0,
});
expect(result).toEqual({
a: fr("Hello"),
b: fr("Hello"),
c: { d: fr("Hello") },
});
});
});
describe("translate with a cache", () => {
it("serves cached hits without calling the model and records misses", async () => {
const cache = (0, cache_1.createCache)();
// Seed a value the fake pipeline would otherwise render as
// "Hello_fr"; a sentinel proves the cache short-circuited it.
(0, cache_1.setCachedTranslation)(cache, "en", "fr", "", "Hello", "CACHED_HELLO");
const result = await (0, translate_1.translate)({
cache,
engine: engine_1.default.ChatGPT,
inputJSON: { greeting: "Hello", other: "World" },
inputLanguageCode: "en",
model: "gpt-4o",
outputLanguageCode: "fr",
promptMode: prompt_mode_1.default.JSON,
rateLimitMs: 0,
});
// greeting came from the cache; other went through the model.
expect(result).toEqual({ greeting: "CACHED_HELLO", other: fr("World") });
// The freshly translated miss is now cached for next time.
expect((0, cache_1.getCachedTranslation)(cache, "en", "fr", "", "World")).toBe(fr("World"));
});
it("does not reuse a cached entry across a different context", async () => {
const cache = (0, cache_1.createCache)();
(0, cache_1.setCachedTranslation)(cache, "en", "fr", "", "Hello", "CACHED_HELLO");
const result = await (0, translate_1.translate)({
cache,
context: "a formal banking app",
engine: engine_1.default.ChatGPT,
inputJSON: { greeting: "Hello" },
inputLanguageCode: "en",
model: "gpt-4o",
outputLanguageCode: "fr",
promptMode: prompt_mode_1.default.JSON,
rateLimitMs: 0,
});
// Different context → cache miss → model translation, not the sentinel.
expect(result).toEqual({ greeting: fr("Hello") });
});
});
describe.each(Object.values(prompt_mode_1.default))("translateDiff (promptMode=%s)", (promptMode) => {
it("only touches added / changed keys", async () => {
const before = { greeting: "Hello", unchanged: "Stay" };
const after = { added: "New", greeting: "Hi" };
const out = await (0, translate_1.translateDiff)({
engine: engine_1.default.ChatGPT,
inputJSONAfter: after,
inputJSONBefore: before,
inputLanguageCode: "en",
model: "gpt-4o",
promptMode,
rateLimitMs: 0,
toUpdateJSONs: {
fr: { greeting: "Bonjour", unchanged: "Rester" },
},
});
const frOut = out.fr;
expect(frOut).toEqual({ added: fr("New"), greeting: fr("Hi") });
});
it("preserves existing translations for keys that were not added/modified/deleted", async () => {
// Regression test for the data-loss bug where translateDiff
// wiped existing target keys on any diff run.
const before = { keepA: "A", keepB: "B" };
const after = { added: "New", keepA: "A", keepB: "B" };
const out = await (0, translate_1.translateDiff)({
engine: engine_1.default.ChatGPT,
inputJSONAfter: after,
inputJSONBefore: before,
inputLanguageCode: "en",
model: "gpt-4o",
promptMode,
rateLimitMs: 0,
toUpdateJSONs: {
fr: { keepA: "Pre-existing A", keepB: "Pre-existing B" },
},
});
expect(out.fr).toEqual({
added: fr("New"),
keepA: "Pre-existing A",
keepB: "Pre-existing B",
});
});
it("only touches added / changed keys with nested objects", async () => {
const before = { greeting: { text: "Hello" }, unchanged: "Stay" };
const after = { added: "New", greeting: { text: "Hi" } };
const out = await (0, translate_1.translateDiff)({
engine: engine_1.default.ChatGPT,
inputJSONAfter: after,
inputJSONBefore: before,
inputLanguageCode: "en",
model: "gpt-4o",
promptMode,
rateLimitMs: 0,
toUpdateJSONs: {
fr: { greeting: { text: "Bonjour" }, unchanged: "Rester" },
},
});
const frOut = out.fr;
expect(frOut).toEqual({
added: fr("New"),
greeting: { text: fr("Hi") },
});
});
it("prunes removed keys", async () => {
const before = { greeting: "Hello", unused: "Unused" };
const after = { greeting: "Hi" };
const out = await (0, translate_1.translateDiff)({
engine: engine_1.default.ChatGPT,
inputJSONAfter: after,
inputJSONBefore: before,
inputLanguageCode: "en",
model: "gpt-4o",
promptMode,
rateLimitMs: 0,
toUpdateJSONs: {
fr: { greeting: "Bonjour", unused: "Obsolete" },
},
});
const frOut = out.fr;
expect(frOut).toEqual({ greeting: fr("Hi") }); // 'unused' pruned
});
it("handles empty input gracefully", async () => {
const out = await (0, translate_1.translateDiff)({
engine: engine_1.default.ChatGPT,
inputJSONAfter: {},
inputJSONBefore: {},
inputLanguageCode: "en",
model: "gpt-4o",
promptMode,
rateLimitMs: 0,
toUpdateJSONs: { fr: {} },
});
expect(out).toEqual({ fr: {} });
});
it("handles multiple languages", async () => {
const before = { greeting: "Hello" };
const after = { added: "New", greeting: "Hi" };
const out = await (0, translate_1.translateDiff)({
engine: engine_1.default.ChatGPT,
inputJSONAfter: after,
inputJSONBefore: before,
inputLanguageCode: "en",
model: "gpt-4o",
promptMode,
rateLimitMs: 0,
toUpdateJSONs: {
es: { greeting: "Hola" },
fr: { greeting: "Bonjour" },
},
});
expect(out.fr).toEqual({ added: fr("New"), greeting: fr("Hi") });
expect(out.es).toEqual({ added: es("New"), greeting: es("Hi") });
});
});
describe.each(Object.values(prompt_mode_1.default))("translateFile (promptMode=%s)", (promptMode) => {
it("creates a sibling file with translated JSON", async () => {
const dir = mkCaseDir();
const inputPath = path_1.default.join(dir, "en.json");
const outputPath = path_1.default.join(dir, "fr.json");
fs_1.default.writeFileSync(inputPath, JSON.stringify({ cat: "Cat" }));
await (0, translate_file_1.translateFile)({
engine: engine_1.default.ChatGPT,
forceLanguageName: "fr",
inputFilePath: inputPath,
inputLanguageCode: "en",
model: "gpt-4o",
outputFilePath: outputPath,
promptMode,
rateLimitMs: 0,
});
const translated = JSON.parse(fs_1.default.readFileSync(outputPath, "utf-8"));
expect(translated).toEqual({ cat: fr("Cat") });
});
it("handles empty input file gracefully", async () => {
const dir = mkCaseDir();
const inputPath = path_1.default.join(dir, "en.json");
const outputPath = path_1.default.join(dir, "fr.json");
fs_1.default.writeFileSync(inputPath, JSON.stringify({}));
await (0, translate_file_1.translateFile)({
engine: engine_1.default.ChatGPT,
forceLanguageName: "fr",
inputFilePath: inputPath,
inputLanguageCode: "en",
model: "gpt-4o",
outputFilePath: outputPath,
promptMode,
rateLimitMs: 0,
});
const translated = JSON.parse(fs_1.default.readFileSync(outputPath, "utf-8"));
expect(translated).toEqual({});
});
});
describe.each(Object.values(prompt_mode_1.default))("translateFileDiff (promptMode=%s)", (promptMode) => {
it("updates only the changed keys in-place", async () => {
const dir = mkCaseDir();
const beforePath = path_1.default.join(dir, "before_en.json");
const afterPath = path_1.default.join(dir, "after_en.json");
const frPath = path_1.default.join(dir, "fr.json");
fs_1.default.writeFileSync(beforePath, JSON.stringify({ key: "Old" }));
fs_1.default.writeFileSync(afterPath, JSON.stringify({ added: "Yes", key: "New" }));
fs_1.default.writeFileSync(frPath, JSON.stringify({ key: "Ancien" }));
await (0, translate_file_1.translateFileDiff)({
engine: engine_1.default.ChatGPT,
inputAfterFileOrPath: afterPath,
inputBeforeFileOrPath: beforePath,
inputLanguageCode: "en",
model: "gpt-4o",
promptMode,
rateLimitMs: 0,
});
const out = JSON.parse(fs_1.default.readFileSync(frPath, "utf-8"));
expect(out).toEqual({ added: fr("Yes"), key: fr("New") });
});
it("prunes removed keys", async () => {
const dir = mkCaseDir();
const beforePath = path_1.default.join(dir, "before_en.json");
const afterPath = path_1.default.join(dir, "after_en.json");
const frPath = path_1.default.join(dir, "fr.json");
fs_1.default.writeFileSync(beforePath, JSON.stringify({ key: "Old", unused: "Unused" }));
fs_1.default.writeFileSync(afterPath, JSON.stringify({ key: "New" }));
fs_1.default.writeFileSync(frPath, JSON.stringify({ key: "Ancien", unused: "Obsolete" }));
await (0, translate_file_1.translateFileDiff)({
engine: engine_1.default.ChatGPT,
inputAfterFileOrPath: afterPath,
inputBeforeFileOrPath: beforePath,
inputLanguageCode: "en",
model: "gpt-4o",
promptMode,
rateLimitMs: 0,
});
const out = JSON.parse(fs_1.default.readFileSync(frPath, "utf-8"));
expect(out).toEqual({ key: fr("New") }); // 'unused' pruned
});
it("handles multiple languages", async () => {
const dir = mkCaseDir();
const beforePath = path_1.default.join(dir, "before_en.json");
const afterPath = path_1.default.join(dir, "after_en.json");
const frPath = path_1.default.join(dir, "fr.json");
const esPath = path_1.default.join(dir, "es.json");
fs_1.default.writeFileSync(beforePath, JSON.stringify({ key: "Old" }));
fs_1.default.writeFileSync(afterPath, JSON.stringify({ added: "Yes", key: "New" }));
fs_1.default.writeFileSync(frPath, JSON.stringify({ key: "Ancien" }));
fs_1.default.writeFileSync(esPath, JSON.stringify({ key: "Viejo" }));
await (0, translate_file_1.translateFileDiff)({
engine: engine_1.default.ChatGPT,
inputAfterFileOrPath: afterPath,
inputBeforeFileOrPath: beforePath,
inputLanguageCode: "en",
model: "gpt-4o",
promptMode,
rateLimitMs: 0,
});
const frOut = JSON.parse(fs_1.default.readFileSync(frPath, "utf-8"));
const esOut = JSON.parse(fs_1.default.readFileSync(esPath, "utf-8"));
expect(frOut).toEqual({ added: fr("Yes"), key: fr("New") });
expect(esOut).toEqual({ added: es("Yes"), key: es("New") });
});
it("skips targets listed in --exclude-languages", async () => {
const dir = mkCaseDir();
const beforePath = path_1.default.join(dir, "before_en.json");
const afterPath = path_1.default.join(dir, "after_en.json");
const frPath = path_1.default.join(dir, "fr.json");
const esPath = path_1.default.join(dir, "es.json");
fs_1.default.writeFileSync(beforePath, JSON.stringify({ key: "Old" }));
fs_1.default.writeFileSync(afterPath, JSON.stringify({ added: "Yes", key: "New" }));
fs_1.default.writeFileSync(frPath, JSON.stringify({ key: "Ancien" }));
fs_1.default.writeFileSync(esPath, JSON.stringify({ key: "Viejo" }));
await (0, translate_file_1.translateFileDiff)({
engine: engine_1.default.ChatGPT,
excludeLanguages: ["fr"],
inputAfterFileOrPath: afterPath,
inputBeforeFileOrPath: beforePath,
inputLanguageCode: "en",
model: "gpt-4o",
promptMode,
rateLimitMs: 0,
});
// fr was excluded, so it retains its original content;
// es is still translated.
const frOut = JSON.parse(fs_1.default.readFileSync(frPath, "utf-8"));
const esOut = JSON.parse(fs_1.default.readFileSync(esPath, "utf-8"));
expect(frOut).toEqual({ key: "Ancien" });
expect(esOut).toEqual({ added: es("Yes"), key: es("New") });
});
});
describe.each(Object.values(prompt_mode_1.default))("translateDirectory (promptMode=%s)", (promptMode) => {
it("replicates the directory hierarchy for the target language", async () => {
const dir = mkCaseDir();
const enDir = path_1.default.join(dir, "en");
fs_1.default.mkdirSync(enDir, { recursive: true });
const enFile = path_1.default.join(enDir, "app.json");
fs_1.default.writeFileSync(enFile, JSON.stringify({ welcome: "Welcome" }));
await (0, translate_directory_1.translateDirectory)({
baseDirectory: dir,
engine: engine_1.default.ChatGPT,
inputLanguageCode: "en",
model: "gpt-4o",
outputLanguageCode: "fr",
promptMode,
rateLimitMs: 0,
});
const frFile = path_1.default.join(dir, "fr", "app.json");
const frJSON = JSON.parse(fs_1.default.readFileSync(frFile, "utf-8"));
expect(frJSON).toEqual({ welcome: fr("Welcome") });
});
it("handles nested directories", async () => {
const dir = mkCaseDir();
const enDir = path_1.default.join(dir, "en", "nested");
fs_1.default.mkdirSync(enDir, { recursive: true });
const enFile = path_1.default.join(enDir, "app.json");
fs_1.default.writeFileSync(enFile, JSON.stringify({ greeting: "Hello" }));
await (0, translate_directory_1.translateDirectory)({
baseDirectory: dir,
engine: engine_1.default.ChatGPT,
inputLanguageCode: "en",
model: "gpt-4o",
outputLanguageCode: "fr",
promptMode,
rateLimitMs: 0,
});
const frFile = path_1.default.join(dir, "fr", "nested", "app.json");
const frJSON = JSON.parse(fs_1.default.readFileSync(frFile, "utf-8"));
expect(frJSON).toEqual({ greeting: fr("Hello") });
});
it("handles multiple files with various amounts of nesting", async () => {
const dir = mkCaseDir();
const enDir = path_1.default.join(dir, "en");
fs_1.default.mkdirSync(enDir, { recursive: true });
// ── layout ────────────────────────────────────────────
// base/en/app.json { welcome: "Welcome" }
// base/en/nested/app.json { greeting: "Hello" }
const enFile1 = path_1.default.join(enDir, "app.json");
const enFile2 = path_1.default.join(enDir, "nested", "app.json");
fs_1.default.mkdirSync(path_1.default.dirname(enFile2), { recursive: true });
fs_1.default.writeFileSync(enFile1, JSON.stringify({ welcome: "Welcome" }));
fs_1.default.writeFileSync(enFile2, JSON.stringify({ greeting: "Hello" }));
await (0, translate_directory_1.translateDirectory)({
baseDirectory: dir,
engine: engine_1.default.ChatGPT,
inputLanguageCode: "en",
model: "gpt-4o",
outputLanguageCode: "fr",
promptMode,
rateLimitMs: 0,
});
const frFile1 = path_1.default.join(dir, "fr", "app.json");
const frFile2 = path_1.default.join(dir, "fr", "nested", "app.json");
const frJSON1 = JSON.parse(fs_1.default.readFileSync(frFile1, "utf-8"));
const frJSON2 = JSON.parse(fs_1.default.readFileSync(frFile2, "utf-8"));
expect(frJSON1).toEqual({ welcome: fr("Welcome") });
expect(frJSON2).toEqual({ greeting: fr("Hello") });
});
});
describe.each(Object.values(prompt_mode_1.default))("translateDirectoryDiff (promptMode=%s)", (promptMode) => {
it("preserves existing target keys for untouched source entries", async () => {
// Regression test for the data-loss bug where directory diff
// wiped untouched keys from pre-existing target files.
const dir = mkCaseDir();
const enBefore = path_1.default.join(dir, "en_before");
const enAfter = path_1.default.join(dir, "en_after");
const frDir = path_1.default.join(dir, "fr");
fs_1.default.mkdirSync(enBefore, { recursive: true });
fs_1.default.mkdirSync(enAfter, { recursive: true });
fs_1.default.mkdirSync(frDir, { recursive: true });
// keepA + keepB are unchanged; 'added' is new. Existing fr
// values for keepA/keepB must survive the diff.
fs_1.default.writeFileSync(path_1.default.join(enBefore, "app.json"), JSON.stringify({ keepA: "A", keepB: "B" }));
fs_1.default.writeFileSync(path_1.default.join(enAfter, "app.json"), JSON.stringify({ added: "New", keepA: "A", keepB: "B" }));
fs_1.default.writeFileSync(path_1.default.join(frDir, "app.json"), JSON.stringify({
keepA: "Pre-existing A",
keepB: "Pre-existing B",
}));
await (0, translate_directory_1.translateDirectoryDiff)({
baseDirectory: dir,
engine: engine_1.default.ChatGPT,
inputFolderNameAfter: "en_after",
inputFolderNameBefore: "en_before",
inputLanguageCode: "en",
model: "gpt-4o",
promptMode,
rateLimitMs: 0,
});
const updated = JSON.parse(fs_1.default.readFileSync(path_1.default.join(frDir, "app.json"), "utf-8"));
expect(updated).toEqual({
added: fr("New"),
keepA: "Pre-existing A",
keepB: "Pre-existing B",
});
});
it("writes translations for changed keys and prunes removed keys", async () => {
const dir = mkCaseDir();
// ── layout ────────────────────────────────────────────
// base/en_before/app.json { hello: "Hello", unused: "Unused" }
// base/en_after /app.json { hello: "Hi", bye: "Bye" }
// base/fr /app.json { hello: "Bonjour", unused: "Obso" }
const enBefore = path_1.default.join(dir, "en_before");
const enAfter = path_1.default.join(dir, "en_after");
const frDir = path_1.default.join(dir, "fr");
fs_1.default.mkdirSync(enBefore, { recursive: true });
fs_1.default.mkdirSync(enAfter, { recursive: true });
fs_1.default.mkdirSync(frDir, { recursive: true });
const beforeFile = path_1.default.join(enBefore, "app.json");
const afterFile = path_1.default.join(enAfter, "app.json");
const frFile = path_1.default.join(frDir, "app.json");
fs_1.default.writeFileSync(beforeFile, JSON.stringify({ hello: "Hello", unused: "Unused" }));
fs_1.default.writeFileSync(afterFile, JSON.stringify({ bye: "Bye", hello: "Hi" }));
fs_1.default.writeFileSync(frFile, JSON.stringify({ hello: "Bonjour", unused: "Obso" }));
await (0, translate_directory_1.translateDirectoryDiff)({
baseDirectory: dir,
engine: engine_1.default.ChatGPT,
inputFolderNameAfter: "en_after",
inputFolderNameBefore: "en_before",
inputLanguageCode: "en",
model: "gpt-4o",
promptMode,
rateLimitMs: 0,
verbose: true,
});
const updated = JSON.parse(fs_1.default.readFileSync(frFile, "utf-8"));
expect(updated).toEqual({ bye: fr("Bye"), hello: fr("Hi") }); // 'unused' pruned
});
it("handles nested directories and multiple files", async () => {
const dir = mkCaseDir();
// ── layout ────────────────────────────────────────────
// base/en_before/app.json { welcome: "Welcome" }
// base/en_after /app.json { greeting: "Hello" }
// base/en_after /nested/app.json { farewell: "Goodbye" }
// base/fr /app.json { welcome: "Bienvenue" }
const enBefore = path_1.default.join(dir, "en_before");
const enAfter = path_1.default.join(dir, "en_after");
const frDir = path_1.default.join(dir, "fr");
fs_1.default.mkdirSync(enBefore, { recursive: true });
fs_1.default.mkdirSync(enAfter, { recursive: true });
fs_1.default.mkdirSync(frDir, { recursive: true });
fs_1.default.mkdirSync(path_1.default.join(enAfter, "nested"), { recursive: true });
const beforeFile = path_1.default.join(enBefore, "app.json");
const afterFile = path_1.default.join(enAfter, "app.json");
const afterNestedFile = path_1.default.join(enAfter, "nested", "app.json");
const frFile = path_1.default.join(frDir, "app.json");
const frNestedFile = path_1.default.join(frDir, "nested", "app.json");
fs_1.default.writeFileSync(beforeFile, JSON.stringify({ welcome: "Welcome" }));
fs_1.default.writeFileSync(afterFile, JSON.stringify({ greeting: "Hello" }));
fs_1.default.writeFileSync(afterNestedFile, JSON.stringify({ farewell: "Goodbye" }));
fs_1.default.writeFileSync(frFile, JSON.stringify({ welcome: "Bienvenue" }));
await (0, translate_directory_1.translateDirectoryDiff)({
baseDirectory: dir,
engine: engine_1.default.ChatGPT,
inputFolderNameAfter: "en_after",
inputFolderNameBefore: "en_before",
inputLanguageCode: "en",
model: "gpt-4o",
promptMode,
rateLimitMs: 0,
verbose: true,
});
const updated = JSON.parse(fs_1.default.readFileSync(frFile, "utf-8"));
const updatedNested = JSON.parse(fs_1.default.readFileSync(frNestedFile, "utf-8"));
expect(updated).toEqual({ greeting: fr("Hello") });
expect(updatedNested).toEqual({ farewell: fr("Goodbye") });
});
it("handles multiple languages in the directory", async () => {
const dir = mkCaseDir();
// ── layout ────────────────────────────────────────────
// base/en_before/app.json { hello: "Hello" }
// base/en_after /app.json { hello: "Hi" }
// base/fr /app.json { hello: "Bonjour" }
// base/es /app.json { hello: "Hola" }
const enBefore = path_1.default.join(dir, "en_before");
const enAfter = path_1.default.join(dir, "en_after");
const frDir = path_1.default.join(dir, "fr");
const esDir = path_1.default.join(dir, "es");
fs_1.default.mkdirSync(enBefore, { recursive: true });
fs_1.default.mkdirSync(enAfter, { recursive: true });
fs_1.default.mkdirSync(frDir, { recursive: true });
fs_1.default.mkdirSync(esDir, { recursive: true });
const beforeFile = path_1.default.join(enBefore, "app.json");
const afterFile = path_1.default.join(enAfter, "app.json");
const frFile = path_1.default.join(frDir, "app.json");
const esFile = path_1.default.join(esDir, "app.json");
fs_1.default.writeFileSync(beforeFile, JSON.stringify({ hello: "Hello" }));
fs_1.default.writeFileSync(afterFile, JSON.stringify({ hello: "Hi" }));
fs_1.default.writeFileSync(frFile, JSON.stringify({ hello: "Bonjour" }));
fs_1.default.writeFileSync(esFile, JSON.stringify({ hello: "Hola" }));
await (0, translate_directory_1.translateDirectoryDiff)({
baseDirectory: dir,
engine: engine_1.default.ChatGPT,
inputFolderNameAfter: "en_after",
inputFolderNameBefore: "en_before",
inputLanguageCode: "en",
model: "gpt-4o",
promptMode,
rateLimitMs: 0,
verbose: true,
});
const updatedFr = JSON.parse(fs_1.default.readFileSync(frFile, "utf-8"));
const updatedEs = JSON.parse(fs_1.default.readFileSync(esFile, "utf-8"));
expect(updatedFr).toEqual({ hello: fr("Hi") });
expect(updatedEs).toEqual({ hello: es("Hi") });
});
});
describe("PO format end-to-end (mocked pipeline)", () => {
it("translateFile writes a translated .po sibling", async () => {
const dir = mkCaseDir();
const inputPath = path_1.default.join(dir, "en.po");
const outputPath = path_1.default.join(dir, "fr.po");
fs_1.default.writeFileSync(inputPath, poFile("en", [
["Cat", ""],
["Dog", ""],
]));
await (0, translate_file_1.translateFile)({
engine: engine_1.default.ChatGPT,
forceLanguageName: "fr",
inputFilePath: inputPath,
inputLanguageCode: "en",
model: "gpt-4o",
outputFilePath: outputPath,
promptMode: prompt_mode_1.default.JSON,
rateLimitMs: 0,
});
const parsed = gettext_parser_1.po.parse(fs_1.default.readFileSync(outputPath, "utf-8"));
expect(parsed.translations[""].Cat.msgstr[0]).toBe(fr("Cat"));
expect(parsed.translations[""].Dog.msgstr[0]).toBe(fr("Dog"));
});
it("translateFileDiff updates changed keys and preserves existing msgstr", async () => {
const dir = mkCaseDir();
const beforePath = path_1.default.join(dir, "before_en.po");
const afterPath = path_1.default.join(dir, "after_en.po");
const frPath = path_1.default.join(dir, "fr.po");
fs_1.default.writeFileSync(beforePath, poFile("en", [["Cat", ""]]));
fs_1.default.writeFileSync(afterPath, poFile("en", [
["Cat", ""],
["Dog", ""],
]));
fs_1.default.writeFileSync(frPath, poFile("fr", [["Cat", "Chat"]]));
await (0, translate_file_1.translateFileDiff)({
engine: engine_1.default.ChatGPT,
inputAfterFileOrPath: afterPath,
inputBeforeFileOrPath: beforePath,
inputLanguageCode: "en",
model: "gpt-4o",
promptMode: prompt_mode_1.default.JSON,
rateLimitMs: 0,
});
const parsed = gettext_parser_1.po.parse(fs_1.default.readFileSync(frPath, "utf-8"));
// 'Cat' was unchanged in the source, so its existing translation
// survives; 'Dog' is new and gets translated.
expect(parsed.translations[""].Cat.msgstr[0]).toBe("Chat");
expect(parsed.translations[""].Dog.msgstr[0]).toBe(fr("Dog"));
});
it("translateDirectory replicates a .po file for the target language", async () => {
const dir = mkCaseDir();
const enDir = path_1.default.join(dir, "en");
fs_1.default.mkdirSync(enDir, { recursive: true });
fs_1.default.writeFileSync(path_1.default.join(enDir, "app.po"), poFile("en", [["Cat", ""]]));
await (0, translate_directory_1.translateDirectory)({
baseDirectory: dir,
engine: engine_1.default.ChatGPT,
inputLanguageCode: "en",
model: "gpt-4o",
outputLanguageCode: "fr",
promptMode: prompt_mode_1.default.JSON,
rateLimitMs: 0,
});
const parsed = gettext_parser_1.po.parse(fs_1.default.readFileSync(path_1.default.join(dir, "fr", "app.po"), "utf-8"));
expect(parsed.translations[""].Cat.msgstr[0]).toBe(fr("Cat"));
});
it("translateDirectoryDiff preserves existing .po msgstr and adds new keys", async () => {
const dir = mkCaseDir();
const enBefore = path_1.default.join(dir, "en_before");
const enAfter = path_1.default.join(dir, "en_after");
const frDir = path_1.default.join(dir, "fr");
fs_1.default.mkdirSync(enBefore, { recursive: true });
fs_1.default.mkdirSync(enAfter, { recursive: true });
fs_1.default.mkdirSync(frDir, { recursive: true });
fs_1.default.writeFileSync(path_1.default.join(enBefore, "app.po"), poFile("en", [["Cat", ""]]));
fs_1.default.writeFileSync(path_1.default.join(enAfter, "app.po"), poFile("en", [
["Cat", ""],
["Dog", ""],
]));
fs_1.default.writeFileSync(path_1.default.join(frDir, "app.po"), poFile("fr", [["Cat", "Chat"]]));
await (0, translate_directory_1.translateDirectoryDiff)({
baseDirectory: dir,
engine: engine_1.default.ChatGPT,
inputFolderNameAfter: "en_after",
inputFolderNameBefore: "en_before",
inputLanguageCode: "en",
model: "gpt-4o",
promptMode: prompt_mode_1.default.JSON,
rateLimitMs: 0,
});
const parsed = gettext_parser_1.po.parse(fs_1.default.readFileSync(path_1.default.join(frDir, "app.po"), "utf-8"));
expect(parsed.translations[""].Cat.msgstr[0]).toBe("Chat");
expect(parsed.translations[""].Dog.msgstr[0]).toBe(fr("Dog"));
});
});
describe("RateLimiter", () => {
const mockedDelay = utils.delay;
const delayBetweenCallsMs = 500;
afterEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
});
it("returns immediately when no API call has been made", async () => {
const rl = new rate_limiter_1.default(delayBetweenCallsMs, true);
// Date.now() is irrelevant here, but stub anyway for determinism.
jest.spyOn(Date, "now").mockReturnValue(1_000);
await rl.wait();
expect(mockedDelay).not.toHaveBeenCalled();
});
it("returns immediately when enough time has already passed since the last call", async () => {
const now = 10_000;
jest.spyOn(Date, "now").mockReturnValue(now);
const rl = new rate_limiter_1.default(delayBetweenCallsMs, true);
rl.lastAPICall = now - delayBetweenCallsMs - 50; // already past the window
await rl.wait();
expect(mockedDelay).not.toHaveBeenCalled();
});
it("waits the correct time when called too soon and verboseLogging = true", async () => {
const now = 20_000;
const timeRemaining = 125; // ms still to wait
// Stub Date.now() for this test
jest.spyOn(Date, "now").mockReturnValue(now);
const rl = new rate_limiter_1.default(delayBetweenCallsMs, true);
rl.lastAPICall = now - (delayBetweenCallsMs - timeRemaining);
await rl.wait();
expect(mockedDelay).toHaveBeenCalledTimes(1);
expect(mockedDelay).toHaveBeenCalledWith(timeRemaining);
});
it("does not log when verboseLogging = false", async () => {
const now = 30_000;
const timeRemaining = 200;
jest.spyOn(Date, "now").mockReturnValue(now);
const rl = new rate_limiter_1.default(delayBetweenCallsMs, false);
rl.lastAPICall = now - (delayBetweenCallsMs - timeRemaining);
await rl.wait();
expect(mockedDelay).toHaveBeenCalledWith(timeRemaining);
});
it("acquire() spaces concurrent callers by delayBetweenCallsMs", async () => {
const start = 100_000;
jest.spyOn(Date, "now").mockReturnValue(start);
const rl = new rate_limiter_1.default(delayBetweenCallsMs, false);
const callers = 5;
// Kick off N acquires in the same synchronous turn.
await Promise.all(Array.from({ length: callers }, () => rl.acquire()));
// Caller 0 fires immediately; callers 1..N-1 each wait delayBetweenCallsMs
// more than the previous one.
expect(mockedDelay).toHaveBeenCalledTimes(callers - 1);
for (let i = 1; i < callers; i++) {
expect(mockedDelay).toHaveBeenNthCalledWith(i, delayBetweenCallsMs * i);
}
});
it("penalize() pushes every subsequent acquire forward", async () => {
const start = 200_000;
jest.spyOn(Date, "now").mockReturnValue(start);
const rl = new rate_limiter_1.default(delayBetweenCallsMs, false);
const penalty = 3_000;
rl.penalize(penalty);
await rl.acquire();
expect(mockedDelay).toHaveBeenCalledTimes(1);
expect(mockedDelay).toHaveBeenCalledWith(penalty);
});
it("penalize() is a no-op if the proposed slot is already further out", async () => {
const start = 300_000;
jest.spyOn(Date, "now").mockReturnValue(start);
const rl = new rate_limiter_1.default(delayBetweenCallsMs, false);
// Reserve a slot far in the future.
await Promise.all([rl.acquire(), rl.acquire(), rl.acquire()]);
mockedDelay.mockClear();
// A small penalty should not override the larger existing reservation.
rl.penalize(10);
await rl.acquire();
// Next caller should still wait the full 3 * delayBetweenCallsMs gap.
expect(mockedDelay).toHaveBeenCalledWith(delayBetweenCallsMs * 3);
});
it("no TPM cap means acquire() returns immediately regardless of tokens", async () => {
const start = 400_000;
jest.spyOn(Date, "now").mockReturnValue(start);
const rl = new rate_limiter_1.default(0, false); // no RPM cap, no TPM cap
await rl.acquire(1_000_000);
expect(mockedDelay).not.toHaveBeenCalled();
});
it("acquire(tokens) tracks usage and sleeps when TPM cap is reached", async () => {
const start = 500_000;
let clock = start;
jest.spyOn(Date, "now").mockImplementation(() => clock);
const rl = new rate_limiter_1.default(0, false, 100);
// Consume 80 tokens; still under cap.
await rl.acquire(80);
expect(mockedDelay).not.toHaveBeenCalled();
// Next call wants 50 more tokens; 80 + 50 > 100, so it must wait
// for the first entry to fall out of the 60s window.
mockedDelay.mockImplementationOnce(async (ms) => {
// Simulate time passing while the limiter awaits.
clock += ms;
return Promise.resolve();
});
await rl.acquire(50);
expect(mockedDelay).toHaveBeenCalledTimes(1);
});
it("TPM window prunes entries older than 60 seconds", async () => {
let clock = 600_000;
jest.spyOn(Date, "now").mockImplementation(() => clock);
const rl = new rate_limiter_1.default(0, false, 100);
await rl.acquire(80);
// Advance the clock past the 60s window.
clock += 61_000;
// Now the 80-token entry is stale, so an 80-token call should
// fit immediately without sleeping.
mockedDelay.mockClear();
await rl.acquire(80);
expect(mockedDelay).not.toHaveBeenCalled();
});
it("a single call larger than the TPM cap fires anyway (no deadlock)", async () => {
jest.spyOn(Date, "now").mockReturnValue(700_000);
const rl = new rate_limiter_1.default(0, false, 100);
// 500 tokens > 100 TPM; we can't satisfy this, but neither
// should we hang. Fire it and let the provider 429 if it
// actually cares.
await rl.acquire(500);
expect(mockedDelay).not.toHaveBeenCalled();
});
});
//# sourceMappingURL=translate.spec.js.map