From 3cc27fdf5f9b36acc48722976bffbbfeacc81c28 Mon Sep 17 00:00:00 2001 From: Steven Syrek Date: Thu, 23 Apr 2026 13:30:14 +0000 Subject: [PATCH 01/16] test(sync): measure bucket-source readFile call count with template patterns --- .github/ISSUE_TEMPLATE/bug_report.md | 41 + .github/ISSUE_TEMPLATE/feature_request.md | 26 + .gitignore | 11 +- CHANGELOG.md | 253 +- CLAUDE.md | 25 +- CONTRIBUTING.md | 3 +- LICENSE | 2 +- README.md | 84 +- SECURITY.md | 3 + VERSION | 2 +- docs/API.md | 806 +++++- docs/SYNC.md | 973 +++++++ docs/TROUBLESHOOTING.md | 22 + examples/18-cicd-integration.sh | 2 +- examples/29-advanced-translate.sh | 42 +- examples/30-sync-basic.sh | 185 ++ examples/31-sync-ci.sh | 208 ++ examples/32-sync-live-validation.sh | 362 +++ examples/33-tm-list.sh | 30 + examples/34-sync-laravel-php.sh | 139 + examples/README.md | 7 +- examples/run-all.sh | 9 + jest.config.js | 12 + package-lock.json | 37 +- package.json | 9 +- src/api/deepl-client.ts | 5 + src/api/glossary-client.ts | 16 +- src/api/http-client.ts | 10 +- src/api/translation-client.ts | 53 +- src/cli/commands/describe.ts | 40 + src/cli/commands/register-describe.ts | 35 + src/cli/commands/register-sync.ts | 24 + src/cli/commands/register-tm.ts | 40 + src/cli/commands/register-translate.ts | 50 + src/cli/commands/service-factory.ts | 28 + src/cli/commands/sync-command.ts | 598 ++++ src/cli/commands/sync/register-sync-audit.ts | 127 + src/cli/commands/sync/register-sync-export.ts | 77 + src/cli/commands/sync/register-sync-init.ts | 219 ++ src/cli/commands/sync/register-sync-pull.ts | 86 + src/cli/commands/sync/register-sync-push.ts | 80 + .../commands/sync/register-sync-resolve.ts | 108 + src/cli/commands/sync/register-sync-root.ts | 155 + src/cli/commands/sync/register-sync-status.ts | 77 + .../commands/sync/register-sync-validate.ts | 84 + src/cli/commands/sync/sync-options.ts | 118 + src/cli/commands/tm.ts | 32 + .../directory-translation-handler.ts | 5 +- .../translate/document-translation-handler.ts | 5 +- .../translate/file-translation-handler.ts | 76 +- src/cli/commands/translate/index.ts | 1 + .../translate/text-translation-handler.ts | 54 +- src/cli/commands/translate/translate-utils.ts | 4 +- .../translate/translation-options-factory.ts | 90 + src/cli/commands/translate/types.ts | 2 + src/cli/index.ts | 22 +- src/formats/android-xml.ts | 251 ++ src/formats/arb.ts | 99 + src/formats/format.ts | 32 + src/formats/index.ts | 74 + src/formats/ios-strings.ts | 186 ++ src/formats/json.ts | 307 ++ src/formats/pending-comment-buffer.ts | 28 + src/formats/php-arrays.ts | 293 ++ src/formats/php-parser-bridge.ts | 3 + src/formats/po.ts | 530 ++++ src/formats/properties.ts | 178 ++ src/formats/toml.ts | 196 ++ src/formats/util/detect-indent.ts | 19 + src/formats/xcstrings.ts | 67 + src/formats/xliff.ts | 219 ++ src/formats/yaml.ts | 130 + src/services/glossary.ts | 69 +- src/services/translation-memory.ts | 83 + src/services/translation.ts | 29 +- src/sync/sync-bak-cleanup.ts | 134 + src/sync/sync-bucket-walker.ts | 201 ++ src/sync/sync-config.ts | 607 ++++ src/sync/sync-context.ts | 489 ++++ src/sync/sync-differ.ts | 46 + src/sync/sync-export.ts | 74 + src/sync/sync-finalize.ts | 62 + src/sync/sync-glossary-report.ts | 82 + src/sync/sync-glossary.ts | 158 + src/sync/sync-init-validate.ts | 123 + src/sync/sync-init.ts | 347 +++ src/sync/sync-instructions.ts | 136 + src/sync/sync-locale-translator.ts | 567 ++++ src/sync/sync-lock.ts | 196 ++ src/sync/sync-message-preprocess.ts | 153 + src/sync/sync-process-bucket.ts | 386 +++ src/sync/sync-process-lock.ts | 127 + src/sync/sync-resolve.ts | 271 ++ src/sync/sync-service.ts | 415 +++ src/sync/sync-status.ts | 81 + src/sync/sync-tms.ts | 238 ++ src/sync/sync-utils.ts | 82 + src/sync/sync-validate.ts | 76 + src/sync/tm-cache.ts | 58 + src/sync/tms-client.ts | 271 ++ src/sync/translation-validator.ts | 216 ++ src/sync/types.ts | 199 ++ src/types/api.ts | 9 + src/utils/atomic-write.ts | 83 +- src/utils/concurrency.ts | 11 +- src/utils/control-chars.ts | 21 + src/utils/errors.ts | 22 + src/utils/exit-codes.ts | 109 +- src/utils/glob-prefix.ts | 143 + src/utils/icu-preservation.ts | 208 ++ src/utils/logger.ts | 12 + src/utils/text-preservation.ts | 14 +- src/utils/uuid.ts | 16 + tests/__mocks__/php-parser-bridge.ts | 1 + tests/e2e/cli-describe.e2e.test.ts | 84 + .../e2e/cli-document-translation.e2e.test.ts | 11 +- tests/e2e/cli-sync-error-envelope.e2e.test.ts | 256 ++ tests/e2e/cli-sync-force-guard.e2e.test.ts | 182 ++ tests/e2e/cli-sync-init.e2e.test.ts | 133 + tests/e2e/cli-sync-json-contract.e2e.test.ts | 306 ++ tests/e2e/cli-sync-push-pull.e2e.test.ts | 419 +++ tests/e2e/cli-sync-tms.e2e.test.ts | 293 ++ tests/e2e/cli-sync-watch.e2e.test.ts | 113 + tests/e2e/cli-sync.e2e.test.ts | 1130 ++++++++ tests/e2e/cli-tm.e2e.test.ts | 61 + tests/e2e/cli-workflow.e2e.test.ts | 36 +- tests/e2e/mock-deepl-server.cjs | 127 + tests/e2e/mock-tms-push-pull.cjs | 131 + .../fixtures/sync/configs/empty-buckets.yaml | 5 + .../sync/configs/invalid-version.yaml | 8 + tests/fixtures/sync/configs/minimal.yaml | 8 + .../fixtures/sync/configs/missing-locale.yaml | 7 + tests/fixtures/sync/configs/multi-bucket.yaml | 14 + tests/fixtures/sync/configs/valid.yaml | 9 + .../fixtures/sync/context/no-translations.ts | 7 + tests/fixtures/sync/context/sample-intl.tsx | 12 + tests/fixtures/sync/context/sample-react.tsx | 16 + tests/fixtures/sync/context/sample-vue.ts | 10 + .../sync/error-envelopes/config-error.json | 9 + .../sync/error-envelopes/init-success.json | 9 + .../sync/error-envelopes/sync-conflict.json | 9 + .../error-envelopes/validation-error.json | 9 + .../formats/android-xml/strings-arrays.xml | 13 + .../formats/android-xml/strings-complex.xml | 11 + .../formats/android-xml/strings-empty.xml | 3 + .../formats/android-xml/strings-plurals.xml | 13 + .../sync/formats/android-xml/strings.xml | 7 + .../sync/formats/arb/app_en-minimal.arb | 4 + .../sync/formats/arb/app_en-no-metadata.arb | 5 + .../sync/formats/arb/app_en-plurals.arb | 21 + tests/fixtures/sync/formats/arb/app_en.arb | 26 + .../ios-strings/Localizable-complex.strings | 14 + .../ios-strings/Localizable-empty.strings | 1 + .../ios-strings/Localizable-escapes.strings | 7 + .../formats/ios-strings/Localizable.strings | 8 + tests/fixtures/sync/formats/json/en-icu.json | 4 + .../fixtures/sync/formats/json/en-nested.json | 14 + .../sync/formats/json/en-with-metadata.json | 5 + tests/fixtures/sync/formats/json/en.json | 5 + .../laravel-php/01-single-quote-escape.php | 6 + .../laravel-php/02-double-quote-escapes.php | 7 + .../laravel-php/03-interpolation-rejected.php | 5 + .../laravel-php/04-heredoc-rejected.php | 9 + .../laravel-php/05-concat-rejected.php | 5 + .../formats/laravel-php/06-mixed-syntax.php | 11 + .../laravel-php/07-colon-placeholder.php | 7 + .../laravel-php/08-trailing-commas.php | 10 + .../laravel-php/09-ast-idempotence.php | 12 + .../laravel-php/10-empty-nested-array.php | 10 + .../laravel-php/11-dot-key-vs-nested.php | 8 + .../formats/laravel-php/12-escaped-dollar.php | 6 + .../sync/formats/laravel-php/13-utf8-bom.php | 5 + .../laravel-php/14-literal-pipe-in-prose.php | 6 + .../15-irregular-whitespace-and-comments.php | 21 + .../sync/formats/po/messages-fuzzy.po | 18 + .../sync/formats/po/messages-msgctxt.po | 19 + .../sync/formats/po/messages-multiline.po | 18 + .../sync/formats/po/messages-plurals.po | 19 + tests/fixtures/sync/formats/po/messages.po | 26 + tests/fixtures/sync/formats/po/template.pot | 32 + .../expected-after-sync/de.properties | 4 + .../sync/formats/properties/source.properties | 4 + .../formats/toml/expected-after-sync/de.toml | 6 + tests/fixtures/sync/formats/toml/source.toml | 6 + .../expected-after-sync/de.xcstrings | 55 + .../sync/formats/xcstrings/source.xcstrings | 37 + .../sync/formats/xliff/messages-no-target.xlf | 13 + .../sync/formats/xliff/messages-v1.xlf | 20 + .../sync/formats/xliff/messages-v2.xlf | 20 + .../sync/formats/yaml/en-multiline.yaml | 7 + .../sync/formats/yaml/en-reserved-words.yaml | 5 + tests/fixtures/sync/formats/yaml/en.yaml | 3 + .../sync/laravel_php-pipe-plural/README.md | 15 + .../sync/laravel_php-pipe-plural/de.php | 8 + .../sync/laravel_php-pipe-plural/en.php | 8 + tests/fixtures/sync/lockfiles/empty.lock | 12 + .../sync/lockfiles/fully-translated.lock | 49 + tests/fixtures/sync/lockfiles/partial.lock | 27 + .../fixtures/sync/lockfiles/with-deleted.lock | 27 + tests/helpers/assert-error-envelope.ts | 163 ++ tests/helpers/index.ts | 9 + tests/helpers/mock-factories.ts | 35 +- tests/helpers/sync-harness.ts | 156 + tests/helpers/tms-nock.ts | 89 + .../cli-glossary.integration.test.ts | 2 +- .../cli-style-rules.integration.test.ts | 6 +- .../cli-translate.integration.test.ts | 232 +- .../integration/cli-usage.integration.test.ts | 2 +- .../sync-auto-commit.integration.test.ts | 245 ++ .../sync-concurrent.integration.test.ts | 251 ++ ...-export-path-traversal.integration.test.ts | 115 + ...nc-init-format-choices.integration.test.ts | 50 + .../integration/sync-init.integration.test.ts | 253 ++ ...translator-plural-perf.integration.test.ts | 82 + .../sync-php-arrays.integration.test.ts | 154 + .../sync-properties.integration.test.ts | 96 + .../sync-scan-bounds.integration.test.ts | 121 + ...stale-lock-fg-coalesce.integration.test.ts | 177 ++ .../sync-symlink-safety.integration.test.ts | 204 ++ ...te-patterns-dedup-perf.integration.test.ts | 113 + .../sync-template-prep.integration.test.ts | 133 + .../sync-tms-push.integration.test.ts | 178 ++ .../integration/sync-tms.integration.test.ts | 221 ++ .../integration/sync-toml.integration.test.ts | 93 + ...sync-watch-reliability.integration.test.ts | 310 ++ .../sync-watch.integration.test.ts | 88 + .../sync-xcstrings.integration.test.ts | 94 + tests/integration/sync.integration.test.ts | 2536 ++++++++++++++++ tests/unit/atomic-write.test.ts | 115 +- tests/unit/cli-did-you-mean.test.ts | 21 +- tests/unit/cli-no-args-exit.test.ts | 36 +- tests/unit/cli/describe.test.ts | 94 + .../unit/cli/register-sync-force-help.test.ts | 53 + tests/unit/cli/register-sync-init.test.ts | 157 + tests/unit/cli/register-sync-root.test.ts | 130 + .../register-sync-scan-context-help.test.ts | 52 + tests/unit/cli/register-sync-tms-help.test.ts | 69 + .../register-sync.commander-snapshot.test.ts | 607 ++++ tests/unit/docs/sync-terminology.test.ts | 84 + tests/unit/errors.test.ts | 55 + tests/unit/file-translation-handler.test.ts | 209 ++ tests/unit/formats/android-xml.test.ts | 290 ++ tests/unit/formats/arb.test.ts | 332 +++ tests/unit/formats/detect-indent.test.ts | 71 + tests/unit/formats/format-registry.test.ts | 108 + tests/unit/formats/ios-strings.test.ts | 483 ++++ tests/unit/formats/json.test.ts | 611 ++++ .../formats/pending-comment-buffer.test.ts | 142 + tests/unit/formats/php-arrays.test.ts | 734 +++++ tests/unit/formats/po.test.ts | 449 +++ tests/unit/formats/properties.test.ts | 330 +++ tests/unit/formats/toml.test.ts | 315 ++ tests/unit/formats/xcstrings.test.ts | 221 ++ tests/unit/formats/xliff.test.ts | 316 ++ tests/unit/formats/yaml.test.ts | 369 +++ tests/unit/glossary-client.test.ts | 18 + tests/unit/glossary-service.test.ts | 238 ++ tests/unit/logger.test.ts | 123 + tests/unit/register-translate.test.ts | 213 +- tests/unit/sync/sync-bak-cleanup.test.ts | 212 ++ tests/unit/sync/sync-bucket-walker.test.ts | 249 ++ tests/unit/sync/sync-command.test.ts | 1164 ++++++++ tests/unit/sync/sync-config.test.ts | 1454 ++++++++++ tests/unit/sync/sync-context.test.ts | 875 ++++++ tests/unit/sync/sync-differ.test.ts | 264 ++ tests/unit/sync/sync-export.test.ts | 111 + tests/unit/sync/sync-glossary-report.test.ts | 250 ++ tests/unit/sync/sync-glossary.test.ts | 349 +++ .../sync/sync-init-detector-table.test.ts | 454 +++ tests/unit/sync/sync-init-validate.test.ts | 148 + tests/unit/sync/sync-init.test.ts | 699 +++++ tests/unit/sync/sync-instructions.test.ts | 287 ++ .../unit/sync/sync-locale-translator.test.ts | 1144 ++++++++ tests/unit/sync/sync-lock.test.ts | 441 +++ .../unit/sync/sync-message-preprocess.test.ts | 149 + tests/unit/sync/sync-push-pull.test.ts | 435 +++ tests/unit/sync/sync-resolve.test.ts | 537 ++++ tests/unit/sync/sync-service.test.ts | 2546 +++++++++++++++++ tests/unit/sync/sync-status.test.ts | 162 ++ tests/unit/sync/sync-tms.test.ts | 458 +++ tests/unit/sync/sync-utils.test.ts | 200 ++ tests/unit/sync/sync-validate.test.ts | 130 + tests/unit/sync/tm-cache.test.ts | 68 + tests/unit/sync/tms-client.test.ts | 496 ++++ tests/unit/sync/translation-validator.test.ts | 238 ++ tests/unit/text-preservation.test.ts | 53 + tests/unit/text-translation-handler.test.ts | 144 + tests/unit/tm-command.test.ts | 78 + tests/unit/translate-utils.test.ts | 39 +- tests/unit/translation-client.test.ts | 274 +- tests/unit/translation-memory.test.ts | 342 +++ .../unit/translation-options-factory.test.ts | 251 ++ tests/unit/translation-service.test.ts | 51 +- tests/unit/utils/control-chars.test.ts | 43 + tests/unit/utils/glob-prefix.test.ts | 95 + tests/unit/utils/icu-preservation.test.ts | 192 ++ tests/unit/uuid.test.ts | 84 + 297 files changed, 47042 insertions(+), 389 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 docs/SYNC.md create mode 100755 examples/30-sync-basic.sh create mode 100755 examples/31-sync-ci.sh create mode 100755 examples/32-sync-live-validation.sh create mode 100755 examples/33-tm-list.sh create mode 100755 examples/34-sync-laravel-php.sh create mode 100644 src/cli/commands/describe.ts create mode 100644 src/cli/commands/register-describe.ts create mode 100644 src/cli/commands/register-sync.ts create mode 100644 src/cli/commands/register-tm.ts create mode 100644 src/cli/commands/sync-command.ts create mode 100644 src/cli/commands/sync/register-sync-audit.ts create mode 100644 src/cli/commands/sync/register-sync-export.ts create mode 100644 src/cli/commands/sync/register-sync-init.ts create mode 100644 src/cli/commands/sync/register-sync-pull.ts create mode 100644 src/cli/commands/sync/register-sync-push.ts create mode 100644 src/cli/commands/sync/register-sync-resolve.ts create mode 100644 src/cli/commands/sync/register-sync-root.ts create mode 100644 src/cli/commands/sync/register-sync-status.ts create mode 100644 src/cli/commands/sync/register-sync-validate.ts create mode 100644 src/cli/commands/sync/sync-options.ts create mode 100644 src/cli/commands/tm.ts create mode 100644 src/cli/commands/translate/translation-options-factory.ts create mode 100644 src/formats/android-xml.ts create mode 100644 src/formats/arb.ts create mode 100644 src/formats/format.ts create mode 100644 src/formats/index.ts create mode 100644 src/formats/ios-strings.ts create mode 100644 src/formats/json.ts create mode 100644 src/formats/pending-comment-buffer.ts create mode 100644 src/formats/php-arrays.ts create mode 100644 src/formats/php-parser-bridge.ts create mode 100644 src/formats/po.ts create mode 100644 src/formats/properties.ts create mode 100644 src/formats/toml.ts create mode 100644 src/formats/util/detect-indent.ts create mode 100644 src/formats/xcstrings.ts create mode 100644 src/formats/xliff.ts create mode 100644 src/formats/yaml.ts create mode 100644 src/services/translation-memory.ts create mode 100644 src/sync/sync-bak-cleanup.ts create mode 100644 src/sync/sync-bucket-walker.ts create mode 100644 src/sync/sync-config.ts create mode 100644 src/sync/sync-context.ts create mode 100644 src/sync/sync-differ.ts create mode 100644 src/sync/sync-export.ts create mode 100644 src/sync/sync-finalize.ts create mode 100644 src/sync/sync-glossary-report.ts create mode 100644 src/sync/sync-glossary.ts create mode 100644 src/sync/sync-init-validate.ts create mode 100644 src/sync/sync-init.ts create mode 100644 src/sync/sync-instructions.ts create mode 100644 src/sync/sync-locale-translator.ts create mode 100644 src/sync/sync-lock.ts create mode 100644 src/sync/sync-message-preprocess.ts create mode 100644 src/sync/sync-process-bucket.ts create mode 100644 src/sync/sync-process-lock.ts create mode 100644 src/sync/sync-resolve.ts create mode 100644 src/sync/sync-service.ts create mode 100644 src/sync/sync-status.ts create mode 100644 src/sync/sync-tms.ts create mode 100644 src/sync/sync-utils.ts create mode 100644 src/sync/sync-validate.ts create mode 100644 src/sync/tm-cache.ts create mode 100644 src/sync/tms-client.ts create mode 100644 src/sync/translation-validator.ts create mode 100644 src/sync/types.ts create mode 100644 src/utils/control-chars.ts create mode 100644 src/utils/glob-prefix.ts create mode 100644 src/utils/icu-preservation.ts create mode 100644 src/utils/uuid.ts create mode 100644 tests/__mocks__/php-parser-bridge.ts create mode 100644 tests/e2e/cli-describe.e2e.test.ts create mode 100644 tests/e2e/cli-sync-error-envelope.e2e.test.ts create mode 100644 tests/e2e/cli-sync-force-guard.e2e.test.ts create mode 100644 tests/e2e/cli-sync-init.e2e.test.ts create mode 100644 tests/e2e/cli-sync-json-contract.e2e.test.ts create mode 100644 tests/e2e/cli-sync-push-pull.e2e.test.ts create mode 100644 tests/e2e/cli-sync-tms.e2e.test.ts create mode 100644 tests/e2e/cli-sync-watch.e2e.test.ts create mode 100644 tests/e2e/cli-sync.e2e.test.ts create mode 100644 tests/e2e/cli-tm.e2e.test.ts create mode 100644 tests/e2e/mock-tms-push-pull.cjs create mode 100644 tests/fixtures/sync/configs/empty-buckets.yaml create mode 100644 tests/fixtures/sync/configs/invalid-version.yaml create mode 100644 tests/fixtures/sync/configs/minimal.yaml create mode 100644 tests/fixtures/sync/configs/missing-locale.yaml create mode 100644 tests/fixtures/sync/configs/multi-bucket.yaml create mode 100644 tests/fixtures/sync/configs/valid.yaml create mode 100644 tests/fixtures/sync/context/no-translations.ts create mode 100644 tests/fixtures/sync/context/sample-intl.tsx create mode 100644 tests/fixtures/sync/context/sample-react.tsx create mode 100644 tests/fixtures/sync/context/sample-vue.ts create mode 100644 tests/fixtures/sync/error-envelopes/config-error.json create mode 100644 tests/fixtures/sync/error-envelopes/init-success.json create mode 100644 tests/fixtures/sync/error-envelopes/sync-conflict.json create mode 100644 tests/fixtures/sync/error-envelopes/validation-error.json create mode 100644 tests/fixtures/sync/formats/android-xml/strings-arrays.xml create mode 100644 tests/fixtures/sync/formats/android-xml/strings-complex.xml create mode 100644 tests/fixtures/sync/formats/android-xml/strings-empty.xml create mode 100644 tests/fixtures/sync/formats/android-xml/strings-plurals.xml create mode 100644 tests/fixtures/sync/formats/android-xml/strings.xml create mode 100644 tests/fixtures/sync/formats/arb/app_en-minimal.arb create mode 100644 tests/fixtures/sync/formats/arb/app_en-no-metadata.arb create mode 100644 tests/fixtures/sync/formats/arb/app_en-plurals.arb create mode 100644 tests/fixtures/sync/formats/arb/app_en.arb create mode 100644 tests/fixtures/sync/formats/ios-strings/Localizable-complex.strings create mode 100644 tests/fixtures/sync/formats/ios-strings/Localizable-empty.strings create mode 100644 tests/fixtures/sync/formats/ios-strings/Localizable-escapes.strings create mode 100644 tests/fixtures/sync/formats/ios-strings/Localizable.strings create mode 100644 tests/fixtures/sync/formats/json/en-icu.json create mode 100644 tests/fixtures/sync/formats/json/en-nested.json create mode 100644 tests/fixtures/sync/formats/json/en-with-metadata.json create mode 100644 tests/fixtures/sync/formats/json/en.json create mode 100644 tests/fixtures/sync/formats/laravel-php/01-single-quote-escape.php create mode 100644 tests/fixtures/sync/formats/laravel-php/02-double-quote-escapes.php create mode 100644 tests/fixtures/sync/formats/laravel-php/03-interpolation-rejected.php create mode 100644 tests/fixtures/sync/formats/laravel-php/04-heredoc-rejected.php create mode 100644 tests/fixtures/sync/formats/laravel-php/05-concat-rejected.php create mode 100644 tests/fixtures/sync/formats/laravel-php/06-mixed-syntax.php create mode 100644 tests/fixtures/sync/formats/laravel-php/07-colon-placeholder.php create mode 100644 tests/fixtures/sync/formats/laravel-php/08-trailing-commas.php create mode 100644 tests/fixtures/sync/formats/laravel-php/09-ast-idempotence.php create mode 100644 tests/fixtures/sync/formats/laravel-php/10-empty-nested-array.php create mode 100644 tests/fixtures/sync/formats/laravel-php/11-dot-key-vs-nested.php create mode 100644 tests/fixtures/sync/formats/laravel-php/12-escaped-dollar.php create mode 100644 tests/fixtures/sync/formats/laravel-php/13-utf8-bom.php create mode 100644 tests/fixtures/sync/formats/laravel-php/14-literal-pipe-in-prose.php create mode 100644 tests/fixtures/sync/formats/laravel-php/15-irregular-whitespace-and-comments.php create mode 100644 tests/fixtures/sync/formats/po/messages-fuzzy.po create mode 100644 tests/fixtures/sync/formats/po/messages-msgctxt.po create mode 100644 tests/fixtures/sync/formats/po/messages-multiline.po create mode 100644 tests/fixtures/sync/formats/po/messages-plurals.po create mode 100644 tests/fixtures/sync/formats/po/messages.po create mode 100644 tests/fixtures/sync/formats/po/template.pot create mode 100644 tests/fixtures/sync/formats/properties/expected-after-sync/de.properties create mode 100644 tests/fixtures/sync/formats/properties/source.properties create mode 100644 tests/fixtures/sync/formats/toml/expected-after-sync/de.toml create mode 100644 tests/fixtures/sync/formats/toml/source.toml create mode 100644 tests/fixtures/sync/formats/xcstrings/expected-after-sync/de.xcstrings create mode 100644 tests/fixtures/sync/formats/xcstrings/source.xcstrings create mode 100644 tests/fixtures/sync/formats/xliff/messages-no-target.xlf create mode 100644 tests/fixtures/sync/formats/xliff/messages-v1.xlf create mode 100644 tests/fixtures/sync/formats/xliff/messages-v2.xlf create mode 100644 tests/fixtures/sync/formats/yaml/en-multiline.yaml create mode 100644 tests/fixtures/sync/formats/yaml/en-reserved-words.yaml create mode 100644 tests/fixtures/sync/formats/yaml/en.yaml create mode 100644 tests/fixtures/sync/laravel_php-pipe-plural/README.md create mode 100644 tests/fixtures/sync/laravel_php-pipe-plural/de.php create mode 100644 tests/fixtures/sync/laravel_php-pipe-plural/en.php create mode 100644 tests/fixtures/sync/lockfiles/empty.lock create mode 100644 tests/fixtures/sync/lockfiles/fully-translated.lock create mode 100644 tests/fixtures/sync/lockfiles/partial.lock create mode 100644 tests/fixtures/sync/lockfiles/with-deleted.lock create mode 100644 tests/helpers/assert-error-envelope.ts create mode 100644 tests/helpers/sync-harness.ts create mode 100644 tests/helpers/tms-nock.ts create mode 100644 tests/integration/sync-auto-commit.integration.test.ts create mode 100644 tests/integration/sync-concurrent.integration.test.ts create mode 100644 tests/integration/sync-export-path-traversal.integration.test.ts create mode 100644 tests/integration/sync-init-format-choices.integration.test.ts create mode 100644 tests/integration/sync-init.integration.test.ts create mode 100644 tests/integration/sync-locale-translator-plural-perf.integration.test.ts create mode 100644 tests/integration/sync-php-arrays.integration.test.ts create mode 100644 tests/integration/sync-properties.integration.test.ts create mode 100644 tests/integration/sync-scan-bounds.integration.test.ts create mode 100644 tests/integration/sync-stale-lock-fg-coalesce.integration.test.ts create mode 100644 tests/integration/sync-symlink-safety.integration.test.ts create mode 100644 tests/integration/sync-template-patterns-dedup-perf.integration.test.ts create mode 100644 tests/integration/sync-template-prep.integration.test.ts create mode 100644 tests/integration/sync-tms-push.integration.test.ts create mode 100644 tests/integration/sync-tms.integration.test.ts create mode 100644 tests/integration/sync-toml.integration.test.ts create mode 100644 tests/integration/sync-watch-reliability.integration.test.ts create mode 100644 tests/integration/sync-watch.integration.test.ts create mode 100644 tests/integration/sync-xcstrings.integration.test.ts create mode 100644 tests/integration/sync.integration.test.ts create mode 100644 tests/unit/cli/describe.test.ts create mode 100644 tests/unit/cli/register-sync-force-help.test.ts create mode 100644 tests/unit/cli/register-sync-init.test.ts create mode 100644 tests/unit/cli/register-sync-root.test.ts create mode 100644 tests/unit/cli/register-sync-scan-context-help.test.ts create mode 100644 tests/unit/cli/register-sync-tms-help.test.ts create mode 100644 tests/unit/cli/register-sync.commander-snapshot.test.ts create mode 100644 tests/unit/docs/sync-terminology.test.ts create mode 100644 tests/unit/errors.test.ts create mode 100644 tests/unit/formats/android-xml.test.ts create mode 100644 tests/unit/formats/arb.test.ts create mode 100644 tests/unit/formats/detect-indent.test.ts create mode 100644 tests/unit/formats/format-registry.test.ts create mode 100644 tests/unit/formats/ios-strings.test.ts create mode 100644 tests/unit/formats/json.test.ts create mode 100644 tests/unit/formats/pending-comment-buffer.test.ts create mode 100644 tests/unit/formats/php-arrays.test.ts create mode 100644 tests/unit/formats/po.test.ts create mode 100644 tests/unit/formats/properties.test.ts create mode 100644 tests/unit/formats/toml.test.ts create mode 100644 tests/unit/formats/xcstrings.test.ts create mode 100644 tests/unit/formats/xliff.test.ts create mode 100644 tests/unit/formats/yaml.test.ts create mode 100644 tests/unit/sync/sync-bak-cleanup.test.ts create mode 100644 tests/unit/sync/sync-bucket-walker.test.ts create mode 100644 tests/unit/sync/sync-command.test.ts create mode 100644 tests/unit/sync/sync-config.test.ts create mode 100644 tests/unit/sync/sync-context.test.ts create mode 100644 tests/unit/sync/sync-differ.test.ts create mode 100644 tests/unit/sync/sync-export.test.ts create mode 100644 tests/unit/sync/sync-glossary-report.test.ts create mode 100644 tests/unit/sync/sync-glossary.test.ts create mode 100644 tests/unit/sync/sync-init-detector-table.test.ts create mode 100644 tests/unit/sync/sync-init-validate.test.ts create mode 100644 tests/unit/sync/sync-init.test.ts create mode 100644 tests/unit/sync/sync-instructions.test.ts create mode 100644 tests/unit/sync/sync-locale-translator.test.ts create mode 100644 tests/unit/sync/sync-lock.test.ts create mode 100644 tests/unit/sync/sync-message-preprocess.test.ts create mode 100644 tests/unit/sync/sync-push-pull.test.ts create mode 100644 tests/unit/sync/sync-resolve.test.ts create mode 100644 tests/unit/sync/sync-service.test.ts create mode 100644 tests/unit/sync/sync-status.test.ts create mode 100644 tests/unit/sync/sync-tms.test.ts create mode 100644 tests/unit/sync/sync-utils.test.ts create mode 100644 tests/unit/sync/sync-validate.test.ts create mode 100644 tests/unit/sync/tm-cache.test.ts create mode 100644 tests/unit/sync/tms-client.test.ts create mode 100644 tests/unit/sync/translation-validator.test.ts create mode 100644 tests/unit/tm-command.test.ts create mode 100644 tests/unit/translation-memory.test.ts create mode 100644 tests/unit/translation-options-factory.test.ts create mode 100644 tests/unit/utils/control-chars.test.ts create mode 100644 tests/unit/utils/glob-prefix.test.ts create mode 100644 tests/unit/utils/icu-preservation.test.ts create mode 100644 tests/unit/uuid.test.ts diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..2e8b450 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,41 @@ +--- +name: Bug report +about: Report a bug in DeepL CLI +title: 'bug: ' +labels: bug +assignees: '' +--- + +## Summary + +A clear, concise description of the bug. + +## Environment + +- **DeepL CLI version**: (run `deepl --version`) +- **Node.js version**: (run `node --version`) +- **Operating system**: (e.g., macOS 14.5, Ubuntu 22.04, Windows 11) +- **Install method**: (source, npm link, other) + +## Reproduction Steps + +1. +2. +3. + +## Expected Behavior + +What you expected to happen. + +## Actual Behavior + +What actually happened. Include the full error output (redact any API keys): + +``` + +``` + +## Additional Context + +Anything else that might help — config snippets (redacted), example files, +related issues, workarounds you've tried. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..c2cfa5d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,26 @@ +--- +name: Feature request +about: Suggest a new feature or enhancement for DeepL CLI +title: 'feat: ' +labels: enhancement +assignees: '' +--- + +## Problem + +What problem does this feature solve? Who is affected? What workflow is +currently painful or impossible? + +## Proposed Solution + +A clear, concise description of what you'd like to see. + +## Alternatives Considered + +Other approaches you've thought about and why you believe the proposed +solution is the best fit. + +## Additional Context + +Use cases, mockups, related tools, or links to similar features in other +CLIs. diff --git a/.gitignore b/.gitignore index 471e185..af0927d 100644 --- a/.gitignore +++ b/.gitignore @@ -66,8 +66,15 @@ logs/ *.db-wal *.db-shm -# Claude Code local settings -.claude/settings.local.json +# Claude Code local tooling (settings, hooks, agent worktrees, session state) +.claude/ + +# Playwright MCP local state +.playwright-mcp/ # Beads issue tracking (local) .beads/ +issues.jsonl + +# QA harness +qa/ diff --git a/CHANGELOG.md b/CHANGELOG.md index f1559f2..1c3c437 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,23 +5,272 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [1.1.0] - 2026-04-23 ### Added +- **Exit Codes appendix** in docs/API.md enumerating all CLI exit codes with emitting commands. +- Continuous localization sync engine (`deepl sync`) for scanning, diffing, and translating i18n resource files +- 11 i18n file format parsers: JSON, YAML, Gettext PO, Android XML, iOS Strings, ARB, XLIFF, TOML, Java Properties, Xcode String Catalog, Laravel PHP arrays +- Xcode String Catalog (`.xcstrings`) format parser for iOS/macOS projects — multi-locale, comment preservation +- **sync**: Laravel PHP arrays (`.php`) format parser with `glayzzle/php-parser` — AST allowlist over string-literal return-array entries; double-quoted interpolation (`"Hello $name"`), heredoc, nowdoc, and string concatenation are rejected with a `ValidationError`. Reconstruct is span-surgical (AST offsets only; every byte outside a replaced string literal is preserved verbatim — comments, PHPDoc, trailing commas, irregular whitespace, and quote style all round-trip unchanged). Laravel pipe-pluralization values (`|{n}`, `|[n,m]`, `|[n,*]`) are detected at extract, excluded from the translation batch, and surfaced in `deepl sync status` via a new `skippedKeys` count. `php-parser` is lazy-loaded only when a `laravel_php` bucket is configured. +- **sync**: `deepl sync init` auto-detects Laravel projects — `composer.json` at the repo root plus `.php` files under `lang/en/` (Laravel 9+) or `resources/lang/en/` (Laravel ≤8 / Lumen) triggers a `laravel_php` bucket suggestion. Filesystem-only (no manifest parsing), consistent with the Rails / Django / Flutter / Angular detectors. +- **sync**: The auto-detect engine now supports optional root-marker files via a `requires` field on each detection pattern. Markers are plain `fs.existsSync` checks — never parsed — matching the filesystem-only stance of the sibling detectors. Laravel's `composer.json` is the first required marker; the ARB (Flutter) detector was retroactively tightened with `pubspec.yaml` to eliminate false positives for the very rare non-Flutter ARB use. +- **sync**: `deepl sync init` now auto-detects four additional ecosystems that the docs previously promised but the detector never actually covered: Rails (`config/locales/en.yml` / `.yaml`), Xcode String Catalog (`Localizable.xcstrings` / `Resources/Localizable.xcstrings` / `*.xcstrings`, multi-locale), go-i18n TOML (`locales/en.toml`, `i18n/en.toml`), and Java / Spring properties (`src/main/resources/messages_en.properties`). Also fixes a pre-existing extension-preservation bug in the YAML detector — a `locales/en.yml` match used to emit a `locales/en.yaml` bucket pattern that wouldn't match at sync time. `.yml` and `.yaml` are now handled as separate detection entries so the extension round-trips faithfully. +- **sync**: `deepl sync init` auto-detects go-i18n's root-level `active.en.toml` layout as a dedicated detection entry, emitting the `active.{locale}.toml` filename template. Previously only `locales/en.toml` / `i18n/en.toml` directory layouts were covered; root-level users had to fall through to the four-flag non-interactive path. +- **sync**: `deepl sync init` auto-detects Rails namespaced layouts under `config/locales/**/en.yml` (and `.yaml`) — engines, concerns, and per-namespace splits are now recognized alongside the canonical `config/locales/en.yml`. The namespace directory is preserved in the generated bucket `include:` pattern. +- **sync**: `deepl sync init` auto-detects Symfony's `translations/messages.en.xlf` layout as a dedicated XLIFF detection entry — distinct from Angular's `src/locale/messages.xlf` convention. Target locales are emitted as `translations/messages.{locale}.xlf`. +- **sync**: `sync.limits` config block — per-file parser caps `max_entries_per_file` (default 25 000, hard max 100 000), `max_file_bytes` (default 4 MiB, hard max 10 MiB), `max_depth` (default 32, hard max 64). Default-exceed = file-skip + warn; setting a value above the hard ceiling fails at config load with `ConfigError` (exit 7). +- Multi-locale format support in sync engine (`FormatParser.multiLocale`) for single-file formats like `.xcstrings` +- Incremental sync with change detection via `.deepl-sync.lock` content hashing +- Interactive setup wizard (`deepl sync init`) with framework auto-detection (i18next, Rails, Django, Flutter, Angular, etc.) +- Translation coverage reporting (`deepl sync status`) with per-locale progress bars +- Translation validation (`deepl sync validate`) for placeholder, format string, and HTML tag integrity +- `sync export` command — export source strings to XLIFF 1.2 for CAT tool handoff +- Auto-context extraction from source code for improved translation quality, including template literal calls (e.g., `` t(`features.${key}.title`) ``) +- Key path context synthesis — i18n key hierarchy (e.g., `pricing.free.cta`) is parsed into natural-language context descriptions sent to the DeepL API +- Element type detection — HTML/JSX element types (button, h2, th, etc.) are extracted from surrounding source code during context scanning +- Element-aware custom instructions — auto-generated `custom_instructions` for 16 element types (button, a, h1-h6, th, label, option, input, title, summary, legend, caption), batched by element type for efficient API usage. Only for the 8 locales supporting custom instructions (DE, EN, ES, FR, IT, JA, KO, ZH) +- `translation.instruction_templates` config — user-customizable instruction templates per HTML element type, overriding built-in defaults +- `translation.length_limits` config — opt-in length-aware translation instructions using per-locale expansion factors based on industry-standard approximations (IBM, W3C); user-overridable +- Section-batched context translation — keys sharing the same i18n section (e.g., `nav.*`) are batched with shared section context, ~3.4x faster than per-key while preserving disambiguation quality +- Translation strategy summary in sync output — shows how many keys used context, instructions (by element type), or plain batch translation +- Config warnings when `instruction_templates` is set but context scanning is disabled or no element types are detected +- `--batch` / `--no-batch` CLI flags — `--batch` forces plain batch (fastest, no context); `--no-batch` forces true per-key context (slowest, max quality); default uses section-batched context +- `PushResult` / `PullResult` types for `sync push` / `sync pull` — return `{pushed|pulled, skipped[]}` so callers can distinguish truly-nothing-to-do from silently-dropped cases. CLI output now appends `(N skipped: ...)` when appropriate. +- Actionable TMS authentication errors — 401/403 responses from the TMS server now surface as `ConfigError` with a remediation hint that names `TMS_API_KEY` / `TMS_TOKEN` and the relevant `.deepl-sync.yaml` fields. +- `context_sent` field in lockfile translation entries — records whether source code context was included in the API request +- `character_count` field in lockfile translation entries — records characters billed per key per locale +- Live progress output during `deepl sync` — per-key `key-translated` events during translation and per-locale `locale-complete` events when each locale finishes, in both text and JSON formats +- `context.overrides` config — manual context strings per key, preferred over auto-extracted context +- Auto-glossary management from translation history +- Optional TMS integration (`deepl sync push`/`pull`) for collaborative editing and human review workflows; documented REST contract lets any compatible TMS be wired up +- CI/CD integration with `--frozen` mode and exit code 10 for translation drift detection +- `validation.fail_on_missing` and `validation.fail_on_stale` config options for granular `--frozen` drift detection +- Dry-run mode (`deepl sync --dry-run`) with character and cost estimates from source string lengths +- Per-locale progress display after sync (`✓ de: 10/10 ✓ fr: 10/10`) +- `estimatedCharacters` and `targetLocaleCount` fields in JSON output +- Dollar cost estimates in sync output and JSON (at DeepL Pro rates, $25/1M chars) +- `sync.max_characters` config option — cost cap that aborts sync before translation if estimated characters exceed limit (override with `--force`) +- `sync.backup` config option — pre-overwrite backup of target files (default `true`); `.bak` files cleaned up after successful sync +- `--watch` mode — monitors source i18n files for changes and auto-syncs with debouncing (configurable via `--debounce`) +- `--flag-for-review` marks MT translations with `review_status: machine_translated` in the lock file for human review workflows - Free API key (`:fx` suffix) support with automatic endpoint resolution to `api-free.deepl.com` -- Shared endpoint resolver used by all commands including voice, auth, and init - Custom/regional endpoint support (e.g. `api-jp.deepl.com`) that takes priority over auto-detection +- `sync export --overwrite` flag — required to overwrite an existing `--output` file; protects against accidental clobbering +- `deepl sync status --format json` error-mode output: failures now emit `{error, code}` JSON to stderr with the error class name (`ConfigError`, `ValidationError`, etc.) as the `code` +- Translation memory support in `deepl translate` via `--translation-memory ` and `--tm-threshold ` — forces `quality_optimized` model, requires `--from` (pair-pinned), threshold is an integer 0–100 (default 75) +- Translation memory support in `deepl sync` via `translation.translation_memory` and `translation.translation_memory_threshold` config keys, with per-locale overrides under `translation.locale_overrides` +- Translation memory name-to-ID resolution is cached per run to avoid redundant `GET /v3/translation_memories` calls; TM files are authored and uploaded via the DeepL web UI +- Verbose-mode logs at the glossary and translation memory resolution boundary: `--verbose` now shows the resolved UUID for each glossary or TM name (`[verbose] Resolved glossary "" -> `, `[verbose] Resolved translation memory "" -> `) and a cache-hit line when the same TM name + pair is reused within a session +- `deepl tm list` subcommand — lists all translation memories on the account, mirroring `deepl glossary list`. Text output filters control chars and zero-width codepoints from TM names so a malicious API-returned name cannot corrupt the terminal; `--format json` emits the raw `TranslationMemory[]` as returned by `GET /v3/translation_memories`. Help text on `deepl translate --translation-memory` now cross-references the new command +- `src/utils/uuid.ts` — shared strict UUID regex (`UUID_RE`) + `validateUuid` / `validateTranslationMemoryId` helpers. `validateTranslationMemoryId` is dormant today (TM IDs only appear in `/v2/translate` POST bodies, which are JSON-escaped) but guards the path-injection surface the moment any future per-TM endpoint interpolates a user-supplied UUID into a URL segment +- **sync**: `deepl sync resolve` now prints a per-entry decision report (`kept ours` / `kept theirs` / `length-heuristic` / `unresolved`) plus a summary, and accepts `--dry-run` to preview decisions without writing the lockfile. +- **sync docs**: `docs/SYNC.md` Exit Codes table and `docs/API.md` sync Behavior bullet now cross-link to the canonical [Exit Codes](API.md#exit-codes) appendix. +- **sync**: New `sync.max_scan_files` config key (default 50,000). +- **errors**: `SyncConflictError` class in `src/utils/errors.ts` mirroring `SyncDriftError` — `ExitCode.SyncConflict` (11) is now throwable as a typed error so library consumers can `instanceof`-match the conflict case. +- **SECURITY.md**: `1.1.x` row added to the Supported Versions table. +- **CONTRIBUTING.md**: PR checklist reminds contributors to register new example scripts in `examples/run-all.sh`. +- **.github/ISSUE_TEMPLATE/**: `bug_report.md` and `feature_request.md` templates for structured issue intake. + +**Note:** `deepl sync` intentionally exposes no `--translation-memory` / `--tm-threshold` CLI override in this release; configure translation memory via `.deepl-sync.yaml`. ### Changed - Voice API no longer hardcodes the Pro endpoint; it follows the same endpoint resolution as all other commands - `auth set-key` and `init` now validate entered keys against the correct endpoint based on key suffix - Standard DeepL URLs (`api.deepl.com`, `api-free.deepl.com`) in saved config no longer override key-based auto-detection +- **`deepl sync` cost estimates now labeled as Pro tier**: text-mode output appends `(Pro tier estimate)` to all cost lines (both dry-run and post-sync). The `--format json` output carries `rateAssumption: "pro"`. `docs/SYNC.md` now documents the Pro-rate assumption ($25/1M chars) and points users to their account page to determine the applicable rate for their tier. +- **`deepl sync --format json` output contract stabilized**: the success JSON payload is now a curated `SyncJsonOutput` shape (`ok`, `totalKeys`, `translated`, `skipped`, `failed`, `targetLocaleCount`, `estimatedCharacters`, `estimatedCost?`, `rateAssumption: "pro"`, `dryRun`, `perLocale[]`) instead of a raw internal spread. The public shape is documented in docs/API.md and guaranteed stable across 1.x. +- **`deepl sync init` no-detection exit**: when the auto-detector finds no recognized i18n files, the command now exits 7 (`ConfigError`) instead of 0, and prints an actionable remediation hint listing all four required flags (`--source-locale`, `--target-locales`, `--file-format`, `--path`). In `--format json` mode the canonical error envelope (`{ok:false, error:{code:"ConfigError",...}, exitCode:7}`) is emitted to stderr. Scripts that previously relied on exit 0 in empty projects must be updated to handle exit 7. +- **`deepl sync status` documentation**: the docs/SYNC.md example output now matches the actual CLI output — ASCII progress bar (`[####....]`), integer coverage percentage, and per-locale `(N missing, N outdated)` parenthetical. The previous example showed Unicode block characters, decimal percentages, and a `Translation Status:` header that the code never emits. The per-locale `outdated` field is now documented in the JSON field legend. +- **Exit code 1 for partial sync failure**: `deepl sync` now sets `process.exitCode = ExitCode.PartialFailure` (named constant, value 1) instead of a bare literal when one or more locales fail while others succeed. The exit-code table in docs/SYNC.md and the Exit Codes appendix in docs/API.md now document exit code 1 with sync subcommand attribution. `ExitCode.PartialFailure = 1` is added to `src/utils/exit-codes.ts` as a named alias alongside `ExitCode.GeneralError = 1`. +- **sync**: `deepl sync init` now prefers the dir-per-locale JSON layout (`locales/en/*.json`) over the flat layout (`locales/en.json`) when both coexist in the same repo. The init wizard's `detected[0]` selection was silently picking the flat entry, which is usually legacy / sample content while the nested layout is the real source — i18next, react-i18next, and next-i18next all default to nested. Both entries remain in `DETECTION_PATTERNS` for enumeration; only the first-pick order changed. +- **translate**: Centralize `TranslateOptions` construction for `deepl translate`, `deepl translate file.txt`, `deepl translate `, and the document path in a new `src/cli/commands/translate/translation-options-factory.ts`. All four handlers now call `buildBaseTranslationOptions()` + `applySharedTmAndGlossary()` instead of each maintaining its own copy of the base mapping plus a near-identical TM/glossary resolution block. Behavior-preserving for the shared flags (`--formality`, `--glossary`, `--model-type`, `--translation-memory`, `--tm-threshold`, `--preserve-formatting`); fixes latent drift risk where one handler could silently diverge from another. Handler-specific shaping (custom instructions, style id, XML tag handling, multi-target `targetLang` stripping) stays in the handler. `deepl sync` is intentionally untouched — its `TranslationOptions` are built from resolved config with per-locale overrides and `context_sent` wiring, a different construction domain that lives in `src/sync/sync-locale-translator.ts`. +- **sync**: Format-name knowledge consolidated under `src/formats/registry.ts`; `--file-format` CLI choices now derive from the registry. Prevents silent divergence between parser, CLI help, and registration. +- **sync**: Removed per-parser `sort` calls (consumers sort once); extracted `detectIndent` to a shared `src/formats/util/detect-indent.ts` used by JSON, ARB, and xcstrings. Pure refactor, no behavior change. +- **sync**: `scan_paths` file walk is now bounded (default 50,000 files; configurable via `sync.max_scan_files` in `.deepl-sync.yaml`) — exceeding the cap throws ValidationError with a suggestion, preventing CI wedges on misconfigured patterns. +- **sync**: `deepl sync push --help` and `deepl sync pull --help` now include a TMS onboarding hint — the required `tms:` YAML block, the `TMS_API_KEY` / `TMS_TOKEN` env vars, and a pointer to `docs/SYNC.md#tms-rest-contract`. Previously the help surface listed only `--locale` / `--sync-config`, so users had to run the subcommand once and read a runtime ConfigError to discover the integration requirements. `docs/API.md` push/pull sections get the same hint and cross-link. +- **sync**: `deepl sync --force` help text now warns that the flag bypasses the `sync.max_characters` cost-cap preflight and can incur unexpected API costs by rebilling every translated key. Previous wording ("Retranslate all strings, ignoring lock file") described the lockfile effect but was silent on the billing surprise. `docs/API.md` and `docs/SYNC.md` updated to match. +- **sync**: Extract CLI exit-code enum to `src/utils/exit-codes.ts` (next to the errors module); adds `SyncConflict` (11) for `sync resolve` unresolvable-conflict exits. No runtime behavior change from the extraction alone; enables the envelope contract wiring. +- **sync**: `deepl sync init` flag vocabulary aligned with the rest of sync: `--source-locale` and `--target-locales` are now the primary names, matching `--locale` in `sync push`/`pull`/`status`/`export`. `deepl translate --target-lang` is unchanged (operates on strings, distinct from locale-file semantics). +- **sync**: Rename `deepl sync --context` / `--no-context` boolean to `--scan-context` / `--no-scan-context` to disambiguate from `deepl translate --context ""` (string-valued). Bare `--context` / `--no-context` on sync now errors with a did-you-mean pointing to the new flag. `deepl sync` had not shipped in a tagged release prior to this change, so no deprecation cycle is needed. +- **sync**: CLI override layering (`--formality`, `--glossary`, `--model-type`, `--scan-context`, `--batch`/`--no-batch`) is now centralized in a single `applyCliOverrides` helper in `sync-config.ts`. The TM-requires-`quality_optimized` guard now also fires at the CLI-override boundary, so `--model-type latency_optimized` is rejected with an actionable `ConfigError` when the loaded YAML has `translation_memory` set (previously the override silently bypassed the check). +- **sync**: `deepl sync glossary-report` is renamed to `deepl sync audit`. Every other sync subcommand is a single action verb (`init`, `status`, `validate`, `export`, `resolve`, `push`, `pull`); the hyphenated noun-phrase was an outlier and a name mismatch (the command detects terminology inconsistency whether or not a glossary is configured). The old form is rejected with a `ValidationError` (exit 6) and a did-you-mean hint pointing to `audit`. No deprecation alias — this is a pre-release rename; `glossary-report` never shipped in a tagged release. `audit` here means translation-consistency audit (term divergence across locales), not security audit in the `npm audit` sense. +- **sync**: Lockfile writes now serialize in-place without deep-cloning; a 10K-key × 10-locale lockfile peaks at ~2× rather than ~3× its serialized size. Watch-mode sync runs that write on every tick see the same reduction. +- **sync**: `deepl sync init` interactive wizard now offers the full DeepL target-locale set (~25 locales) in the checkbox prompt, with 8 common locales pre-checked. Previously the wizard exposed only de/es/fr/ja/zh. +- **sync**: Default context translation mode: keys with auto-extracted context are now section-batched instead of per-key. Use `--no-batch` to restore per-key behavior. +- **sync**: `deepl sync status --format json` output shape declared stable across 1.x — `{sourceLocale, totalKeys, locales[]}` with `coverage` as an integer 0-100. CLI JSON uses camelCase; on-disk lockfile/config use snake_case. +- **endpoint**: Shared endpoint resolver now used by all commands including voice, auth, and init. +- **docs**: Corrected Watch Mode section of `docs/SYNC.md` — `.deepl-sync.yaml` IS watched and triggers a config hot-reload on change (was documented as not watched); CLI flags (`--locale`, `--dry-run`, `--formality`, `--glossary`, etc.) are baked at invocation and do NOT reload between cycles (was documented as re-read each cycle); added SIGHUP force-reload behavior (previously undocumented). +- **sync**: TMS credential-hygiene warnings now route through `Logger.warn` (respects `--quiet`, consistent with the rest of the CLI and flowing through the Logger sanitizer). +- **CLAUDE.md**: Architecture block refreshed to include `sync/`, `formats/`, `data/` layers; drift-prone version/test-count metadata replaced with references to `VERSION`/`package.json` and `npm test` output. +- **README**: Featured `deepl sync` (Continuous Localization) prominently in Key Features. +- **README**: Voice Translation Key Features bullet now labeled `(Pro/Enterprise)`. +- **README**: Quick Start version-output example replaced with schematic `deepl-cli 1.x.x` (no longer drifts per release). +- **README**: Configurable timeout/retry copy reworded — now described as library-consumer options, not exposed as a CLI flag. +- **README**: "GDPR compliant" softened to "GDPR-aligned with DeepL's DPA" for legal precision. +- **README**: DeepL® trademark attribution appended to the License section. +- **README**: `deepl init` section cross-links to `deepl sync init` for continuous-localization setup. + +### Deprecated + +- **sync**: `deepl sync init --source-lang` and `--target-langs` are deprecated in favor of `--source-locale` and `--target-locales`. The old flags continue to work but emit a stderr deprecation warning; they will be removed in the next major release. + +### Removed + +- **sync**: Dead `onProgress` callback and `SyncProgressEvent` interface from `SyncOptions` (never wired up). +- **sync**: Remove silently-ignored `--batch-size` flag +- **sync**: Remove 5 unimplemented config fields from types and docs +- **package.json**: Drop `exports["./cli"]` subpath. It pointed at `dist/cli/index.js`, which runs `program.parseAsync` + `process.exit` at module load — any consumer who imported `deepl-cli/cli` would have had their own process terminated mid-import. The CLI remains available as a binary via the `bin` field. + +### Fixed + +- **sync cost cap**: When a brand-new target locale is added to an existing project, `sync.max_characters` now correctly includes the character cost of translating all current keys into the new locale in its preflight estimate. Previously, `toTranslate` was empty (no new/stale diffs) so the cap check passed with 0 estimated characters while the actual sync translated the entire key set — a silent cost surprise. The live-path preflight now mirrors the dry-run math (`currentChars × newLocaleCount`) so `--dry-run` and the live run always report the same estimated character count for the same workload. +- **sync perf**: Stale-lock entry cleanup now issues a single `fg` call with all stale-basename patterns instead of one call per stale entry. A reorg renaming 50 files previously triggered 50 sequential full-tree scans before sync completed; it now completes in one pass regardless of stale-entry count. +- **sync perf**: Startup `.bak` sweep (`sweepStaleBackups`) is now scoped to the directories implied by each bucket's `include` globs instead of walking the entire project tree. On large monorepos the sweep cost is now proportional to the number of bucket-matched directories rather than total project size. Callers without bucket config fall back to the previous full-tree walk with a one-time warning. +- `deepl sync push --format json`, `deepl sync pull --format json`, and `deepl sync resolve --format json` now emit a JSON success envelope to stdout on the happy path (`{ok:true, pushed/pulled/resolved: N, skipped/decisions: [...]}`) instead of silently writing nothing; scripts piping output to a file no longer receive an empty result. +- **sync**: Eliminated O(F×K) `resolveTemplatePatterns` loop over duplicate template-pattern entries. The accumulator in `extractAllKeyContexts` pushed one `TemplatePatternMatch` per template-literal match per source file with no dedup; a 2K-file repo with 20 template literals per file produced 40K entries × 10K keys = 400M `.test()` calls (~8s/sync). A `Set`-based dedup before the resolve loop collapses all per-file duplicates to at most one entry per distinct pattern string; `MAX_LOCATIONS=3` downstream is unaffected since the first-seen `filePath`/`line` is sufficient context. +- **sync**: Eliminated O(N²) `Array.includes` scan in the per-locale plural-slot hot path (`sync-locale-translator.ts`). Three call sites that tested `batchIndices.includes(slot.diffIndex)` — one in Path A (plain batch), one in Path C (element-instruction batch), one in Path B1 (section-batched context) — now precompute a `Set` before the `pluralSlots` loop and use `Set.has`. With 5K plural entries, 50 locales, and a 200-file repo the old code added ~40 min of pure array-scan overhead per sync run. +- **sync**: `deepl sync push` and `deepl sync pull` CLI summary lines now render a per-reason breakdown when entries are skipped (e.g., `(4 skipped: 1 target file not yet present, 2 pipe-pluralization (never sent to TMS), 1 no matching keys)`) instead of a single stale message. After `pipe_pluralization` was added as a third `SkipReason`, the previous "target file not yet present" / "no matching keys" strings were incorrect for Laravel users hitting the pipe-plural skip. Logic extracted to a shared `formatSkippedSummary(skipped)` helper in `sync-tms.ts`; the programmatic `PushResult.skipped` / `PullResult.skipped` shape is unchanged. +- **sync**: `deepl sync push` and `deepl sync pull` now enforce the walker's skip-metadata partition at every inline `parser.extract(...)` site (multi-locale source, non-multi-locale target file, pull-merge template). Laravel pipe-pluralization values (`|{n}`, `|[n,m]`, `|[n,*]`) were leaking past the partition on push (sent verbatim to `TmsClient.pushKey`, where the TMS would store them as a single malformed string) and on pull merge (overwriting the preserved pipe-plural target value with the single-string TMS payload, corrupting Laravel's pluralization syntax). A new exported `partitionEntries` helper in `sync-bucket-walker.ts` is applied at the three callsites, `TmsClient.pushEntry()` now rejects skip-tagged entries at the client boundary so pipe-plural values cannot reach the TMS even if a caller forgets to partition, and `PushResult`/`PullResult` now surface a `SkippedRecord` with `reason: 'pipe_pluralization'` and `key` per leaked entry so silent-partition regressions are detectable. +- **sync**: `deepl sync init` JSON detector now emits a glob bucket pattern for the directory-per-locale i18next layout (`locales/en/*.json`) instead of fabricating a nonexistent `locales/en/en.json` single-file path. Flat (`locales/en.json`) and dir-per-locale layouts are now separate detection entries. +- **sync**: `deepl sync init` iOS detector no longer claims bare-root `*.strings` files — Apple's bundle model mandates `.lproj`, and the root-level glob was a relocation magnet that emitted `{locale}.lproj/Localizable.strings` target patterns pointing at paths the source never lived in. Projects with that layout now fall through to the four-flag non-interactive init path. +- **sync**: `deepl sync init` XLIFF detector no longer claims bare-root `*.xlf` / `*.xliff` files — CAT-tool dumps (Trados/memoQ/Xcode `.xcloc` extracts) are a false-positive magnet and the detector used to relocate them under `src/locale/`. Canonical Angular layouts (`src/locale/messages.xlf`) are unchanged. +- **sync**: TOML parser reconstruct is now span-surgical — comments, blank lines between sections, per-value quote style (double vs literal), key order within a section, and irregular whitespace around `=` all round-trip byte-identically. Previously `reconstruct()` ran `smol-toml.stringify(data)` on a mutated parse tree, silently discarding every `# translator: …` comment and collapsing blank lines on first sync — a content-loss regression users saw as noisy first-sync diffs. Multi-line triple-quoted strings remain pass-through (out of scope). `smol-toml` is retained for `extract()`. +- **sync**: `.deepl-sync.yaml` now rejects unknown fields at every nesting level (top-level, buckets, translation, context, validation, sync, tms, locale_overrides) with a ConfigError (exit 7) and a did-you-mean hint pointing at the closest known field. Previously typos were silently discarded — for example, `target_locale: en` (singular) produced a "missing target_locales" error with no pointer to the offending key. +- **build**: Build pipeline now wipes `dist/` before compilation (`npm run clean && tsc`) so file renames in `src/` cannot leave orphaned `.js`/`.d.ts` files that would ship via `npm publish`. +- **voice**: Voice API no longer hardcodes the Pro endpoint; it follows the same endpoint resolution as all other commands. +- **auth**: `auth set-key` and `init` now validate entered keys against the correct endpoint based on key suffix. +- **endpoint**: Standard DeepL URLs (`api.deepl.com`, `api-free.deepl.com`) in saved config no longer override key-based auto-detection. +- **sync**: `deepl sync push --locale ` and `deepl sync pull --locale ` now narrow the fan-out to the named locale instead of silently over-fetching every configured target. Commander was routing `--locale` to whichever scope declared it first, so the subcommand handlers received `undefined` and treated the filter as absent. The subcommands now resolve `--locale` via a shared `resolveLocale(opts, command)` helper that prefers the subcommand's value and falls back to the parent `sync --locale`, matching the existing `resolveFormat` pattern. +- **sync**: Every sync subcommand now cleans up in-flight `.tmp` and `.bak` sibling files on SIGINT/SIGTERM (previously only `sync --watch` had this discipline), and sweeps stale `.bak` files older than `sync.bak_sweep_max_age_seconds` (default 300) at the start of each non-watch run. Reduces accumulation of orphaned artifacts in locale directories after crashes. +- **sync**: `deepl sync --watch` now caches the validated sync config across debounced change events instead of reloading + revalidating it every tick. The cache invalidates on `SIGHUP` (explicit reload) or when `.deepl-sync.yaml` itself is one of the changed files. The watcher also tracks the config file itself so in-session edits are picked up automatically. Previously every file-change event paid for a YAML parse and full config validation even though config rarely changes during a watch session. +- **sync**: Inline TMS credentials in `.deepl-sync.yaml` (`tms.api_key`, `tms.token`) now produce a `stderr` warning at config-load time on every `deepl sync …` subcommand, including non-TTY contexts like CI. Previously the warning was only emitted on the `sync push` / `sync pull` code path, so a user running `sync status` or piping output through another tool would never see that their config held a secret. +- **sync**: Section-batched context translation now honors the key-path separator the source format emitted. YAML keys (flattened with NUL) are now batched by section alongside JSON keys, and a literal dot in a flat YAML key (e.g., `version.major: "1"`) is no longer mis-split into two sections by the section-batcher. +- **sync**: `deepl sync init` now reports an accurate key count for every supported format, not just JSON and YAML. The detection step used to hard-code JSON/YAML parsing and silently fell back to `0` for Android XML, iOS Strings, PO, ARB, XLIFF, TOML, xcstrings, and Java Properties, so the wizard printed "Found 0 keys" for correctly-configured projects. Detection now routes through the FormatRegistry so key counts match what sync itself will extract. +- **sync**: Remove duplicate per-locale tick output in default `deepl sync` runs. Every completed (file, locale) pair was being printed twice — once live via the `locale-complete` progress event and again in a post-sync aggregated summary built from `fileResults`. The aggregated summary is removed; the live tick is now the sole emission site, so the console reflects progress as it happens without a redundant end-of-run block. +- **sync**: Per-key new-locale lookup in `LocaleTranslator.translateForLocale` is now O(1) (Map-indexed) instead of O(N) linear-scan. No user-visible behavior change; reduces cost on projects with large current-diff sets. +- **sync**: `resolveTemplatePatterns` now compiles each distinct pattern regex once per sync run instead of once per `TemplatePatternMatch` occurrence. Duplicate pattern strings (same template literal appearing in many source files) reuse the same `RegExp`. +- **sync**: Template-pattern prep no longer reads every source file twice during `deepl sync` runs that use template-literal patterns. Source content is cached once at the pattern-resolution step and reused in the main translation loop. +- **sync**: `push`, `pull`, `resolve`, `export`, `validate`, `audit`, and `init` now emit a machine-parseable JSON error envelope on stderr when `--format json` is set and an error occurs: `{ok: false, error: {code, message, suggestion?}, exitCode}`. Previously these subcommands wrote free-form text to stderr on failure, breaking script consumers that parse the output. `sync init` also gains a `--format json` success envelope (`{ok: true, created: {configPath, sourceLocale, targetLocales, keys}}`) for project-bootstrap scripts. Envelope shape is guarded by an AJV schema and a shared `assertErrorEnvelope` test helper. +- **sync**: `deepl sync resolve` now exits 11 (SyncConflict) when auto-resolution leaves unresolved conflicts, instead of exit 1 (GeneralError). CI pipelines can now distinguish "lockfile needs human merge" from "CLI crashed". Error message includes an actionable hint to edit `.deepl-sync.lock` manually and re-run `deepl sync`. +- **sync**: `deepl sync --watch --auto-commit` now commits on every successful sync cycle, not only on the initial sync before the watcher attaches. Matches the expected "commit on save" semantics. Gated by the same conditions as the pre-watch auto-commit (clean tree, not dry-run, files written). +- **sync**: `deepl sync --watch` no longer leaks SIGINT/SIGTERM listeners across invocations and no longer serves a stale `tmCache` entry after the TM has been rotated or deleted. The cache now enforces a 5-minute TTL and signal handlers are detached on watcher shutdown; the debounce timer is cleared so "Change detected" cannot print after "Stopping watch". +- **sync**: Stale-lock GC no longer silently deletes lockfile entries when a glob miss is potentially a moved-source rather than a truly-absent file. A broader projectRoot scan by base name guards the deletion; entries that would be GC'd now log a "glob change suspected" warning and are preserved. +- **sync**: Error messages now sanitize control chars and zero-width codepoints from user-supplied content (YAML keys, key paths, translation text) before rendering, so a malicious config or TMS-returned string cannot corrupt the terminal when shown in a ConfigError or ValidationError. +- **sync**: `deepl sync push` now issues push requests with bounded concurrency (default 10, configurable via `tms.push_concurrency`). Previously pushes ran serially per-key-per-locale, so a 5000-key × 10-locale project took ~hours at typical RTT; the new behavior completes in minutes. Aborts on first failure (unchanged semantic). +- **sync**: `deepl sync resolve` now emits a loud warning when `JSON.parse` on a conflict fragment fails and the resolver falls back to a length-heuristic. Previously the heuristic ran silently; users could not audit which entries needed manual review. +- **sync**: `deepl sync init` non-interactive path now validates inputs before writing `.deepl-sync.yaml`: rejects source locale appearing in target-langs, duplicate targets, empty target-langs, malformed locale codes, and path-traversal. Previously the wizard could write a self-invalidating config that failed at the next `deepl sync` run with a cryptic error. +- **sync**: `deepl sync --watch` now coalesces file-change events that fire during an in-flight sync; the watcher re-runs once after the current sync completes instead of silently dropping events. Previously rapid edits could leave final changes unsynced until a manual trigger. +- **sync**: `deepl sync --watch` now cleans up `.bak` files on SIGINT/SIGTERM even when a translation is in flight, and sweeps stale `.bak` siblings at watcher startup (older than 5 minutes). In-flight syncs terminate gracefully after the current locale completes. +- **sync**: Auto-glossary sync now issues a single dictionary-mutation request per locale (previously one per added/removed term) and caches glossary list responses across same-run lookups. Large glossary updates (e.g., 100 term changes) go from 200+ round-trips to ~2 per locale. +- **sync**: `deepl sync --help` now groups examples under *First-time setup* and *Everyday use*, showing the `init` → `--dry-run` → `sync` → `status` onboarding flow, and adds a pointer to `deepl tm list` for translation-memory discoverability. +- **sync**: Acquires an exclusive advisory lock (`.deepl-sync.lock.pidfile`) at sync start to prevent two concurrent `deepl sync` invocations from racing the lockfile and losing keys. Stale locks from crashed processes are detected via PID-liveness check and reclaimed with a warning. +- **sync**: `deepl sync --auto-commit` now refuses to commit when the working tree has unrelated modifications, is mid-rebase/mid-merge/mid-cherry-pick, or HEAD is detached. Also runs git commands from `config.projectRoot` (not the CLI's cwd) and stages only files actually written by the sync run. Previously, auto-commit could bundle a user's in-progress edits into the chore(i18n) commit or fail ambiguously mid-rebase. +- **sync**: TmsClient push/pull now uses a 30s default request timeout (configurable via `tms.timeout_ms`), retries 429 and 503 responses with jittered exponential backoff (max 3 attempts), and includes the response body in error messages when available. Previously a stalled TMS server hung `deepl sync push`/`pull` indefinitely and 500-class errors surfaced with no diagnostic context. +- **sync**: Lockfile version-mismatch and JSON-parse recovery now backs up the prior lockfile to `.deepl-sync.lock.bak--` before resetting in-memory state. Previously a corrupt or wrong-version lockfile was silently discarded, forcing full retranslation with no recovery path. +- **sync**: Invalid `.deepl-sync.yaml` now exits 7 (ConfigError) instead of 6 (ValidationError), matching the documented exit-code contract in docs/SYNC.md and docs/TROUBLESHOOTING.md. +- **sync**: `deepl sync init` now exits 6 (ValidationError) immediately when stdin is not a TTY and fewer than all four init flags are supplied. Previously the partial-flag path fell through to `@inquirer/prompts` and either threw `ExitPromptError` or blocked indefinitely in CI. +- **sync**: `deepl sync --frozen --watch` now exits with ValidationError (code 6). Previously the combination was documented as invalid but entered an infinite drift-check watch loop. +- **sync**: Every `ConfigError` thrown from `validateSyncConfig` (`.deepl-sync.yaml` validation) now includes a remediation `suggestion` string pointing the user at the exact YAML field to fix. Previously ~15 of 18 throw sites provided only a title, defeating the advertised `DeepLCLIError.suggestion` consumer contract. +- **sync**: `deepl sync pull` now fetches each target locale's dictionary once per sync instead of once per (source file x locale) pair. Previously a repo with N source files and L target locales issued N x L identical GETs to the TMS; the new behavior issues L. Affects push/pull throughput on multi-bucket or multi-file projects. +- **api**: `listTranslationMemories` now paginates the `GET /v3/translation_memories` response using the documented `page` / `page_size` query parameters (max 25 per page, bounded at 20 pages). Accounts with more than 25 translation memories previously received a silently truncated list, which caused `deepl tm list` and the TM name → UUID resolver to miss entries. The first call is still issued without query params for backward compatibility and only continues when the server's `total_count` indicates more pages are available. +- **sync**: `deepl sync --format json` now emits `{error, code}` JSON to stderr on failure (matching the `sync status --format json` error contract) and exits with the correct granular exit code. Previously the top-level command fell through to free-form stderr regardless of `--format`. +- **sync**: `deepl sync --format json` (and `status`, `validate`, `audit`) now emit the success JSON payload on stdout, not stderr. Previously `deepl sync --format json > out.json` produced an empty file because the payload was interleaved with progress logs on stderr. +- **translate**: `deepl translate file.txt --to en,fr,es --glossary ` and `--translation-memory ` were silently dropped on the multi-target code path, so terminology and TM were not enforced when translating to more than one language. The multi-target branch now mirrors the single-target precondition and resolution shape: `--from` is required, TM rejects non-`quality_optimized` model types, glossary and TM are resolved once per invocation, and `modelType` defaults to `quality_optimized` when TM is set. +- **translate**: Translation memory resolver cache now keys entries by `name|from|targets`, so the pair-check runs every time a different pair is requested under the same TM name within a session. Previously, sync configs with no top-level `translation_memory` but `locale_overrides` sharing a TM name across locales with mismatched pair support could silently reuse an incompatible TM UUID on the second locale. +- **translate**: `warnIgnoredOptions` now actually fires for `--translation-memory` and `--tm-threshold` in modes that do not support them (e.g. `directory`, `document`). The keys were present in the handler supported-sets but missing from `optionLabels`, so the warning was inert. +- **translate**: Harden TM name resolution against API-returned name pollution. `resolveTranslationMemoryId` now filters entries whose names contain ASCII control chars or zero-width codepoints before matching, and throws `ConfigError` when two entries share the exact name a caller is resolving (asks for UUID disambiguation instead of first-create-wins). Closes a theoretical collision vector against server-side tenancy. +- **glossary**: Glossary resolver hardening — `resolveGlossaryId` now filters API-returned glossary entries whose names contain ASCII control chars or zero-width codepoints before name matching, and throws `ConfigError` with a UUID-disambiguation hint when two surviving entries share the same name. Mirrors the TM resolver defenses. +- **examples**: `examples/31-sync-ci.sh` passes `--file-format json` to `deepl sync init` (was `--format json`, which is not a registered flag on `init` and would fall through to the interactive-prompt branch in non-TTY environments). +- **api**: `listGlossaries` and `listTranslationMemories` errors now carry their method name as a `[listGlossaries]` / `[listTranslationMemories]` suffix on `error.message`. Suffix (not prefix) preserves `deepl sync --format json` stderr-shim consumer greps on canonical phrases like `Authentication failed: Invalid API key`. +- **sync**: Reject `translation_memory` paired with a non-`quality_optimized` `model_type` at config load (ConfigError, exit 7) instead of letting the DeepL API reject each translate request. Applies at top-level and per-locale override. +- **sync**: ICU MessageFormat preservation — plural, select, and selectordinal structures are now preserved during translation. Only leaf text is sent to the API; structural keywords (`plural`, `one`, `other`, etc.) and variable names are kept intact. Handles nested ICU (e.g., select inside plural). +- **sync**: Progress output no longer shows `0/0 keys` lines for up-to-date locales. +- **sync**: New-locale translations now correctly count in progress output. +- **sync**: `sync resolve` conflict marker detection now works mid-file (added multiline flag to regex) +- **sync**: `sync validate`, `sync status`, `sync export`, `sync push`, and `sync pull` now handle multi-locale formats (`.xcstrings`) correctly +- **sync**: `sync init` auto-detection now generates valid glob patterns instead of `{locale}` placeholders that fast-glob cannot match +- **sync**: `resolveTargetPath` supports `target_path_pattern` for Android XML and XLIFF where source locale is absent from source path +- **json**: Warn when JSON files contain duplicate keys (last value used per RFC 8259) +- **sync**: Validation now detects untranslated content (translation identical to source) and excessive length ratio (>150%) +- **sync**: Config validator now passes through `translation`, `validation`, `sync`, `tms`, and `ignore` YAML blocks +- **sync**: CLI overrides (`--formality`, `--glossary`, `--model-type`, `--context`) now merge into config +- **sync**: `--force` mode no longer causes index misalignment when lock has deleted keys +- **sync**: Failed translations now recorded as 'failed' (not 'translated') in lock file per locale +- **sync**: `--frozen` mode now detects drift for deletion-only changes +- **sync**: Lock file structural validation prevents crash on malformed lock files +- **sync**: Batch translation context correctly scoped — per-key requests when context is available, batch without context otherwise +- **sync**: Reject path traversal in target locale at config validation +- **sync**: `sync export --output` now rejects paths that escape the project root and creates missing intermediate directories before writing +- **sync**: `deepl sync audit` now surfaces real translated text read from target files instead of SHA hashes, so terminology-inconsistency output is readable. Missing target files fall back to the hash so divergence is still detected. +- **sync**: Restore `--locale` and `--format` options on the bare `deepl sync` command (previously dropped during an earlier commander option-shadowing fix) and wire `--sync-config` end-to-end — commander camelCases the flag to `syncConfig`, but the handler was reading `config`, so the flag was silently ignored. +- **sync**: Protect placeholders from translation via preserveVariables +- **sync**: PO format reconstruct preserves translations on re-sync +- **sync**: Validate source_locale for path traversal characters +- **sync**: Add `assertPathWithinRoot` guard in sync validate +- **sync**: Fix `resolveTargetPath` `$n` locale injection via function callbacks +- **sync**: Skip deleted diffs in sync-status coverage counts +- **sync**: Validate HTTPS scheme in TmsClient +- **sync**: Replace blocking `readFileSync` with async read in context extraction +- **po**: Correct escape sequence order in `unquote()` -- backslash processed first +- **po**: Multi-line PO header no longer deleted during reconstruct +- **po**: Use ASCII EOT separator for msgctxt keys (fixes `#` in msgid collision) +- **formats**: Prevent `$`-pattern corruption in XLIFF, iOS Strings, and text-preservation `String.replace` calls +- **android-xml**: Add backslash escaping and preserve extra attributes on plurals/string-arrays +- **json**: Handle UTF-8 BOM in JSON files +- **yaml**: Handle empty content in YAML reconstruct +- **utils**: Use unique temp filenames in atomicWriteFile to prevent concurrent corruption +- **sync**: Pre-initialize localeSuccessMap to prevent concurrent locale overwrite race +- **sync**: Retry failed lock entries — computeDiff checks translation status +- **sync**: Resolve per-locale glossary override by name instead of passing raw string +- **sync**: Guard force+frozen combination in sync() API +- **sync**: Only write pull lock file when entries were actually processed +- **android-xml**: Correct unescapeAndroid escape order using single-pass regex. +- **formats**: Reconstructed output for Android XML, YAML, iOS Strings, XLIFF, and ARB parsers now omits keys that were deleted from the source. ARB also inserts newly-added keys at reconstruct time. +- **sync**: Unified placeholder regex with frequency-based comparison +- **sync**: Support Unicode placeholder names and positional printf specifiers (%1$s) +- **po**: Remove fuzzy flag when providing fresh translation +- **sync**: Validate optional config block types (translation, validation, sync, tms) before casting +- **sync**: Normalize documented bucket keys across `sync`, `status`, `validate`, `push`, `pull`, and `sync init` +- **sync**: New locale detection — translate existing keys when a target locale is added +- **sync**: Write lock entries for new-locale translations to prevent re-translation +- **sync**: `--frozen` now detects drift when a new target locale is added +- **sync**: `--dry-run` reports pending new-locale translation in key counts +- **sync**: Clean stale lock entries for files no longer matched by any bucket glob +- **sync**: Merge `config.ignore` patterns into fast-glob for status, validate, push, pull +- **sync**: Guard `source_locale` == `target_locale` in config validation +- **sync**: Deep-clone diff metadata per locale to prevent concurrent mutation +- **sync**: Wrap locale worker in try/catch for graceful per-locale error handling +- **sync**: New-locale translation path applies `preserveVariables`/`restorePlaceholders` +- **sync**: PO plural forms for 3+ form languages (Russian, Arabic) fill higher msgstr indices +- **sync**: `--frozen` guards stale lock entry cleanup and lock file write +- **sync**: Preserve translateBatch index alignment by returning sparse array on partial failure +- **sync**: `restorePlaceholders` replaces all occurrences (not just first) +- **sync**: Fix `context_lines` default to 3 (matching documentation) +- **android-xml**: Escape `<`, `>`, `&` in translations to prevent XML injection +- **json**: Guard against 0-byte source files +- **translate**: Invalid `--to` error is now concise — the 100+ language-code dump is removed; the message points at `deepl languages` for the full list. +- **examples**: `examples/30-sync-basic.sh` and `examples/31-sync-ci.sh` now clean up `/tmp/deepl-sync-demo/` and `/tmp/deepl-sync-ci-demo/` on mid-script failure via `trap cleanup EXIT` (matching the pattern already in examples 32 and 34). ### Security +- **`deepl sync --force` billing defense**: `--watch --force` is now rejected at startup with `ValidationError` (exit 6) — the combination would silently retranslate every key on every file change, creating an unbounded billing loop. Additionally, `--force` now requires confirmation in interactive mode ("Retranslate all keys and bypass cost cap? [y/N]"); add `--yes` (`-y`) to skip the prompt in scripts. In CI environments (`CI=true`), `--force` requires `--yes` explicitly — the process exits 6 with an actionable hint rather than proceeding silently. - Updated `minimatch` from `^9.0.5` to `^10.2.1` to fix ReDoS vulnerability (GHSA-3ppc-4f35-3m26) +- **sync**: `deepl sync pull` now acquires the pidfile process lock (`acquireSyncProcessLock`) before writing any target files. Previously, a concurrent `deepl sync` (which holds the lock while writing target files) and `deepl sync pull` could race across multiple files — `atomicWriteFile` prevents torn individual writes but the multi-file read-modify-write cycle was unguarded. `deepl sync push` is read-only toward local files and does not need the guard. +- **sync**: `sanitizePullKeysResponse` now enforces a hard cap of 50,000 keys (`MAX_PULL_KEY_COUNT`) on TMS pull responses. A response exceeding this limit is rejected with a `ValidationError` before any data is written to disk, preventing OOM conditions on large TMS inventories (e.g. 100K keys × 20 locales). Remediation: partition the TMS export by locale or paginate the pull. +- **sync**: TMS error responses are now sanitized through `sanitizeForTerminal` before appearing in thrown `Error` messages. Both the response body (capped at 1024 bytes) and `response.statusText` are stripped of ANSI escape sequences, bidi override codepoints (U+202A–U+202F), and other terminal-unsafe control characters. Previously a malicious or compromised TMS server could inject sequences such as `\x1b[2J` (screen clear) or U+202E (right-to-left override) into the operator's terminal via a crafted 4xx/5xx HTTP response. +- **sync**: TMS `sync pull` now validates response payloads at the TmsClient boundary before writing to source trees. Non-string values, keys with path separators or control chars, and values >64KiB are rejected with ValidationError; control chars are stripped from accepted values. Previously a compromised or misconfigured TMS could write arbitrary bytes — including format-breaking XML or terminal-corrupting control sequences — into the user's working tree. +- **security**: Validate `context.scan_paths` against project root with symlink protection +- **security**: Use URL hostname check for `tms.server` (prevents `localhost.evil.com` bypass) +- **security**: Encode `tms.project_id` in URL path +- **sync**: `sync push`, `sync pull`, `sync export`, and `sync validate` now refuse to follow symbolic links when scanning source files, matching the policy already enforced by `sync` itself and `sync-context`. Previously a symlink inside a bucket's `include` pattern would be silently followed, potentially reading and transmitting files outside the project root (e.g., `/etc/passwd`, SSH keys). +- **sync**: `Logger.sanitize()` now redacts TMS credentials (`TMS_API_KEY`, `TMS_TOKEN` env values, and `Authorization: ApiKey/Bearer ` headers). Previously only `DEEPL_API_KEY` and `DeepL-Auth-Key` were covered, so TMS credentials could leak into logs via error messages, Headers dumps, or stringified fetch error bodies. +- **sync**: Harden `sync resolve` conflict-fragment merge against prototype pollution. JSON-parsed fragments can carry `__proto__`/`constructor`/`prototype` as own properties; the merge now skips those keys and uses `Object.create(null)` accumulators so `deepl sync resolve` on a hostile `.deepl-sync.lock` cannot mutate `Object.prototype`. +- **sync**: `deepl sync export` now rejects source-side paths that resolve outside the project root with a ValidationError, matching the `--output` destination guard. Previously a `.deepl-sync.yaml` with absolute source paths or symlinks pre-dating the fast-glob hardening could read files outside the configured scan root during export. +- **sync**: `scan_paths` config entries are validated against path traversal using a proper glob-literal prefix extractor rather than a regex strip. Previously crafted patterns using brace-expansion (`{..,src}/**`), extglobs (`@(..)/**`), or escaped wildcards could bypass the prior `assertPathWithinRoot` guard. No change to valid configurations; rejected configurations now produce a ConfigError with the offending pattern shown. +- **deps**: `npm audit fix` — resolves `axios` GHSA-3p68-rc4w-qgx5 (SSRF via NO_PROXY normalization), `axios` GHSA-fvcv-3m26-pcqx (cloud-metadata exfil via header injection), and `follow-redirects` GHSA-r4q5-vmmm-2653 (auth-header leak on redirect). Not reachable from the CLI (baseUrl hardcoded to `api.deepl.com`; TMS uses native `fetch`); transitive advisories are now quiet. ## [1.0.0] - 2026-02-17 diff --git a/CLAUDE.md b/CLAUDE.md index 0930573..f2b032c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,21 +6,25 @@ DeepL CLI is a command-line interface for the DeepL API that integrates translat ### Current Status -- **Version**: 1.0.0 -- **Tests**: 2757 tests passing (100% pass rate), ~91% coverage +- **Version**: see `VERSION` file / `package.json` +- **Tests**: see `npm test` output (target: all green; coverage thresholds enforced by jest config) - **Test mix**: ~70-75% unit, ~25-30% integration/e2e -- **Next Milestone**: 1.0.0 (stable public API) ### Architecture ``` -CLI Commands (translate, write, watch, glossary, etc.) +CLI Commands (translate, write, voice, sync, watch, glossary, tm, …) ↓ -Service Layer (Translation, Write, Batch, Watch, GitHooks, Cache, Glossary) +Service Layer (Translation, Write, Voice, Batch, Watch, Glossary, + TranslationMemory, StyleRules, Admin, Document, + GitHooks, Usage, Detect, Languages) + ↓ ↓ +Sync Engine (src/sync) Format Parsers (src/formats — 11 i18n formats) + ↓ ↓ +API Client (Translate, Write, Glossary, Document, Voice, + StyleRules, Admin, TMS) ↓ -API Client (DeepL API: /v2/translate, /v2/write, /v3/glossaries) - ↓ -Storage (SQLite Cache, Config Management) +Storage (SQLite Cache, Config) + Static Data (src/data — language registry) ``` ### Configuration @@ -98,8 +102,11 @@ Use **Semantic Versioning** with **Conventional Commits**: src/ ├── cli/ # CLI interface and commands ├── services/ # Business logic +├── sync/ # Continuous localization engine (scan, diff, translate, write, lock) +├── formats/ # i18n file format parsers (JSON, YAML, PO, XLIFF, Android XML, etc.) ├── api/ # DeepL API client -├── storage/ # Data persistence +├── storage/ # Data persistence (SQLite cache, config) +├── data/ # Static data (language registry) ├── utils/ # Utility functions └── types/ # Type definitions ``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1fd5810..bc99a5f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -195,6 +195,7 @@ Contributions must be licensed under the same license as the project: - [ ] `README.md` updated if user-facing feature changed - [ ] `docs/API.md` updated if command/flag added or changed - [ ] Example script added/updated in `examples/` for new features +- [ ] Added new example script to `examples/run-all.sh` EXAMPLES array (for new CLI commands) ## Adding a New CLI Command @@ -227,4 +228,4 @@ When filing a bug report, include: - [docs/API.md](./docs/API.md) -- Complete CLI command reference - [DeepL API Docs](https://www.deepl.com/docs-api) -- Official API documentation -[issues]: https://github.com/kwey/deepl-cli/issues +[issues]: https://github.com/DeepLcom/deepl-cli/issues diff --git a/LICENSE b/LICENSE index 6c3eca3..d0df5e5 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 DeepL +Copyright (c) 2026 DeepL SE Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 3fc3220..020582f 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,9 @@ ## 🌟 Key Features - **🌍 Translation** - High-quality translation using DeepL's next-gen LLM +- **🔄 Continuous Localization (`deepl sync`)** - Incremental i18n file sync with 11 format parsers - **📄 Document Translation** - Translate PDF, DOCX, PPTX, XLSX with formatting preservation -- **🎙️ Voice Translation** - Real-time speech translation via WebSocket streaming (Voice API) +- **🎙️ Voice Translation (Pro/Enterprise)** - Real-time speech translation via WebSocket streaming (Voice API) - **👀 Watch Mode** - Real-time file watching with auto-translation - **✍️ Writing Enhancement** - Grammar, style, and tone suggestions (DeepL Write API) - **💾 Smart Caching** - Local SQLite cache with LRU eviction @@ -31,26 +32,27 @@ For security policy and vulnerability reporting, see [SECURITY.md](SECURITY.md). - [Verbose Mode](#verbose-mode) - [Quiet Mode](#quiet-mode) - [Custom Configuration Files](#custom-configuration-files) + - [Configuration Paths](#configuration-paths) + - [Proxy Configuration](#proxy-configuration) + - [Retry and Timeout Configuration](#retry-and-timeout-configuration) - [Usage](#-usage) - **Core Commands:** [Translation](#translation) | [Writing Enhancement](#writing-enhancement) | [Voice Translation](#voice-translation) - - **Resources:** [Glossaries](#glossaries) + - **Resources:** [Glossaries](#glossaries) | [Translation Memories](#translation-memories) - **Workflow:** [Watch Mode](#watch-mode) | [Git Hooks](#git-hooks) - **Configuration:** [Setup Wizard](#setup-wizard) | [Authentication](#authentication) | [Configure Defaults](#configure-defaults) | [Cache Management](#cache-management) | [Style Rules](#style-rules) - - **Information:** [Usage Statistics](#api-usage-statistics) | [Language Detection](#language-detection) | [Languages](#supported-languages) | [Shell Completion](#shell-completion) + - **Information:** [Usage Statistics](#api-usage-statistics) | [Language Detection](#language-detection) | [Languages](#supported-languages) | [Shell Completion](#shell-completion) | [Command Suggestions](#command-suggestions) - **Administration:** [Admin API](#admin-api) - [Development](#-development) - [Architecture](#-architecture) +- [Testing](#-testing) +- [Documentation](#-documentation) +- [Environment Variables](#-environment-variables) +- [Security & Privacy](#-security--privacy) - [Contributing](#-contributing) - [License](#-license) ## 📦 Installation -### From npm (Coming Soon) - -```bash -npm install -g deepl-cli -``` - ### From Source ```bash @@ -69,7 +71,7 @@ npm link # Verify installation deepl --version -# Output: 1.0.0 +# Output: deepl-cli 1.x.x ``` > **Note:** This project uses [`better-sqlite3`](https://github.com/WiseLibs/better-sqlite3) for local caching, which requires native compilation. If `npm install` fails with build errors, ensure you have: @@ -78,6 +80,12 @@ deepl --version > - **Linux**: `python3`, `make`, and `gcc` (`apt install python3 make gcc g++`) > - **Windows**: Visual Studio Build Tools or `windows-build-tools` (`npm install -g windows-build-tools`) +### From npm (not yet published) + +An npm package is not yet published; install from source until then. + +> **CI examples below** (and generated hook output) assume a published npm package; source-installed users should substitute the source-install path. + ## 🚀 Quick Start ### 1. Get Your DeepL API Key @@ -288,13 +296,13 @@ deepl translate locales/en.json --to es,fr,de --output locales/ **Smart Caching for Text Files:** -Small text-based files (under 100 KB) automatically use the cached text translation API for faster performance and reduced API calls. Larger files automatically fall back to the document translation API (not cached). +Small text-based files (under 100 KiB) automatically use the cached text translation API for faster performance and reduced API calls. Larger files automatically fall back to the document translation API (not cached). -- **Cached formats:** `.txt`, `.md`, `.html`, `.htm`, `.srt`, `.xlf`, `.xliff` (files under 100 KB only) +- **Cached formats:** `.txt`, `.md`, `.html`, `.htm`, `.srt`, `.xlf`, `.xliff` (files under 100 KiB only) - **Structured formats:** `.json`, `.yaml`, `.yml` — parsed and translated via batch text API (no size limit) -- **Large file fallback:** Files ≥100 KB use document API (not cached, always makes API calls) +- **Large file fallback:** Files ≥100 KiB use document API (not cached, always makes API calls) - **Binary formats:** `.pdf`, `.docx`, `.pptx`, `.xlsx` always use document API (not cached) -- **Performance:** Only small text files (<100 KB) benefit from instant cached translations +- **Performance:** Only small text files (<100 KiB) benefit from instant cached translations - **Cost savings:** Only small text files avoid repeated API calls ```bash @@ -840,6 +848,8 @@ deepl init # - Basic configuration ``` +> To configure continuous localization for an existing project, see also [`deepl sync init`](#sync-init) or run the wizard directly. + #### Authentication ```bash @@ -1040,7 +1050,7 @@ DeepL CLI includes built-in retry logic and timeout handling for robust API comm - ✅ Automatic retry on transient failures - ✅ Exponential backoff to avoid overwhelming the API - ✅ Smart error detection (retries 5xx, not 4xx) -- ✅ Configurable timeout and retry limits (programmatic API only) +- ✅ Configurable via library-consumer options; not exposed as a CLI flag - ✅ Works across all DeepL API endpoints **Retry Behavior Examples:** @@ -1313,7 +1323,7 @@ Cache location: `~/.cache/deepl-cli/cache.db` (or `~/.deepl-cli/cache.db` for le ### Prerequisites -- Node.js >= 20.0.0 +- Node.js >= 20.19.0 - npm >= 9.0.0 - DeepL API key @@ -1387,25 +1397,33 @@ deepl translate "Hello" --to es DeepL CLI follows a layered architecture: ``` -CLI Interface (Commands, Parsing, Help) - ↓ -Core Application (Command Handlers, Interactive Shell) +CLI Commands (translate, write, voice, sync, watch, glossary, tm, …) ↓ -Service Layer (Translation, Write, Cache, Watch, Glossary) +Service Layer (Translation, Write, Voice, Batch, Watch, Glossary, + TranslationMemory, StyleRules, Admin, Document, + GitHooks, Usage, Detect, Languages) + ↓ ↓ +Sync Engine (src/sync) Format Parsers (src/formats — 11 i18n formats) + ↓ ↓ +API Client (Translate, Write, Glossary, Document, Voice, + StyleRules, Admin, TMS) ↓ -API Client (DeepL Translate, Write, Glossary APIs) - ↓ -Storage (SQLite Cache, Config, Translation Memory) +Storage (SQLite Cache, Config) + Static Data (src/data — language registry) ``` ### Key Components -- **Translation Service** - Core translation logic with caching and preservation -- **Write Service** - Grammar and style enhancement -- **Cache Service** - SQLite-based cache with LRU eviction -- **Preservation Service** - Preserves code blocks, variables, formatting -- **Watch Service** - File watching with debouncing -- **Glossary Service** - Glossary management and application +- **Translation Service** — core translation with caching and text preservation +- **Write Service** — grammar, style, and tone suggestions +- **Voice Service** — real-time speech translation over WebSocket +- **Sync Engine** — continuous localization: scan, diff, translate, write, lock +- **Format Parsers** — 11 i18n parsers (JSON, YAML, TOML, PO, Android XML, iOS Strings, xcstrings, ARB, XLIFF, Properties, Laravel PHP) with format-preserving reconstruct +- **Batch Service** — parallel multi-file translation +- **Watch Service** — file watching with debouncing +- **Glossary Service** — glossary management and application +- **Translation Memory Service** — reuse approved translations (`--translation-memory`) +- **Cache** — SQLite cache with LRU eviction (`src/storage/cache.ts`) +- **Preservation utilities** — `src/utils/` helpers for code blocks, variables, and ICU MessageFormat ## 🧪 Testing @@ -1452,6 +1470,10 @@ npm run examples:fast | `DEEPL_CONFIG_DIR` | Override config and cache directory | | `XDG_CONFIG_HOME` | Override XDG config base (default: `~/.config`) | | `XDG_CACHE_HOME` | Override XDG cache base (default: `~/.cache`) | +| `HTTP_PROXY` | HTTP proxy URL for outbound DeepL API traffic | +| `HTTPS_PROXY` | HTTPS proxy URL (takes precedence over `HTTP_PROXY`) | +| `TMS_API_KEY` | API key for the configured TMS server (`sync push`/`pull`) | +| `TMS_TOKEN` | Alternative auth token for the configured TMS server | | `NO_COLOR` | Disable colored output | | `FORCE_COLOR` | Force colored output even when terminal doesn't support it. Useful in CI. `NO_COLOR` takes priority if both are set. | | `TERM=dumb` | Disables colored output and progress spinners. Automatically set by some CI environments and editors. | @@ -1464,12 +1486,14 @@ See [docs/API.md#environment-variables](./docs/API.md#environment-variables) for - **Local caching** - All cached data stored locally in SQLite, never shared - **No telemetry** - Zero usage tracking or data collection - **Environment variable support** - Use `DEEPL_API_KEY` environment variable for CI/CD -- **GDPR compliant** - Follows DeepL's GDPR compliance guidelines +- **GDPR-aligned with DeepL's DPA** - Follows DeepL's Data Processing Agreement terms ## 📄 License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +DeepL® is a registered trademark of DeepL SE. + --- _Powered by DeepL's next-generation language model_ diff --git a/SECURITY.md b/SECURITY.md index 2f1286c..ee58f23 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,7 +1,10 @@ +# Security Policy + ## Supported Versions | Version | Supported | |---------|--------------------| +| 1.1.x | :white_check_mark: | | 1.0.x | :white_check_mark: | | < 1.0 | :x: | diff --git a/VERSION b/VERSION index 3eefcb9..9084fa2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0 +1.1.0 diff --git a/docs/API.md b/docs/API.md index bafcff9..b66b4a4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,7 +1,7 @@ # DeepL CLI - API Reference -**Version**: 1.0.0 -**Last Updated**: February 17, 2026 +**Version**: 1.1.0 +**Last Updated**: April 23, 2026 Complete reference for all DeepL CLI commands, options, and configuration. @@ -17,8 +17,10 @@ Complete reference for all DeepL CLI commands, options, and configuration. - [voice](#voice) - Resources - [glossary](#glossary) + - [tm](#tm) - Workflow - [watch](#watch) + - [sync](#sync) - [hooks](#hooks) - Configuration - [init](#init) @@ -34,8 +36,8 @@ Complete reference for all DeepL CLI commands, options, and configuration. - Administration - [admin](#admin) - [Configuration](#configuration) -- [Exit Codes](#exit-codes) - [Environment Variables](#environment-variables) +- [Exit Codes](#exit-codes) --- @@ -174,8 +176,8 @@ Commands are organized into six groups, matching the `deepl --help` output: | Group | Commands | Description | | ------------------ | ------------------------------------------------ | ------------------------------------------------------------------------- | | **Core Commands** | `translate`, `write`, `voice` | Translation, writing enhancement, and speech translation | -| **Resources** | `glossary` | Manage translation glossaries | -| **Workflow** | `watch`, `hooks` | File watching and git hook automation | +| **Resources** | `glossary`, `tm` | Manage translation glossaries and translation memory | +| **Workflow** | `watch`, `sync`, `hooks` | File watching, project sync, and git hook automation | | **Configuration** | `init`, `auth`, `config`, `cache`, `style-rules` | Setup wizard, authentication, settings, caching, and style rules | | **Information** | `usage`, `languages`, `detect`, `completion` | API usage, supported languages, language detection, and shell completions | | **Administration** | `admin` | Organization key management and usage analytics | @@ -237,6 +239,8 @@ Translate text directly, from stdin, from files, or entire directories. Supports - `--ignore-tags TAGS` - Comma-separated XML tags with content to ignore (requires `--tag-handling xml`) - `--tag-handling-version VERSION` - Tag handling version: `v1`, `v2`. v2 improves XML/HTML structure handling (requires `--tag-handling`) - `--glossary NAME-OR-ID` - Use glossary by name or ID for consistent terminology +- `--translation-memory NAME-OR-UUID` - Use translation memory by name or UUID (forces `quality_optimized` model). Requires `--from` because TMs are pinned to a specific source→target language pair. Invalid use exits 6 (ValidationError); unresolvable/misconfigured TM exits 7 (ConfigError). +- `--tm-threshold N` - Minimum match score 0–100 (default 75, requires `--translation-memory`). Invalid use exits 6 (ValidationError); unresolvable/misconfigured TM exits 7 (ConfigError). - `--custom-instruction INSTRUCTION` - Custom instruction for translation (repeatable, max 10, max 300 chars each). Forces `quality_optimized` model. Cannot be used with `latency_optimized`. - `--style-id UUID` - Style rule ID for translation (Pro API only). Forces `quality_optimized` model. Cannot be used with `latency_optimized`. Use `deepl style-rules list` to see available IDs. - `--enable-beta-languages` - Include beta languages that are not yet stable (forward-compatibility with new DeepL languages) @@ -448,8 +452,6 @@ deepl translate "Bank" --to es --context "Financial institution" deepl translate app.json --to es --context "E-commerce checkout flow" ``` -**Note:** The `--context` feature may not be supported by all DeepL API tiers. Check your API plan for context support availability. - **Formality levels:** ```bash @@ -528,6 +530,37 @@ deepl translate "API documentation" --to es --glossary tech-terms deepl translate README.md --to fr --glossary abc-123-def-456 --output README.fr.md ``` +**Translation memory usage:** + +Translation memories (TMs) are pinned to a source→target language pair, so `--from` is required. Passing `--translation-memory` forces `quality_optimized` model type; combining it with `--model-type latency_optimized` (or `prefer_quality_optimized`) exits 6 (ValidationError). TM files are authored and uploaded via the DeepL web UI; this CLI resolves the name-or-UUID against `GET /v3/translation_memories` and caches the resolution per run. + +```bash +# Use translation memory by name (requires --from for pair resolution) +deepl translate "Welcome to our product." --from en --to de --translation-memory my-tm + +# Use translation memory by UUID with a custom threshold +deepl translate "Welcome to our product." --from en --to de \ + --translation-memory 3f2504e0-4f89-41d3-9a0c-0305e82c3301 --tm-threshold 80 + +# Combine glossary and translation memory on a single call +deepl translate "Welcome to our product." --from en --to de \ + --glossary tech-terms --translation-memory my-tm --tm-threshold 85 +``` + +**Multi-target file translation with glossary / TM:** + +Both `--glossary` and `--translation-memory` apply to multi-target file translation (e.g. `--to en,fr,es`) and in that mode `--from` is required. Glossary name resolution works transparently across all target languages. Translation memory name resolution, however, requires a single TM that covers every requested target language pair — because each TM in DeepL is scoped to one source→target pair, using a TM name with differing multi-targets surfaces a `ConfigError` (exit 7). For multi-target TM use, pass the TM UUID directly. + +```bash +# Glossary across multiple targets (name resolution works for all targets) +deepl translate README.md --from en --to fr,es,it --glossary tech-terms --output ./out + +# Translation memory across multiple targets: pass a UUID to avoid the +# single-pair name-resolution constraint +deepl translate README.md --from en --to fr,es,it --output ./out \ + --translation-memory 3f2504e0-4f89-41d3-9a0c-0305e82c3301 +``` + **Cache control:** ```bash @@ -986,6 +1019,401 @@ deepl watch docs/ --to de,ja --git-staged --auto-commit --- +### sync + +Continuous localization engine for i18n file translation. + +#### Synopsis + +```bash +deepl sync [OPTIONS] +deepl sync init [OPTIONS] +deepl sync status [OPTIONS] +deepl sync validate [OPTIONS] +deepl sync audit [OPTIONS] +deepl sync export [OPTIONS] +deepl sync resolve [OPTIONS] +deepl sync push [OPTIONS] +deepl sync pull [OPTIONS] +``` + +#### Description + +Scan, translate, and sync i18n resource files. The sync engine reads `.deepl-sync.yaml` for project configuration, diffs source strings against `.deepl-sync.lock` to detect changes, translates only new and modified strings via the DeepL API, and writes properly formatted target files. + +**Supported formats:** JSON, YAML, TOML, Gettext PO, Android XML, iOS Strings, Xcode String Catalog (.xcstrings), ARB, XLIFF, Java Properties, Laravel PHP arrays (.php). + +**Behavior:** + +- Reads configuration from `.deepl-sync.yaml` in the current directory +- Tracks translation state in `.deepl-sync.lock` for incremental sync +- Preserves format-specific structure (indentation, comments, metadata) +- Displays per-locale progress as each translation completes +- Exits with code [10](#exit-codes) when `--frozen` detects translation drift +- Bounds `context.scan_paths` at `sync.max_scan_files` files (default 50,000) to prevent a misconfigured glob from wedging the CLI on huge source trees. Exceeding the cap throws a `ValidationError` with a suggestion to narrow the pattern or raise the cap; see [docs/SYNC.md](SYNC.md#sync). + +#### Options + +**Sync Mode:** + +- `--dry-run` - Preview changes without translating +- `--frozen` - Fail (exit 10) if translations are missing or outdated; no API calls +- `--ci` - Alias for `--frozen` +- `--force` - Re-translate all strings, ignoring the lockfile. **WARNING:** also bypasses the `sync.max_characters` cost-cap preflight in `.deepl-sync.yaml`, so a forced run can re-bill every translated key and incur unexpected API costs. Run `deepl sync --dry-run` first to see the character estimate before forcing. + + **Billing safety guards:** + + - `--watch --force` is rejected at startup with a `ValidationError` (exit 6) to prevent unbounded billing from a forced re-translation on every file save. + - In an interactive terminal, `--force` prompts for confirmation before bypassing the cost cap. Pass `--yes` (`-y`) to skip the prompt in scripts. + - In CI environments (`CI=true`), `--force` requires an explicit `--yes`; otherwise the process exits 6 with an actionable hint naming the missing flag. + +**Filtering:** + +- `--locale LANGS` - Sync only specific target locales (comma-separated) + +**Translation Quality:** + +- `--formality LEVEL` - Override formality: `default`, `more`, `less`, `prefer_more`, `prefer_less`, `formal`, `informal` +- `--model-type TYPE` - Override model type: `quality_optimized`, `prefer_quality_optimized`, `latency_optimized` +- `--glossary NAME-OR-ID` - Override glossary name or ID +- `--scan-context` / `--no-scan-context` - Enable or disable source-code context scanning. Matches both string literal and template literal `t()` calls. When enabled, key paths are parsed into natural-language context descriptions, and HTML element types are detected from surrounding source code. Element types feed into `instruction_templates` (configured in `.deepl-sync.yaml`) for auto-generated `custom_instructions`. **Scope:** these flags override `context.enabled` in `.deepl-sync.yaml` only; all other `context.*` settings (`include`, `exclude`, `max_files`, etc.) continue to apply when scanning is enabled. **Note:** bare `--context` / `--no-context` on `deepl sync` is rejected with a ValidationError (exit 6) — the string-valued `--context ""` flag only applies to `deepl translate`; sync's boolean toggle was renamed to `--scan-context` to avoid the collision. + +**Note:** `deepl sync` deliberately exposes no `--translation-memory` / `--tm-threshold` CLI override; configure translation memory via `translation.translation_memory` (and optional `translation.translation_memory_threshold`) in `.deepl-sync.yaml`, with per-locale overrides under `translation.locale_overrides`. + +**Performance:** + +- `--concurrency NUM` - Max parallel locale translations (default: 5) +- `--batch` - Force plain batch mode (fastest, no context or instructions). All keys in batch API calls. +- `--no-batch` - Force per-key mode (slowest, individual context per key). Default: section-batched context (~3.4x faster than per-key while preserving disambiguation context). + +**Git:** + +- `--auto-commit` - Auto-commit translated files after sync (requires git) + +**Review:** + +- `--flag-for-review` - Mark translations as `machine_translated` in lock file for human review + +**Watch:** + +- `--watch` - Watch source files and auto-sync on changes +- `--debounce MS` - Debounce delay for watch mode (default: 500ms) + +**Output:** + +- `--format FORMAT` - Output format: `text` (default), `json` + +**Config:** + +- `--sync-config PATH` - Path to `.deepl-sync.yaml` (default: auto-detect) + +#### Subcommands + +##### `init` + +Interactive setup wizard that creates `.deepl-sync.yaml` by scanning the project for i18n files. + +**Auto-detected project types:** i18next / react-intl / vue-i18n / next-intl (JSON under `locales/` or `i18n/`), Rails (`config/locales/en.yml`), generic YAML i18n, Django / generic gettext (`locale/*/LC_MESSAGES/*.po`), Android (`res/values/strings.xml`), iOS / macOS (`*.lproj/Localizable.strings`), Xcode String Catalog (`Localizable.xcstrings` / `*.xcstrings`), Flutter (`pubspec.yaml` + `l10n/app_en.arb` or `*_en.arb`), Angular / CAT tools (XLIFF under `src/locale/` or root), go-i18n (`locales/en.toml` or `i18n/en.toml`), Java / Spring (`src/main/resources/messages_en.properties`), and Laravel (`composer.json` + `lang/en/*.php` or `resources/lang/en/*.php`). Detection is filesystem-only — no package manifests are parsed. See [docs/SYNC.md](./SYNC.md#deepl-sync-init) for the full detection matrix. Layouts outside these conventions need the four flags above. + +**Options:** + +- `--source-locale CODE` - Source locale code +- `--target-locales CODES` - Target locales (comma-separated) +- `--file-format TYPE` - File format: `json`, `yaml`, `toml`, `po`, `android_xml`, `ios_strings`, `xcstrings`, `arb`, `xliff`, `properties`, `laravel_php` +- `--path GLOB` - Source file path or glob pattern +- `--sync-config PATH` - Path to `.deepl-sync.yaml` + +`--source-lang` and `--target-langs` are accepted as deprecated aliases for one minor release and emit a stderr warning; they will be removed in the next major release. `deepl translate --target-lang` is unchanged — it operates on strings and stays aligned with the DeepL API's wire name. + +**Examples:** + +```bash +# Interactive auto-detection +deepl sync init + +# Non-interactive +deepl sync init --source-locale en --target-locales de,fr,es --file-format json --path "locales/en.json" +``` + +##### `status` + +Show translation coverage for all target locales. + +**Options:** + +- `--locale LANGS` - Show status for specific locales only +- `--format FORMAT` - Output format: `text` (default), `json` +- `--sync-config PATH` - Path to `.deepl-sync.yaml` + +**JSON output contract (stable across 1.x):** + +```json +{ + "sourceLocale": "en", + "totalKeys": 142, + "skippedKeys": 1, + "locales": [ + { "locale": "de", "complete": 140, "missing": 2, "outdated": 0, "coverage": 98 } + ] +} +``` + +`skippedKeys` counts entries the parser tagged as untranslatable and excluded from the translation batch — currently only Laravel pipe-pluralization values (`|{n}`, `|[n,m]`, `|[n,*]`). Included in `totalKeys`. + +**stdout/stderr split (stable contract):** The success JSON payload is written to **stdout**, so `deepl sync status --format json > status.json` produces a parseable file. Diagnostic/progress logs stay on **stderr**. The same stdout/stderr split applies to `deepl sync --format json`, `deepl sync validate --format json`, and `deepl sync audit --format json`. + +**Error envelope (shared across every `sync` subcommand):** On failure, `--format json` emits the following JSON envelope to **stderr** and exits with the typed exit code: + +```json +{ + "ok": false, + "error": { + "code": "ConfigError", + "message": ".deepl-sync.yaml not found in current directory or any parent", + "suggestion": "Run `deepl sync init` to create one." + }, + "exitCode": 7 +} +``` + +The `error.code` field matches the error class name (`ConfigError`, `ValidationError`, `SyncConflict`, `AuthError`, etc.). `error.suggestion` is present when the underlying `DeepLCLIError` carries one. `exitCode` matches the process exit code, so a caller can branch on either field. The envelope shape is identical for `deepl sync`, `sync push`, `sync pull`, `sync resolve`, `sync export`, `sync validate`, `sync audit`, `sync init`, and `sync status`. + +**`sync init --format json` success envelope:** For scripted project bootstrap, `deepl sync init --format json` emits a success envelope on **stdout** instead of the plain text confirmation: + +```json +{ + "ok": true, + "created": { + "configPath": "/absolute/path/.deepl-sync.yaml", + "sourceLocale": "en", + "targetLocales": ["de", "fr"], + "keys": 128 + } +} +``` + +**Casing convention:** CLI JSON output uses `camelCase`; the on-disk `.deepl-sync.lock` and `.deepl-sync.yaml` use `snake_case`. The two are deliberately kept separate — JSON output is a consumer contract; the files are authored configuration. + +**Examples:** + +```bash +deepl sync status + +# JSON output +deepl sync status --format json +``` + +**Sample output:** + +``` +Source: en (142 keys) + + de [###################.] 98% (2 missing, 0 outdated) + fr [####################] 100% (0 missing, 0 outdated) + es [###################.] 97% (4 missing, 0 outdated) + ja [##################..] 91% (12 missing, 0 outdated) +``` + +##### `validate` + +Check translations for placeholder integrity and format consistency. + +**Options:** + +- `--locale LANGS` - Validate specific locales only +- `--format FORMAT` - Output format: `text` (default), `json` +- `--sync-config PATH` - Path to `.deepl-sync.yaml` + +**Examples:** + +```bash +deepl sync validate + +# Validate only German +deepl sync validate --locale de +``` + +**Sample output:** + +``` +Validation Results: + + de: + ✓ 138/140 strings valid + ✗ 2 issues found: + - messages.welcome: placeholder {name} missing in translation + - errors.count: format specifier %d replaced with %s + + fr: + ✓ 142/142 strings valid +``` + +##### `audit` + +Analyze translation consistency and detect terminology inconsistencies across target locales. "Audit" here means translation-consistency audit (detecting term divergence across locales), not security audit in the `npm audit` sense. + +**Options:** + +- `--format FORMAT` - Output format: `text` (default), `json` +- `--sync-config PATH` - Path to `.deepl-sync.yaml` + +**Note:** Prior to the 1.0.0 release, this subcommand was named `glossary-report`. The old name is rejected with an error pointing at the new form; no alias is kept. + +**JSON output sample:** + +```json +{ + "totalTerms": 1, + "inconsistencies": [ + { + "sourceText": "Dashboard", + "locale": "de", + "translations": ["Armaturenbrett", "Dashboard"], + "files": ["locales/en/common.json", "locales/en/admin.json"] + } + ] +} +``` + +The `translations` array contains the actual translated strings read from target files. If a target file is missing, the content hash falls back in its place. + +##### `export` + +Export source strings to XLIFF 1.2 for CAT tool handoff. + +**Options:** + +- `--locale LANGS` - Filter by locale (comma-separated) +- `--output PATH` - Write to file instead of stdout. Path must stay within the project root; intermediate directories are created automatically +- `--overwrite` - Required to overwrite an existing `--output` file. Without it, an existing file causes a non-zero exit and no write occurs +- `--sync-config PATH` - Path to `.deepl-sync.yaml` + +**Examples:** + +```bash +# Print XLIFF to stdout (pipe to CAT tool, clipboard, etc.) +deepl sync export + +# Write to a file (creates reports/ if needed) +deepl sync export --output reports/handoff.xlf + +# Overwrite an existing file +deepl sync export --output reports/handoff.xlf --overwrite + +# Rejected: path escapes the project root +deepl sync export --output ../elsewhere.xlf +``` + +##### `resolve` + +Resolve git merge conflicts in `.deepl-sync.lock`. + +**Options:** + +- `--format FORMAT` - Output format: `text` (default), `json` +- `--dry-run` - Preview conflict decisions without writing the lockfile +- `--sync-config PATH` - Path to `.deepl-sync.yaml` + +**JSON success envelope (stable across 1.x):** `{ "ok": true, "resolved": , "decisions": [...] }` + +##### `push` + +Push local translations to a TMS for human review. + +**Options:** + +- `--locale LANGS` - Push specific locales only +- `--format FORMAT` - Output format: `text` (default), `json` +- `--sync-config PATH` - Path to `.deepl-sync.yaml` + +**Requires TMS integration.** Add a `tms:` block to `.deepl-sync.yaml` (at minimum `enabled: true`, `server`, `project_id`) and supply credentials via the `TMS_API_KEY` or `TMS_TOKEN` environment variable. Running `push` without a configured `tms:` block exits 7 (ConfigError). See [docs/SYNC.md#tms-rest-contract](./SYNC.md#tms-rest-contract) for the full field reference and REST contract. + +**JSON success envelope (stable across 1.x):** `{ "ok": true, "pushed": , "skipped": [...] }` + +##### `pull` + +Pull approved translations from a TMS back into local files. + +**Options:** + +- `--locale LANGS` - Pull specific locales only +- `--format FORMAT` - Output format: `text` (default), `json` +- `--sync-config PATH` - Path to `.deepl-sync.yaml` + +**Requires TMS integration.** Add a `tms:` block to `.deepl-sync.yaml` (at minimum `enabled: true`, `server`, `project_id`) and supply credentials via the `TMS_API_KEY` or `TMS_TOKEN` environment variable. Running `pull` without a configured `tms:` block exits 7 (ConfigError). See [docs/SYNC.md#tms-rest-contract](./SYNC.md#tms-rest-contract) for the full field reference and REST contract. + +**JSON success envelope (stable across 1.x):** `{ "ok": true, "pulled": , "skipped": [...] }` + +#### Examples + +**Basic sync:** + +```bash +# Sync all configured locales +deepl sync + +# Preview what would be translated +deepl sync --dry-run +``` + +**CI/CD (frozen mode):** + +```bash +# Fail if translations are out of date (exit code 10) +deepl sync --frozen +``` + +**Locale filtering:** + +```bash +# Sync only German and French +deepl sync --locale de,fr +``` + +**Force re-translation:** + +```bash +# Re-translate everything, ignoring the lockfile +deepl sync --force +``` + +**JSON output for scripting:** + +```bash +deepl sync --format json +deepl sync status --format json +``` + +**`deepl sync --format json` output contract (stable across 1.x):** + +The success payload is written to **stdout** as a single JSON object. The following fields are guaranteed stable and will not be renamed or removed in any 1.x release: + +| Field | Type | Description | +|---|---|---| +| `ok` | `boolean` | `true` if the sync completed without errors | +| `totalKeys` | `number` | Total translation keys discovered across all source files | +| `translated` | `number` | Keys translated during this run (summed across all locales) | +| `skipped` | `number` | Keys skipped (already up-to-date, summed across all locales) | +| `failed` | `number` | Keys that could not be translated (summed across all locales) | +| `targetLocaleCount` | `number` | Number of target locales processed | +| `estimatedCharacters` | `number` | Characters estimated for billing this run | +| `estimatedCost` | `string \| undefined` | Human-readable cost estimate at Pro rate (e.g. `~$0.05`), omitted when zero | +| `rateAssumption` | `"pro"` | Always `"pro"` — cost estimate uses the DeepL Pro per-character rate | +| `dryRun` | `boolean` | `true` when `--dry-run` was passed; no translations were written | +| `perLocale` | `Array<{locale, translated, skipped, failed}>` | Per-locale breakdown; each entry aggregates all files for that locale | + +No other fields appear in the output. Fields not listed above are internal and may change without notice. + +#### Notes + +- The `--frozen` flag makes no API calls. It compares the lockfile against source files and exits with code 10 if any translations are missing or outdated. This is the recommended mode for CI/CD pipelines. +- The lockfile (`.deepl-sync.lock`) should be committed to version control. It enables incremental sync by tracking content hashes. +- The `push` and `pull` subcommands require a TMS that implements the REST contract documented in [docs/SYNC.md](./SYNC.md#tms-rest-contract). All other commands work with the standard DeepL Translation API. +- By default, keys with extracted context are grouped by i18n section and translated in section batches. Use `--no-batch` to force individual per-key context translation. Use `--batch` to force all keys into plain batch calls (no context). +- See [docs/SYNC.md](./SYNC.md) for the complete sync guide including configuration schema, CI/CD recipes, and troubleshooting. + +--- + ### hooks Manage git hooks for translation workflow automation. @@ -1505,6 +1933,53 @@ deepl glossary delete-dictionary abc-123-def-456 fr --- +### tm + +Manage translation memories. TM files are authored and uploaded via the DeepL web UI; this command surfaces the account's TMs so you can copy a name or UUID into a translate or sync invocation without leaving the terminal. + +#### Synopsis + +```bash +deepl tm list [options] +``` + +#### Subcommands + +##### `list` + +List all translation memories on the account. + +**Options:** + +- `--format ` - Output format: `text`, `json` (default: `text`) + +**Output Format (text):** + +- Per-TM: `name (source → target[, target...])` — e.g. `brand-terms (EN → DE, FR, JA)`. Control chars and zero-width codepoints are stripped from the rendered name to prevent a malicious API-returned name from corrupting the terminal via ANSI escape sequences. +- Empty list: `No translation memories found` + +**Output Format (JSON):** + +Raw `TranslationMemory[]` as returned by `GET /v3/translation_memories` — fields: `translation_memory_id`, `name`, `source_language`, `target_languages` (array). + +**Example:** + +```bash +deepl tm list +# brand-terms (EN → DE, FR, JA) +# legal-phrases (EN → FR) + +deepl tm list --format json | jq '.[] | select(.name == "brand-terms") | .translation_memory_id' +# "3f2504e0-4f89-41d3-9a0c-0305e82c3301" +``` + +**Related:** + +- `deepl translate --translation-memory ` — use a listed TM on a single translate call. +- `.deepl-sync.yaml` `translation.translation_memory` — configure a TM for a sync run (see [sync](#sync)). + +--- + ### cache Manage translation cache. @@ -2267,96 +2742,6 @@ Existing `~/.deepl-cli/` installations continue to work with no changes needed. --- -## Exit Codes - -The CLI uses semantic exit codes to enable intelligent error handling in scripts and CI/CD pipelines. - -| Code | Meaning | Description | Retryable | -| ---- | -------------------- | -------------------------------------------------------------- | --------- | -| 0 | Success | Operation completed successfully | N/A | -| 1 | General Error | Unclassified error | No | -| 2 | Authentication Error | Invalid or missing API key | No | -| 3 | Rate Limit Error | Too many requests (HTTP 429) | Yes | -| 4 | Quota Exceeded | Character limit reached (HTTP 456) | No | -| 5 | Network Error | Connection timeout, refused, or service unavailable (HTTP 503) | Yes | -| 6 | Invalid Input | Missing arguments, unsupported format, or validation error | No | -| 7 | Configuration Error | Invalid configuration file or settings | No | -| 8 | Check Failed | Text needs improvement (`deepl write --check`) | No | -| 9 | Voice Error | Voice API error (unsupported plan or session failure) | No | - -**Special Cases:** - -- `deepl write --check`: Exits with 0 if no changes needed, 8 (CheckFailed) if improvements suggested - -**Exit Code Classification:** - -The CLI automatically classifies errors based on error messages and HTTP status codes: - -- **Authentication (2)**: "authentication failed", "invalid api key", "api key not set" -- **Rate Limit (3)**: "rate limit exceeded", "too many requests", HTTP 429. The CLI respects the `Retry-After` header when present, falling back to exponential backoff when absent -- **Quota (4)**: "quota exceeded", "character limit reached", HTTP 456 -- **Network (5)**: "econnrefused", "enotfound", "econnreset", "etimedout", "socket hang up", "network error", "network timeout", "connection refused", "connection reset", "connection timed out", "service temporarily unavailable", HTTP 503 -- **Invalid Input (6)**: "cannot be empty", "not found", "unsupported", "not supported", "invalid", "is required", "expected", "cannot specify both" -- **Configuration (7)**: "config file", "config directory", "configuration file", "configuration error", "failed to load config", "failed to save config", "failed to read config" - -**Trace IDs for Debugging:** - -API error messages include the DeepL `X-Trace-ID` header when available. This trace ID is useful for debugging and when contacting DeepL support: - -```bash -deepl translate "Hello" --to es -# Error: Authentication failed: Invalid API key (Trace ID: abc123-def456-ghi789) -``` - -The trace ID is also accessible programmatically via `DeepLClient.lastTraceId` after any API call. - -**CI/CD Integration:** - -Use exit codes to implement intelligent retry logic in scripts: - -```bash -#!/bin/bash -# Retry on rate limit or network errors only - -deepl translate "Hello" --to es -EXIT_CODE=$? - -case $EXIT_CODE in - 0) - echo "Success" - ;; - 3|5) - echo "Retryable error (code $EXIT_CODE), retrying in 5 seconds..." - sleep 5 - deepl translate "Hello" --to es - ;; - *) - echo "Non-retryable error (code $EXIT_CODE)" - exit $EXIT_CODE - ;; -esac -``` - -**Checking Exit Codes:** - -```bash -# Check if translation succeeded -if deepl translate "Hello" --to es; then - echo "Translation succeeded" -else - EXIT_CODE=$? - echo "Translation failed with exit code: $EXIT_CODE" -fi - -# Handle specific errors -deepl translate "Hello" --to invalid -if [ $? -eq 6 ]; then - echo "Invalid input provided" -fi -``` - ---- - ## Environment Variables ### `DEEPL_API_KEY` @@ -2416,6 +2801,229 @@ When set to `dumb`, disables colored output and progress spinners. This is autom export TERM=dumb ``` +### `HTTP_PROXY` + +Route outbound DeepL API requests through an HTTP proxy. Accepts a full URL including optional `username:password@` credentials. Also recognized as lowercase `http_proxy`. + +```bash +export HTTP_PROXY="http://proxy.example.com:3128" +``` + +### `HTTPS_PROXY` + +Route outbound DeepL API requests through an HTTPS proxy. Takes precedence over `HTTP_PROXY` when both are set. Also recognized as lowercase `https_proxy`. + +```bash +export HTTPS_PROXY="http://proxy.example.com:3128" +``` + +### `TMS_API_KEY` + +API key used by `deepl sync push` and `deepl sync pull` to authenticate against the external translation management system configured under `tms.server` in `.deepl-sync.yaml`. See [docs/SYNC.md](SYNC.md) for setup details. + +```bash +export TMS_API_KEY="your-tms-api-key" +``` + +### `TMS_TOKEN` + +Bearer token alternative to `TMS_API_KEY`. Used by `deepl sync push` and `deepl sync pull` when the configured TMS server expects token-based auth. See [docs/SYNC.md](SYNC.md) for setup details. + +```bash +export TMS_TOKEN="your-tms-token" +``` + +--- + +## Exit Codes + +Every `deepl` command returns a specific exit code so CI/CD pipelines and shell scripts can react programmatically to failure modes. This appendix is the single source of truth for every code the CLI emits; per-command sections above surface exit codes inline where a flag has a code-specific contract (for example, `sync --frozen` exits 10 on drift). + +Exit codes come from three paths: + +1. **Typed errors** thrown in services, API clients, and commands subclass `DeepLCLIError`, each carrying a fixed `exitCode`. The CLI's top-level `handleError` uses that value directly. +2. **HTTP responses** from the DeepL API are mapped to typed errors inside the HTTP client (401 → `AuthError`, 429 → `RateLimitError`, 456 → `QuotaError`, 503 → `NetworkError`). +3. **Untyped errors** (plain `Error` instances that escape service boundaries) are classified by message against a curated list of substrings. When nothing matches, the CLI returns `1` (general error). + +Retryable codes are `3` (rate limit) and `5` (network); everything else should be treated as fatal by calling scripts. + +### Quick reference + +| Code | Name | Meaning | Retryable | +| ---- | -------------- | -------------------------------------------------------------- | --------- | +| 0 | Success | Command completed successfully | N/A | +| 1 | GeneralError / PartialFailure | Unclassified failure, or partial sync failure (some locales succeeded, some failed) | No | +| 2 | AuthError | Authentication failed or API key missing | No | +| 3 | RateLimitError | Rate limit exceeded (HTTP 429) | Yes | +| 4 | QuotaError | Monthly character quota exhausted (HTTP 456) | No | +| 5 | NetworkError | Connection timeout, refused, reset, or 503 Service Unavailable | Yes | +| 6 | InvalidInput | Missing or malformed arguments, unsupported format | No | +| 7 | ConfigError | Configuration file or value invalid | No | +| 8 | CheckFailed | A check-style command found actionable issues | No | +| 9 | VoiceError | Voice API unavailable or session failed | No | +| 10 | SyncDrift | `sync --frozen` detected translations out of date | No | +| 11 | SyncConflict | `sync resolve` could not auto-resolve lockfile conflicts | No | + +### Code details + +#### 0 — Success + +Command completed without error. Every `deepl` subcommand uses this code on success. Do not rely on stdout being non-empty — successful commands may emit only a status line (e.g., `deepl cache clear`). + +#### 1 — GeneralError / PartialFailure + +Two distinct cases share this code: + +1. **Unclassified failure** (`GeneralError`): emitted when an error escapes every typed handler and matches none of the message-classification heuristics in `src/utils/exit-codes.ts`. Any command can surface this. Treat it as "unknown failure — inspect stderr." Typically indicates an unexpected CLI bug or an error from a third-party dependency. + +2. **Partial sync failure** (`PartialFailure`): emitted by `deepl sync` when at least one locale failed and at least one locale succeeded. The successful locales' target files and lockfile entries are written; the failed locales' files are not touched. Re-running `deepl sync` (with or without `--locale`) will retry only the failed locales. Authentication failures (401/403) abort the entire run and surface as exit code 2 instead of 1. + +CI scripts should treat exit code 1 from `deepl sync` as partial failure and inspect the per-locale summary in stderr to determine which locales need attention. + +#### 2 — AuthError + +Authentication failed or no API key is available. Emitted by: + +- `deepl auth set-key`, `deepl auth test` when the key cannot be validated +- Every command that touches the API (`translate`, `write`, `voice`, `glossary`, `usage`, `sync`, `tm list`, `admin`, etc.) when `DEEPL_API_KEY` is unset and no key is in the config file +- HTTP 401/403 responses from the DeepL API + +Remediation: run `deepl init` or `deepl auth set-key `, or export `DEEPL_API_KEY`. + +#### 3 — RateLimitError + +Too many requests in too short a window. Emitted when the DeepL API returns HTTP 429 from any endpoint (`/v2/translate`, `/v2/write`, `/v3/glossaries`, `/v3/translation_memories`, document upload/download, voice session). The CLI honors the `Retry-After` header when the server sends one, otherwise it falls back to exponential backoff for in-process retries. When all internal retries are exhausted, this code is returned to the caller. + +Remediation: wait and retry, or lower concurrency with `--concurrency` (batch translation, `sync`). + +#### 4 — QuotaError + +Monthly character quota has been exhausted (HTTP 456). Emitted by any command that consumes characters: `translate`, `write`, `voice`, and `sync`. Unlike rate limits, quota is not retryable within the billing window. + +Remediation: run `deepl usage` to see remaining characters, or upgrade the plan at . + +#### 5 — NetworkError + +Connection-layer failure or transient server outage. Covers TCP errors (`ECONNREFUSED`, `ENOTFOUND`, `ECONNRESET`, `ETIMEDOUT`, socket hang up), timeouts, proxy misconfigurations, and HTTP 503 responses. Also emitted for malformed or empty API responses thrown from `src/api/translation-client.ts` and `src/api/write-client.ts`, and from document/structured-file translation when the polling response is unparseable. + +Remediation: check connectivity and `HTTPS_PROXY` / `HTTP_PROXY` env vars, then retry. + +#### 6 — InvalidInput + +User-supplied input was rejected by client-side validation before any API call. This is the most commonly emitted non-zero code. Sites include: + +- `translate`: empty text, missing `--to`, unsupported file format, invalid `--tm-threshold` range, `--tm-threshold` without `--translation-memory`, `--translation-memory` without `--from`, mutually exclusive flags +- `write`: empty text, `--style` and `--tone` used together, `--fix` without a file path, unsupported language for the Write API +- `voice`: missing target languages, unsupported plan (pre-flight check), invalid session parameters +- `glossary`: missing name/entries, entry not found on delete +- `sync`: `--frozen` combined with `--force`, missing `.deepl-sync.yaml` (before `ConfigError` hands off) +- `hooks`, `watch`, `detect`, `admin`, `init`, `completion`, `cache`: argument parsing, unknown subcommand, bad path, bad size + +Remediation: re-read the command's `--help` and the relevant section of this API reference. + +#### 7 — ConfigError + +The configuration file or a configuration value is invalid. Emitted by: + +- `deepl config set` with a key that is not in the schema, or a value that fails validation (invalid language code, invalid formality, invalid output format, non-positive cache size, non-HTTPS `baseUrl`, path-traversal attempts) +- `deepl config get/unset` with a malformed key +- Any command that loads the config file when the file fails to parse, is missing a required field, or specifies an unsupported version +- `sync` when `.deepl-sync.yaml` is missing required fields, has invalid locales, or declares an unsupported version +- `sync push` / `sync pull` when the remote TMS returns 401/403 (surfaced as `ConfigError` with a hint to check `TMS_API_KEY` / `TMS_TOKEN` and the relevant YAML fields) +- `glossary` when a named glossary cannot be resolved + +Remediation: run `deepl config get` to inspect the current config, or edit the file directly and re-run. + +#### 8 — CheckFailed + +A check-style command ran successfully but found actionable issues. Exit is *soft* — `process.exitCode` is set so cleanup still runs. Emitted by: + +- `deepl write --check ` when the Write API would suggest changes (`needsImprovement === true`) +- `deepl sync validate` when validation surfaces one or more `error`-severity issues (missing placeholders, format-string mismatches, unbalanced HTML tags) + +This code is specifically designed for CI use: a `check` step can block a merge without requiring try/catch wrappers in the calling script. It does **not** indicate a CLI failure. + +#### 9 — VoiceError + +Voice API call failed for a reason other than authentication, rate limiting, or generic network trouble. Emitted by: + +- `deepl voice` when the plan does not include the Voice API (pre-flight check in the voice client) +- Voice streaming URL validation failures (`src/api/voice-client.ts`: non-`wss://` scheme, unparseable URL, disallowed host) +- Voice session lifecycle errors (failed to open, unexpected close) + +Remediation: confirm Pro/Enterprise plan, verify the session configuration, and retry. + +#### 10 — SyncDrift + +`deepl sync --frozen` (alias `--ci`) detected that lockfile-tracked translations are out of date with the source strings. Emitted only from `src/cli/commands/register-sync.ts` when the sync run completes and `result.driftDetected === true`. No other command returns this code. + +Use this in CI to fail a pull request when a contributor edits source strings without running `deepl sync`. Note that `SyncDriftError` is defined in `src/utils/errors.ts` as an alias for this code, but the CLI currently exits directly via `process.exit(ExitCode.SyncDrift)` rather than throwing. + +Remediation: run `deepl sync` locally and commit the updated translations and lockfile. + +#### 11 — SyncConflict + +`deepl sync resolve` found git merge conflict markers in `.deepl-sync.lock` but could not automatically resolve every region. Emitted only by `sync resolve` when auto-resolution leaves residual conflict markers or produces invalid JSON (typically because the conflict region split a JSON entry in two and neither side parses in isolation). + +Distinct from exit code 1 (GeneralError): a pipeline that runs `deepl sync resolve` in CI can now branch on `11` to route the lockfile to a human for manual merge without masking real CLI crashes under the same code. + +Remediation: open `.deepl-sync.lock`, resolve the remaining `<<<<<<<` / `=======` / `>>>>>>>` regions by hand, save, and run `deepl sync` to fill any gaps. + +### Classification heuristics (fallback) + +When an error reaches the top-level handler without being a `DeepLCLIError`, the CLI inspects the error message (lowercased) and maps it to a code. These substring matches live in `classifyByMessage` in `src/utils/exit-codes.ts`: + +- **2 — AuthError**: `authentication failed`, `invalid api key`, `api key not set`, `api key is required` +- **3 — RateLimitError**: `rate limit exceeded`, `too many requests`, `\b429\b` +- **4 — QuotaError**: `quota exceeded`, `character limit reached`, `\b456\b` +- **5 — NetworkError**: `econnrefused`, `enotfound`, `econnreset`, `etimedout`, `socket hang up`, `network error`, `network timeout`, `connection refused`, `connection reset`, `connection timed out`, `service temporarily unavailable`, `\b503\b` +- **7 — ConfigError** (checked before 6 because config messages may contain "invalid"): `config file`, `config directory`, `configuration file`, `configuration error`, `failed to load config`, `failed to save config`, `failed to read config` +- **6 — InvalidInput**: `cannot be empty`, `file not found`, `path not found`, `directory not found`, `not found in glossary`, `unsupported format`, `unsupported language`, `not supported for`, `not supported in`, `invalid target language`, `invalid source language`, `invalid language code`, `invalid glossary`, `invalid hook`, `invalid url`, `invalid size`, `is required`, `cannot specify both` +- **9 — VoiceError**: `voice api`, `voice session` +- **1 — GeneralError**: anything not matched above + +### Trace IDs + +API error messages include the DeepL `X-Trace-ID` header when available, which is useful when contacting DeepL support: + +```bash +deepl translate "Hello" --to es +# Error: Authentication failed: Invalid API key (Trace ID: abc123-def456-ghi789) +``` + +The trace ID is also accessible programmatically via `DeepLClient.lastTraceId` after any API call. + +### Shell handling examples + +Retry only on retryable codes (`3` and `5`): + +```bash +#!/bin/bash +deepl translate "Hello" --to es +case $? in + 0) echo "Success" ;; + 3|5) sleep 5 && deepl translate "Hello" --to es ;; + *) echo "Non-retryable error ($?)"; exit $? ;; +esac +``` + +Fail a CI build when translations drift: + +```bash +deepl sync --frozen || { + code=$? + [ $code -eq 10 ] && echo "::error::Translation drift — run 'deepl sync' locally" >&2 + exit $code +} +``` + +Block a merge when `deepl write --check` flags a file: + +```bash +deepl write --check README.md +[ $? -eq 8 ] && echo "Write suggests improvements; run: deepl write --fix README.md" >&2 +``` + --- ## See Also @@ -2425,5 +3033,5 @@ export TERM=dumb --- -**Last Updated**: February 17, 2026 -**DeepL CLI Version**: 1.0.0 +**Last Updated**: April 20, 2026 +**DeepL CLI Version**: 1.1.0 diff --git a/docs/SYNC.md b/docs/SYNC.md new file mode 100644 index 0000000..1c7aab2 --- /dev/null +++ b/docs/SYNC.md @@ -0,0 +1,973 @@ +# DeepL Sync -- Continuous Localization Engine + +> Scan, translate, and sync i18n files from the command line. + +## Table of Contents + +- [Overview](#overview) +- [Quick Start](#quick-start) +- [How It Works](#how-it-works) +- [Supported File Formats](#supported-file-formats) +- [Configuration](#configuration) +- [Commands](#commands) +- [Stability & deprecation](#stability--deprecation) +- [CI/CD Integration](#cicd-integration) +- [Troubleshooting](#troubleshooting) +- [Exit Codes](#exit-codes) +- [Further Reading](#further-reading) + +## Overview + +`deepl sync` is a continuous localization engine that keeps your project's translation files in sync with your source strings. It scans your project for i18n resource files, detects new and changed strings using a lockfile, sends only the delta to the DeepL API, and writes back properly formatted target files -- preserving indentation, comments, and format-specific conventions. This replaces the manual export/translate/import cycle with a single command that fits into both local development and CI/CD pipelines. + +## Quick Start + +### Prerequisites + +- DeepL API key (`deepl auth set-key YOUR_KEY` or `DEEPL_API_KEY` env var) +- Project with i18n resource files (JSON, YAML, TOML, PO, Android XML, iOS Strings, ARB, XLIFF, Java Properties, Xcode String Catalog, or Laravel PHP arrays) + +### First Sync in 30 Seconds + +```bash +# 1. Initialize (auto-detects your project's i18n framework) +deepl sync init --source-locale en --target-locales de,fr --file-format json --path "locales/en.json" + +# 2. Preview what would be translated +deepl sync --dry-run + +# 3. Translate +deepl sync +``` + +## How It Works + +1. **Scan** -- finds i18n files matching bucket patterns in `.deepl-sync.yaml` +2. **Diff** -- compares source strings against `.deepl-sync.lock` to find new/changed/deleted keys +3. **Translate** -- sends only new and changed strings to the DeepL API +4. **Write** -- reconstructs target files preserving format, indentation, and comments +5. **Lock** -- updates `.deepl-sync.lock` with translation hashes for incremental sync + +The lockfile tracks content hashes for every source string. On subsequent runs, only strings whose hash has changed (or that are newly added) are sent to DeepL. Deleted keys are removed from target files. This makes sync fast and cost-efficient -- you only pay for what actually changed. + +All sync commands (`sync`, `sync push`, `sync pull`, `sync export`, `sync validate`) refuse to follow symbolic links when scanning `include` globs. A symlink matching a bucket pattern is silently skipped, preventing a hostile symlink (e.g., `locales/en.json` -> `/etc/passwd`) from exfiltrating files outside the project root to the TMS server or into an exported XLIFF. + +### Concurrent sync + +Only one `deepl sync` run is supported at a time per project directory. At startup, sync writes a `.deepl-sync.lock.pidfile` containing its PID; a second invocation that sees an existing pidfile whose PID is still alive exits with `ConfigError` (exit code 7). If the PID is dead (e.g., a previous run crashed), sync removes the stale pidfile with a warning and proceeds. The pidfile is deleted automatically on normal completion and on SIGINT/SIGTERM. + +## Supported File Formats + +| Format | Extensions | Used By | +|--------|-----------|---------| +| JSON (i18n) | `.json` | i18next, react-intl, vue-i18n, next-intl | +| YAML | `.yaml`, `.yml` | Rails, Hugo, Symfony | +| TOML | `.toml` | Go go-i18n | +| Gettext PO | `.po`, `.pot` | Django, WordPress, LinguiJS | +| Android XML | `.xml` | Android (`strings.xml`) | +| iOS Strings | `.strings` | iOS, macOS (`Localizable.strings`) | +| Xcode String Catalog | `.xcstrings` | iOS, macOS (`Localizable.xcstrings`) — multi-locale | +| ARB | `.arb` | Flutter, Dart | +| XLIFF | `.xlf`, `.xliff` | Angular, Xcode, enterprise CAT tools | +| Java Properties | `.properties` | Java, Spring (`ResourceBundle`) | +| Laravel PHP arrays | `.php` | Laravel (`lang/**/*.php`, `resources/lang/**/*.php`) | + +All parsers preserve format-specific metadata: + +- **JSON**: nested key structure, indentation style, trailing newlines +- **YAML**: comments, anchors, flow/block style +- **PO**: translator comments, flags, plural forms, msgctxt +- **Android XML**: `translatable="false"` attributes, comments, string-arrays, plurals +- **iOS Strings**: comments, ordering, escape sequences +- **Xcode String Catalog**: per-locale `stringUnit` state, comments, multi-locale structure +- **ARB**: `@key` metadata (description, placeholders, type) +- **XLIFF**: `` elements, state attributes, translation units +- **TOML**: `#` comments, blank lines between sections, key order within a section, per-value quote style (double-quoted vs literal single-quoted), irregular whitespace around `=`, and every byte outside a replaced string literal round-trip verbatim via span-surgical reconstruct. Multi-line triple-quoted strings are passed through as-is (not translated). +- **Properties**: comments, Unicode escapes (`\uXXXX`), line continuations, separator style +- **Laravel PHP arrays**: PHPDoc/line/block comments, quote style (single vs double), trailing commas, irregular whitespace, and every byte outside a replaced string literal round-trip verbatim. Span-surgical reconstruct — the AST is used for string-literal offsets only, never reprinted. Allowlist rejects double-quoted interpolation (`"Hello $name"`), heredoc, nowdoc, and string concatenation; Laravel pipe-pluralization values (`|{n}` / `|[n,m]` / `|[n,*]`) are excluded from the translation batch and counted separately in `deepl sync status`. + +The sync engine also supports **multi-locale formats** where all locales are stored in a single file (e.g., Apple `.xcstrings`). For these formats, the engine automatically serializes locale writes to prevent race conditions and passes the locale to the parser so it can scope extract/reconstruct operations to the correct locale section. + +## Configuration + +### `.deepl-sync.yaml` + +This file defines what `deepl sync` translates. It lives in your project root and should be committed to version control. + +```yaml +version: 1 +source_locale: en +target_locales: + - de + - fr + - es + - ja + +buckets: + json: + include: + - "locales/en.json" + - "locales/en/*.json" + exclude: + - "locales/en/generated.json" + +translation: + formality: default + model_type: prefer_quality_optimized + glossary: my-project-terms + translation_memory: my-tm + translation_memory_threshold: 80 + instruction_templates: + button: "Keep translation concise, maximum 3 words." + th: "Table column header. Maximum 2 words." + +context: + enabled: true + scan_paths: + - "src/**/*.{ts,tsx}" + overrides: + save: "Save button in document editor toolbar" + close: "Close button in modal dialog" +``` + +### Schema Reference + +**Validation.** Unknown fields are rejected at every nesting level (top-level, buckets, translation, context, validation, sync, tms, locale_overrides) with a `ConfigError` (exit 7). Typos produce a did-you-mean hint pointing at the closest known field — for example, `target_locale: en` (singular) reports `Unknown field "target_locale" in .deepl-sync.yaml top level` with the hint `Did you mean "target_locales"?`. + +#### Top-level fields + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `version` | `number` | Yes | -- | Config schema version (currently `1`) | +| `source_locale` | `string` | Yes | -- | BCP-47 source language code (e.g., `en`, `de`, `ja`) | +| `target_locales` | `string[]` | Yes | -- | List of target language codes | + +#### `buckets` + +Each bucket maps a format name to a set of file patterns. The format name must be one of: `json`, `yaml`, `toml`, `po`, `android_xml`, `ios_strings`, `xcstrings`, `arb`, `xliff`, `properties`, `laravel_php`. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `include` | `string[]` | Yes | Glob patterns for source files to include | +| `exclude` | `string[]` | No | Glob patterns for source files to exclude | +| `target_path_pattern` | `string` | No | Template for target file paths. Use `{locale}` for the target locale and `{basename}` for the source filename. Required for formats where the source locale is not in the source file path (e.g., Android XML, XLIFF). | +| `key_style` | `string` | No | Key format: `nested` (dot-separated keys become nested objects) or `flat` (keys preserved as-is). | + +#### `translation` + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `formality` | `string` | No | `default` | Formality level: `default`, `more`, `less`, `prefer_more`, `prefer_less`, `formal`, `informal` | +| `model_type` | `string` | No | `prefer_quality_optimized` | Model type: `quality_optimized`, `latency_optimized`, `prefer_quality_optimized` | +| `glossary` | `string` | No | -- | Glossary name or ID, or `auto` for automatic glossary management | +| `translation_memory` | `string` | No | -- | Translation memory name or UUID. Requires `model_type: quality_optimized`. Invalid pairing rejected at config load (ConfigError, exit 7). See [Translation memory](#translation-memory). | +| `translation_memory_threshold` | `number` | No | `75` | Minimum match score 0–100 (requires `translation_memory`). Non-integer or out-of-range values exit 7 (ConfigError). | +| `custom_instructions` | `string[]` | No | -- | Custom instructions passed to the DeepL API | +| `style_id` | `string` | No | -- | Style ID for consistent translation style | +| `locale_overrides` | `object` | No | -- | Per-locale overrides for `formality`, `glossary`, `translation_memory`, `translation_memory_threshold`, `custom_instructions`, `style_id` | +| `instruction_templates` | `object` | No | -- | Per-element-type instruction templates. Built-in defaults cover 16 element types: `button`, `a`, `h1`-`h6`, `th`, `label`, `option`, `input`, `title`, `summary`, `legend`, `caption`. User-provided templates override defaults. Only effective for locales supporting custom instructions: DE, EN, ES, FR, IT, JA, KO, ZH. See [Translation Strategies](#translation-strategies). | +| `length_limits.enabled` | `boolean` | No | `false` | Enable length-aware translation instructions. Adds "Keep under N characters" per key based on source text length and locale expansion factors. Only applies to length-constrained element types (button, th, label, option, input, title) for keys sent via per-key API calls. | +| `length_limits.expansion_factors` | `object` | No | built-in defaults | Per-locale expansion factors relative to English source. Built-in defaults: DE 1.3, FR 1.3, ES 1.25, JA 0.5, KO 0.7, ZH 0.5, etc. Based on industry-standard approximations (IBM, W3C). User-overridable. | + +##### `glossary: auto` + +Setting `translation.glossary: auto` enables automatic project glossaries. Each time `deepl sync` runs, the engine scans completed translations for source terms that appear in at least three distinct keys with a consistent translation across all of them, and creates (or updates) a DeepL glossary per target locale named `deepl-sync-{source}-{target}`. On the first run the glossary is created; on subsequent runs the existing glossary is found by name and its entries are replaced with the freshly computed set in a single `PATCH /v3/glossaries/{id}` call per locale — rather than one API call per added or removed term. The glossary list response is also cached for the duration of a sync run, so multi-locale projects issue one `GET /v3/glossaries` lookup total instead of one per locale. The resulting `glossary_id` is stored in the lockfile under `glossary_ids` (keyed by `{source}-{target}` pair) so you can see which glossary the engine is tracking. Only source terms of 50 characters or fewer are considered. + +##### Translation memory + +Set `translation.translation_memory` to a translation memory name or UUID to reuse approved translations across a sync run. Translation memories are authored and uploaded through the DeepL web UI; the CLI never creates or edits them. Names are resolved to UUIDs once via `GET /v3/translation_memories` and cached for the remainder of the invocation, so a multi-locale sync issues at most one list call per unique name. TM composes with glossary — both `glossary_id` and `translation_memory_id` are sent on the same translate call when both are configured. + +Translation memories require `model_type: quality_optimized`. Set `model_type: quality_optimized` at the same scope as `translation_memory` (top-level `translation.model_type`, or the matching per-locale override). Other values are rejected at config load with `ConfigError` (exit 7), before any API call is made. Threshold propagates from YAML into each translate request (default 75, range 0–100); `translation_memory_threshold` without `translation_memory` is inert. Per-locale `locale_overrides..translation_memory` takes precedence over the top-level `translation.translation_memory`; `locale_overrides..translation_memory_threshold` falls back to the top-level threshold when unset. See [Is translation memory actually being applied?](#is-translation-memory-actually-being-applied) for verification steps. + +#### `context` + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `enabled` | `boolean` | No | `false` | Enable auto-context extraction from source code | +| `scan_paths` | `string[]` | No | `['src/**/*.{ts,tsx,js,jsx}']` | Glob patterns for source files to scan for context | +| `function_names` | `string[]` | No | `['t', 'i18n.t', '$t', 'intl.formatMessage']` | Function names to search for key usage. Both string literal calls (`t('key')`) and template literal calls (`` t(`prefix.${var}`) ``) are matched. | +| `context_lines` | `number` | No | `3` | Number of surrounding code lines to include as context | +| `overrides` | `object` | No | -- | Manual context strings per key (e.g., `save: "Save button in toolbar"`). Overrides auto-extracted context. | + +Template literal calls like `` t(`features.${key}.title`) `` are resolved against the known keys in your source locale files. The interpolation is treated as a wildcard, so the pattern `features.*.title` matches `features.incremental.title`, `features.multiformat.title`, etc. Each matched key inherits the surrounding source code as context. This is useful for idiomatic React/Vue i18n patterns that iterate over keys dynamically. + +When context scanning is enabled, two additional signals are automatically extracted: + +- **Key path context**: The i18n key hierarchy (e.g., `pricing.free.cta`) is parsed into a natural-language description (`"Call-to-action in the pricing > free section."`) and prepended to the context string sent to the API. This helps the API disambiguate short strings like "Save" (verb vs noun). + +- **Element type detection**: The HTML/JSX element type surrounding each `t()` call (e.g., ` + + + `, +}); diff --git a/tests/fixtures/sync/error-envelopes/config-error.json b/tests/fixtures/sync/error-envelopes/config-error.json new file mode 100644 index 0000000..ed0fd32 --- /dev/null +++ b/tests/fixtures/sync/error-envelopes/config-error.json @@ -0,0 +1,9 @@ +{ + "ok": false, + "error": { + "code": "ConfigError", + "message": ".deepl-sync.yaml not found in current directory or any parent", + "suggestion": "Run `deepl sync init` to create one." + }, + "exitCode": 7 +} diff --git a/tests/fixtures/sync/error-envelopes/init-success.json b/tests/fixtures/sync/error-envelopes/init-success.json new file mode 100644 index 0000000..852e1aa --- /dev/null +++ b/tests/fixtures/sync/error-envelopes/init-success.json @@ -0,0 +1,9 @@ +{ + "ok": true, + "created": { + "configPath": "/absolute/path/to/.deepl-sync.yaml", + "sourceLocale": "en", + "targetLocales": ["de", "fr"], + "keys": 128 + } +} diff --git a/tests/fixtures/sync/error-envelopes/sync-conflict.json b/tests/fixtures/sync/error-envelopes/sync-conflict.json new file mode 100644 index 0000000..db95f8e --- /dev/null +++ b/tests/fixtures/sync/error-envelopes/sync-conflict.json @@ -0,0 +1,9 @@ +{ + "ok": false, + "error": { + "code": "SyncConflict", + "message": "Could not automatically resolve lock file conflicts. Manual resolution required.", + "suggestion": "Edit .deepl-sync.lock to resolve conflict markers manually, then run `deepl sync` to fill gaps." + }, + "exitCode": 11 +} diff --git a/tests/fixtures/sync/error-envelopes/validation-error.json b/tests/fixtures/sync/error-envelopes/validation-error.json new file mode 100644 index 0000000..5104cae --- /dev/null +++ b/tests/fixtures/sync/error-envelopes/validation-error.json @@ -0,0 +1,9 @@ +{ + "ok": false, + "error": { + "code": "ValidationError", + "message": "All four flags (--source-locale, --target-locales, --file-format, --path) are required when stdin is not a TTY.", + "suggestion": "Provide all four flags for non-interactive use (CI, piped shells), or run in an interactive terminal." + }, + "exitCode": 6 +} diff --git a/tests/fixtures/sync/formats/android-xml/strings-arrays.xml b/tests/fixtures/sync/formats/android-xml/strings-arrays.xml new file mode 100644 index 0000000..44dda8c --- /dev/null +++ b/tests/fixtures/sync/formats/android-xml/strings-arrays.xml @@ -0,0 +1,13 @@ + + + My App + + Red + Green + Blue + + + Mercury + Venus + + diff --git a/tests/fixtures/sync/formats/android-xml/strings-complex.xml b/tests/fixtures/sync/formats/android-xml/strings-complex.xml new file mode 100644 index 0000000..c3673a5 --- /dev/null +++ b/tests/fixtures/sync/formats/android-xml/strings-complex.xml @@ -0,0 +1,11 @@ + + + + My App + com.example.app + Welcome to My App + It\'s a \"test\" with\nnewline + bold & italic]]> + + Terms & Conditions + diff --git a/tests/fixtures/sync/formats/android-xml/strings-empty.xml b/tests/fixtures/sync/formats/android-xml/strings-empty.xml new file mode 100644 index 0000000..045e125 --- /dev/null +++ b/tests/fixtures/sync/formats/android-xml/strings-empty.xml @@ -0,0 +1,3 @@ + + + diff --git a/tests/fixtures/sync/formats/android-xml/strings-plurals.xml b/tests/fixtures/sync/formats/android-xml/strings-plurals.xml new file mode 100644 index 0000000..c48cbc3 --- /dev/null +++ b/tests/fixtures/sync/formats/android-xml/strings-plurals.xml @@ -0,0 +1,13 @@ + + + My App + + %d item + %d items + + + No messages + %d message + %d messages + + diff --git a/tests/fixtures/sync/formats/android-xml/strings.xml b/tests/fixtures/sync/formats/android-xml/strings.xml new file mode 100644 index 0000000..d7c84bf --- /dev/null +++ b/tests/fixtures/sync/formats/android-xml/strings.xml @@ -0,0 +1,7 @@ + + + My App + Hello + Goodbye + Welcome to our app + diff --git a/tests/fixtures/sync/formats/arb/app_en-minimal.arb b/tests/fixtures/sync/formats/arb/app_en-minimal.arb new file mode 100644 index 0000000..e5f71aa --- /dev/null +++ b/tests/fixtures/sync/formats/arb/app_en-minimal.arb @@ -0,0 +1,4 @@ +{ + "@@locale": "en", + "title": "My App" +} diff --git a/tests/fixtures/sync/formats/arb/app_en-no-metadata.arb b/tests/fixtures/sync/formats/arb/app_en-no-metadata.arb new file mode 100644 index 0000000..b59e5ae --- /dev/null +++ b/tests/fixtures/sync/formats/arb/app_en-no-metadata.arb @@ -0,0 +1,5 @@ +{ + "greeting": "Hello", + "farewell": "Goodbye", + "title": "My App" +} diff --git a/tests/fixtures/sync/formats/arb/app_en-plurals.arb b/tests/fixtures/sync/formats/arb/app_en-plurals.arb new file mode 100644 index 0000000..443a26f --- /dev/null +++ b/tests/fixtures/sync/formats/arb/app_en-plurals.arb @@ -0,0 +1,21 @@ +{ + "@@locale": "en", + "itemCount": "{count, plural, =0{No items} one{{count} item} other{{count} items}}", + "@itemCount": { + "description": "Cart item count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "messageCount": "{count, plural, one{1 message} other{{count} messages}}", + "@messageCount": { + "description": "Number of unread messages", + "placeholders": { + "count": { + "type": "int" + } + } + } +} diff --git a/tests/fixtures/sync/formats/arb/app_en.arb b/tests/fixtures/sync/formats/arb/app_en.arb new file mode 100644 index 0000000..d95c8a2 --- /dev/null +++ b/tests/fixtures/sync/formats/arb/app_en.arb @@ -0,0 +1,26 @@ +{ + "@@locale": "en", + "@@last_modified": "2024-01-15T10:30:00.000Z", + "greeting": "Hello", + "@greeting": { + "description": "Shown on home screen", + "placeholders": { + "userName": { + "type": "String" + } + } + }, + "farewell": "Goodbye", + "@farewell": { + "description": "Shown when user leaves" + }, + "welcomeMessage": "Welcome to our app", + "@welcomeMessage": { + "description": "Welcome banner text", + "placeholders": { + "appName": { + "type": "String" + } + } + } +} diff --git a/tests/fixtures/sync/formats/ios-strings/Localizable-complex.strings b/tests/fixtures/sync/formats/ios-strings/Localizable-complex.strings new file mode 100644 index 0000000..d82ce6b --- /dev/null +++ b/tests/fixtures/sync/formats/ios-strings/Localizable-complex.strings @@ -0,0 +1,14 @@ +/* Main screen title */ +"app.main.title" = "My Application"; + +/* Navigation items */ +"nav.home" = "Home"; +"nav.settings" = "Settings"; + +// Unicode test +"unicode.smiley" = "\U263A"; +"unicode.alpha" = "\U0041\U0042\U0043"; + +/* Key with special characters */ +"error.404.message" = "Page not found"; +"button.ok!" = "OK"; diff --git a/tests/fixtures/sync/formats/ios-strings/Localizable-empty.strings b/tests/fixtures/sync/formats/ios-strings/Localizable-empty.strings new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/fixtures/sync/formats/ios-strings/Localizable-empty.strings @@ -0,0 +1 @@ + diff --git a/tests/fixtures/sync/formats/ios-strings/Localizable-escapes.strings b/tests/fixtures/sync/formats/ios-strings/Localizable-escapes.strings new file mode 100644 index 0000000..35faba2 --- /dev/null +++ b/tests/fixtures/sync/formats/ios-strings/Localizable-escapes.strings @@ -0,0 +1,7 @@ +"escaped_quotes" = "She said \"hello\""; +"escaped_backslash" = "C:\\Users\\test"; +"newline" = "Line one\nLine two"; +"tab" = "Col1\tCol2"; +"carriage_return" = "Before\rAfter"; +"null_char" = "Null\0Here"; +"mixed_escapes" = "Say \"hi\"\nand\\go"; diff --git a/tests/fixtures/sync/formats/ios-strings/Localizable.strings b/tests/fixtures/sync/formats/ios-strings/Localizable.strings new file mode 100644 index 0000000..1de148b --- /dev/null +++ b/tests/fixtures/sync/formats/ios-strings/Localizable.strings @@ -0,0 +1,8 @@ +/* Greeting shown on home screen */ +"greeting" = "Hello"; + +/* Farewell message */ +"farewell" = "Goodbye"; + +/* Welcome message for new users */ +"welcome_message" = "Welcome to our app"; diff --git a/tests/fixtures/sync/formats/json/en-icu.json b/tests/fixtures/sync/formats/json/en-icu.json new file mode 100644 index 0000000..492faf2 --- /dev/null +++ b/tests/fixtures/sync/formats/json/en-icu.json @@ -0,0 +1,4 @@ +{ + "items_count": "{count, plural, one {# item} other {# items}}", + "greeting": "Hello, {name}!" +} diff --git a/tests/fixtures/sync/formats/json/en-nested.json b/tests/fixtures/sync/formats/json/en-nested.json new file mode 100644 index 0000000..d89ce19 --- /dev/null +++ b/tests/fixtures/sync/formats/json/en-nested.json @@ -0,0 +1,14 @@ +{ + "nav": { + "home": "Home", + "about": "About Us", + "contact": "Contact" + }, + "footer": { + "copyright": "All rights reserved", + "links": { + "privacy": "Privacy Policy", + "terms": "Terms of Service" + } + } +} diff --git a/tests/fixtures/sync/formats/json/en-with-metadata.json b/tests/fixtures/sync/formats/json/en-with-metadata.json new file mode 100644 index 0000000..e8a8af8 --- /dev/null +++ b/tests/fixtures/sync/formats/json/en-with-metadata.json @@ -0,0 +1,5 @@ +{ + "save": "Save", + "cancel": "Cancel", + "delete_confirm": "Are you sure you want to delete {item}?" +} diff --git a/tests/fixtures/sync/formats/json/en.json b/tests/fixtures/sync/formats/json/en.json new file mode 100644 index 0000000..3c5a1ce --- /dev/null +++ b/tests/fixtures/sync/formats/json/en.json @@ -0,0 +1,5 @@ +{ + "greeting": "Hello", + "farewell": "Goodbye", + "welcome_message": "Welcome to our app" +} diff --git a/tests/fixtures/sync/formats/laravel-php/01-single-quote-escape.php b/tests/fixtures/sync/formats/laravel-php/01-single-quote-escape.php new file mode 100644 index 0000000..813cb9f --- /dev/null +++ b/tests/fixtures/sync/formats/laravel-php/01-single-quote-escape.php @@ -0,0 +1,6 @@ + 'It\'s a test', + 'backslash' => 'path\\to\\file', +]; diff --git a/tests/fixtures/sync/formats/laravel-php/02-double-quote-escapes.php b/tests/fixtures/sync/formats/laravel-php/02-double-quote-escapes.php new file mode 100644 index 0000000..9bf6de9 --- /dev/null +++ b/tests/fixtures/sync/formats/laravel-php/02-double-quote-escapes.php @@ -0,0 +1,7 @@ + "line1\nline2", + 'quote' => "She said \"hi\"", + 'tab' => "col1\tcol2", +]; diff --git a/tests/fixtures/sync/formats/laravel-php/03-interpolation-rejected.php b/tests/fixtures/sync/formats/laravel-php/03-interpolation-rejected.php new file mode 100644 index 0000000..0f56eb4 --- /dev/null +++ b/tests/fixtures/sync/formats/laravel-php/03-interpolation-rejected.php @@ -0,0 +1,5 @@ + "Hello $name", +]; diff --git a/tests/fixtures/sync/formats/laravel-php/04-heredoc-rejected.php b/tests/fixtures/sync/formats/laravel-php/04-heredoc-rejected.php new file mode 100644 index 0000000..811fbc2 --- /dev/null +++ b/tests/fixtures/sync/formats/laravel-php/04-heredoc-rejected.php @@ -0,0 +1,9 @@ + << 'Hello, ' . 'world', +]; diff --git a/tests/fixtures/sync/formats/laravel-php/06-mixed-syntax.php b/tests/fixtures/sync/formats/laravel-php/06-mixed-syntax.php new file mode 100644 index 0000000..58bb8f6 --- /dev/null +++ b/tests/fixtures/sync/formats/laravel-php/06-mixed-syntax.php @@ -0,0 +1,11 @@ + 'Short syntax', + 'long' => array( + 'inner' => 'Long syntax inner', + ), + 'nested_short' => [ + 'a' => 'short-in-long', + ], +]; diff --git a/tests/fixtures/sync/formats/laravel-php/07-colon-placeholder.php b/tests/fixtures/sync/formats/laravel-php/07-colon-placeholder.php new file mode 100644 index 0000000..7451cc7 --- /dev/null +++ b/tests/fixtures/sync/formats/laravel-php/07-colon-placeholder.php @@ -0,0 +1,7 @@ + 'Welcome, :name!', + 'summary' => 'You have :count messages from :sender', + 'required' => 'The :attribute field is required.', +]; diff --git a/tests/fixtures/sync/formats/laravel-php/08-trailing-commas.php b/tests/fixtures/sync/formats/laravel-php/08-trailing-commas.php new file mode 100644 index 0000000..4f95ab8 --- /dev/null +++ b/tests/fixtures/sync/formats/laravel-php/08-trailing-commas.php @@ -0,0 +1,10 @@ + 'one', + 'b' => [ + 'c' => 'two', + 'd' => 'three', + ], + 'e' => 'four', +]; diff --git a/tests/fixtures/sync/formats/laravel-php/09-ast-idempotence.php b/tests/fixtures/sync/formats/laravel-php/09-ast-idempotence.php new file mode 100644 index 0000000..babc894 --- /dev/null +++ b/tests/fixtures/sync/formats/laravel-php/09-ast-idempotence.php @@ -0,0 +1,12 @@ + [ + 'failed' => 'These credentials do not match our records.', + 'throttle' => 'Too many login attempts. Please try again later.', + ], + 'passwords' => [ + 'reset' => 'Your password has been reset!', + 'sent' => 'We have emailed your password reset link!', + ], +]; diff --git a/tests/fixtures/sync/formats/laravel-php/10-empty-nested-array.php b/tests/fixtures/sync/formats/laravel-php/10-empty-nested-array.php new file mode 100644 index 0000000..f6b7d7c --- /dev/null +++ b/tests/fixtures/sync/formats/laravel-php/10-empty-nested-array.php @@ -0,0 +1,10 @@ + 'Welcome', + 'rules' => [], + 'errors' => [ + 'required' => 'Required', + 'extras' => [], + ], +]; diff --git a/tests/fixtures/sync/formats/laravel-php/11-dot-key-vs-nested.php b/tests/fixtures/sync/formats/laravel-php/11-dot-key-vs-nested.php new file mode 100644 index 0000000..977b1ad --- /dev/null +++ b/tests/fixtures/sync/formats/laravel-php/11-dot-key-vs-nested.php @@ -0,0 +1,8 @@ + 'Literal dot key', + 'user' => [ + 'name' => 'Nested under user.name', + ], +]; diff --git a/tests/fixtures/sync/formats/laravel-php/12-escaped-dollar.php b/tests/fixtures/sync/formats/laravel-php/12-escaped-dollar.php new file mode 100644 index 0000000..23dafd7 --- /dev/null +++ b/tests/fixtures/sync/formats/laravel-php/12-escaped-dollar.php @@ -0,0 +1,6 @@ + "Only \$100!", + 'note' => "Use \${currency} in your local flavor", +]; diff --git a/tests/fixtures/sync/formats/laravel-php/13-utf8-bom.php b/tests/fixtures/sync/formats/laravel-php/13-utf8-bom.php new file mode 100644 index 0000000..4663bd6 --- /dev/null +++ b/tests/fixtures/sync/formats/laravel-php/13-utf8-bom.php @@ -0,0 +1,5 @@ + 'Hello with BOM', +]; diff --git a/tests/fixtures/sync/formats/laravel-php/14-literal-pipe-in-prose.php b/tests/fixtures/sync/formats/laravel-php/14-literal-pipe-in-prose.php new file mode 100644 index 0000000..89d6801 --- /dev/null +++ b/tests/fixtures/sync/formats/laravel-php/14-literal-pipe-in-prose.php @@ -0,0 +1,6 @@ + 'Press Ctrl | Cmd to continue', + 'choices' => 'Options: A | B | C', +]; diff --git a/tests/fixtures/sync/formats/laravel-php/15-irregular-whitespace-and-comments.php b/tests/fixtures/sync/formats/laravel-php/15-irregular-whitespace-and-comments.php new file mode 100644 index 0000000..bbf116d --- /dev/null +++ b/tests/fixtures/sync/formats/laravel-php/15-irregular-whitespace-and-comments.php @@ -0,0 +1,21 @@ + 'Hello', // trailing + + /* multiline + block comment */ + 'bye' => 'Goodbye', + + 'nav' => [ + 'home' => 'Home', + 'about' => 'About', // nested trailing + ], +]; diff --git a/tests/fixtures/sync/formats/po/messages-fuzzy.po b/tests/fixtures/sync/formats/po/messages-fuzzy.po new file mode 100644 index 0000000..9a43a37 --- /dev/null +++ b/tests/fixtures/sync/formats/po/messages-fuzzy.po @@ -0,0 +1,18 @@ +# Fuzzy flag test +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" + +#: src/app.py:12 +msgid "Hello" +msgstr "Hola" + +#: src/app.py:15 +#, fuzzy +msgid "Goodbye" +msgstr "Adiós" + +#: src/app.py:20 +#, fuzzy, python-format +msgid "Welcome %s" +msgstr "Bienvenido %s" diff --git a/tests/fixtures/sync/formats/po/messages-msgctxt.po b/tests/fixtures/sync/formats/po/messages-msgctxt.po new file mode 100644 index 0000000..0b3c060 --- /dev/null +++ b/tests/fixtures/sync/formats/po/messages-msgctxt.po @@ -0,0 +1,19 @@ +# Message context test +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" + +#: src/menu.py:5 +msgctxt "menu" +msgid "File" +msgstr "Archivo" + +#: src/menu.py:10 +msgctxt "menu" +msgid "Open" +msgstr "Abrir" + +#: src/dialog.py:5 +msgctxt "dialog" +msgid "Open" +msgstr "Abrir archivo" diff --git a/tests/fixtures/sync/formats/po/messages-multiline.po b/tests/fixtures/sync/formats/po/messages-multiline.po new file mode 100644 index 0000000..7d0cab7 --- /dev/null +++ b/tests/fixtures/sync/formats/po/messages-multiline.po @@ -0,0 +1,18 @@ +# Multiline test +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" + +#: src/help.py:5 +msgid "" +"This is a long message " +"that spans multiple lines " +"in the PO file." +msgstr "" +"Este es un mensaje largo " +"que abarca múltiples líneas " +"en el archivo PO." + +#: src/help.py:15 +msgid "Short message" +msgstr "Mensaje corto" diff --git a/tests/fixtures/sync/formats/po/messages-plurals.po b/tests/fixtures/sync/formats/po/messages-plurals.po new file mode 100644 index 0000000..6a5aa32 --- /dev/null +++ b/tests/fixtures/sync/formats/po/messages-plurals.po @@ -0,0 +1,19 @@ +# Plural forms test +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. Item count display +#: src/items.py:10 +msgid "One item" +msgid_plural "%d items" +msgstr[0] "Un elemento" +msgstr[1] "%d elementos" + +#. File count display +#: src/files.py:20 +msgid "One file" +msgid_plural "%d files" +msgstr[0] "Un archivo" +msgstr[1] "%d archivos" diff --git a/tests/fixtures/sync/formats/po/messages.po b/tests/fixtures/sync/formats/po/messages.po new file mode 100644 index 0000000..df14542 --- /dev/null +++ b/tests/fixtures/sync/formats/po/messages.po @@ -0,0 +1,26 @@ +# Translation file for the application +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +# Greeting message +#. Welcome text shown on the home page +#: src/app.py:12 +msgid "Hello" +msgstr "Hola" + +# Farewell message +#. Goodbye text shown when user leaves +#: src/app.py:15 +msgid "Goodbye" +msgstr "Adiós" + +#. Generic welcome message +#: src/app.py:20 +msgid "Welcome to our app" +msgstr "Bienvenido a nuestra aplicación" + +#: src/app.py:25 +msgid "Cancel" +msgstr "Cancelar" diff --git a/tests/fixtures/sync/formats/po/template.pot b/tests/fixtures/sync/formats/po/template.pot new file mode 100644 index 0000000..714c54f --- /dev/null +++ b/tests/fixtures/sync/formats/po/template.pot @@ -0,0 +1,32 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-01-01 00:00+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#. Welcome text shown on the home page +#: src/app.py:12 +msgid "Hello" +msgstr "" + +#. Goodbye text shown when user leaves +#: src/app.py:15 +msgid "Goodbye" +msgstr "" + +#. Generic welcome message +#: src/app.py:20 +msgid "Welcome to our app" +msgstr "" diff --git a/tests/fixtures/sync/formats/properties/expected-after-sync/de.properties b/tests/fixtures/sync/formats/properties/expected-after-sync/de.properties new file mode 100644 index 0000000..55a6f17 --- /dev/null +++ b/tests/fixtures/sync/formats/properties/expected-after-sync/de.properties @@ -0,0 +1,4 @@ +# Welcome screen messages +greeting=Hallo +farewell=Auf Wiedersehen +welcome=Willkommen diff --git a/tests/fixtures/sync/formats/properties/source.properties b/tests/fixtures/sync/formats/properties/source.properties new file mode 100644 index 0000000..b56e8de --- /dev/null +++ b/tests/fixtures/sync/formats/properties/source.properties @@ -0,0 +1,4 @@ +# Welcome screen messages +greeting=Hello +farewell=Goodbye +welcome=Welcome diff --git a/tests/fixtures/sync/formats/toml/expected-after-sync/de.toml b/tests/fixtures/sync/formats/toml/expected-after-sync/de.toml new file mode 100644 index 0000000..fa20641 --- /dev/null +++ b/tests/fixtures/sync/formats/toml/expected-after-sync/de.toml @@ -0,0 +1,6 @@ +greeting = "Hallo" +farewell = "Auf Wiedersehen" + +[nav] +home = "Startseite" +settings = "Einstellungen" diff --git a/tests/fixtures/sync/formats/toml/source.toml b/tests/fixtures/sync/formats/toml/source.toml new file mode 100644 index 0000000..b280073 --- /dev/null +++ b/tests/fixtures/sync/formats/toml/source.toml @@ -0,0 +1,6 @@ +greeting = "Hello" +farewell = "Goodbye" + +[nav] +home = "Home" +settings = "Settings" diff --git a/tests/fixtures/sync/formats/xcstrings/expected-after-sync/de.xcstrings b/tests/fixtures/sync/formats/xcstrings/expected-after-sync/de.xcstrings new file mode 100644 index 0000000..cfd3f10 --- /dev/null +++ b/tests/fixtures/sync/formats/xcstrings/expected-after-sync/de.xcstrings @@ -0,0 +1,55 @@ +{ + "sourceLanguage": "en", + "version": "1.0", + "strings": { + "greeting": { + "comment": "Welcome message", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hello" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hallo" + } + } + } + }, + "farewell": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Goodbye" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Auf Wiedersehen" + } + } + } + }, + "welcome": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Welcome" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Willkommen" + } + } + } + } + } +} diff --git a/tests/fixtures/sync/formats/xcstrings/source.xcstrings b/tests/fixtures/sync/formats/xcstrings/source.xcstrings new file mode 100644 index 0000000..ce92b2c --- /dev/null +++ b/tests/fixtures/sync/formats/xcstrings/source.xcstrings @@ -0,0 +1,37 @@ +{ + "sourceLanguage": "en", + "version": "1.0", + "strings": { + "greeting": { + "comment": "Welcome message", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hello" + } + } + } + }, + "farewell": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Goodbye" + } + } + } + }, + "welcome": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Welcome" + } + } + } + } + } +} diff --git a/tests/fixtures/sync/formats/xliff/messages-no-target.xlf b/tests/fixtures/sync/formats/xliff/messages-no-target.xlf new file mode 100644 index 0000000..bb32e33 --- /dev/null +++ b/tests/fixtures/sync/formats/xliff/messages-no-target.xlf @@ -0,0 +1,13 @@ + + + + + + Hello + + + Goodbye + + + + diff --git a/tests/fixtures/sync/formats/xliff/messages-v1.xlf b/tests/fixtures/sync/formats/xliff/messages-v1.xlf new file mode 100644 index 0000000..b2a5b38 --- /dev/null +++ b/tests/fixtures/sync/formats/xliff/messages-v1.xlf @@ -0,0 +1,20 @@ + + + + + + Hello + Hallo + Home page greeting + + + Goodbye + Auf Wiedersehen + + + Welcome, & enjoy! + Welcome message with special chars + + + + diff --git a/tests/fixtures/sync/formats/xliff/messages-v2.xlf b/tests/fixtures/sync/formats/xliff/messages-v2.xlf new file mode 100644 index 0000000..12ceaa6 --- /dev/null +++ b/tests/fixtures/sync/formats/xliff/messages-v2.xlf @@ -0,0 +1,20 @@ + + + + + + Hello + Hallo + + + + + Farewell message + + + Goodbye + Auf Wiedersehen + + + + diff --git a/tests/fixtures/sync/formats/yaml/en-multiline.yaml b/tests/fixtures/sync/formats/yaml/en-multiline.yaml new file mode 100644 index 0000000..2179786 --- /dev/null +++ b/tests/fixtures/sync/formats/yaml/en-multiline.yaml @@ -0,0 +1,7 @@ +description: | + This is a long description + that spans multiple lines. +summary: > + This is a folded + block scalar. +title: Short title diff --git a/tests/fixtures/sync/formats/yaml/en-reserved-words.yaml b/tests/fixtures/sync/formats/yaml/en-reserved-words.yaml new file mode 100644 index 0000000..7a87224 --- /dev/null +++ b/tests/fixtures/sync/formats/yaml/en-reserved-words.yaml @@ -0,0 +1,5 @@ +enabled: true +disabled: false +confirm: "yes" +deny: "no" +title: My Application diff --git a/tests/fixtures/sync/formats/yaml/en.yaml b/tests/fixtures/sync/formats/yaml/en.yaml new file mode 100644 index 0000000..ad0523d --- /dev/null +++ b/tests/fixtures/sync/formats/yaml/en.yaml @@ -0,0 +1,3 @@ +greeting: Hello +farewell: Goodbye +welcome_message: Welcome to our app diff --git a/tests/fixtures/sync/laravel_php-pipe-plural/README.md b/tests/fixtures/sync/laravel_php-pipe-plural/README.md new file mode 100644 index 0000000..fca0f2e --- /dev/null +++ b/tests/fixtures/sync/laravel_php-pipe-plural/README.md @@ -0,0 +1,15 @@ +# laravel_php pipe-plural fixtures + +Minimal Laravel PHP bucket with two locales and two pipe-pluralization keys +(`apples` with `|{n}` markers, `days` with `|[n,m]` markers). Used by +`tests/integration/sync-tms-push.integration.test.ts` to exercise the +walker-skip-partition invariant at every TMS push/pull extract site: + +- Push from the non-multi-locale target file (`de.php`) — pipe-plural keys + must not reach `TmsClient.pushKey(...)`. +- Pull merge against an existing target — pipe-plural keys must not appear in + the translated-entry list handed to `parser.reconstruct(...)`. + +The `|{n}` / `|[n,m]` markers match the gate in `src/formats/php-arrays.ts` +`PIPE_PLURALIZATION_REGEX`; plain pipe-delimited values (e.g., `apples|apple`) +are intentionally NOT flagged and are not exercised here. diff --git a/tests/fixtures/sync/laravel_php-pipe-plural/de.php b/tests/fixtures/sync/laravel_php-pipe-plural/de.php new file mode 100644 index 0000000..5fb3077 --- /dev/null +++ b/tests/fixtures/sync/laravel_php-pipe-plural/de.php @@ -0,0 +1,8 @@ + 'Hallo', + 'apples' => '{0} No apples|{1} One apple|[2,*] Many apples', + 'farewell' => 'Tschüss', + 'days' => '[0,0] No days|[1,6] A few days|[7,*] Full week', +]; diff --git a/tests/fixtures/sync/laravel_php-pipe-plural/en.php b/tests/fixtures/sync/laravel_php-pipe-plural/en.php new file mode 100644 index 0000000..ae1f199 --- /dev/null +++ b/tests/fixtures/sync/laravel_php-pipe-plural/en.php @@ -0,0 +1,8 @@ + 'Hello', + 'apples' => '{0} No apples|{1} One apple|[2,*] Many apples', + 'farewell' => 'Goodbye', + 'days' => '[0,0] No days|[1,6] A few days|[7,*] Full week', +]; diff --git a/tests/fixtures/sync/lockfiles/empty.lock b/tests/fixtures/sync/lockfiles/empty.lock new file mode 100644 index 0000000..0cdc9e2 --- /dev/null +++ b/tests/fixtures/sync/lockfiles/empty.lock @@ -0,0 +1,12 @@ +{ + "_comment": "Auto-generated by deepl sync. To resolve merge conflicts, accept either version and re-run deepl sync.", + "version": 1, + "generated_at": "2026-01-01T00:00:00.000Z", + "source_locale": "en", + "entries": {}, + "stats": { + "total_keys": 0, + "total_translations": 0, + "last_sync": "2026-01-01T00:00:00.000Z" + } +} diff --git a/tests/fixtures/sync/lockfiles/fully-translated.lock b/tests/fixtures/sync/lockfiles/fully-translated.lock new file mode 100644 index 0000000..2a12736 --- /dev/null +++ b/tests/fixtures/sync/lockfiles/fully-translated.lock @@ -0,0 +1,49 @@ +{ + "_comment": "Auto-generated by deepl sync. To resolve merge conflicts, accept either version and re-run deepl sync.", + "version": 1, + "generated_at": "2026-01-01T00:00:00.000Z", + "source_locale": "en", + "entries": { + "locales/en.json": { + "farewell": { + "source_hash": "c015ad6ddaf8", + "source_locale": "en", + "updated_at": "2026-01-01T00:00:00.000Z", + "translations": { + "es": { + "locale": "es", + "hash": "es_farewell_h", + "translated_at": "2026-01-01T00:00:00.000Z" + }, + "fr": { + "locale": "fr", + "hash": "fr_farewell_h", + "translated_at": "2026-01-01T00:00:00.000Z" + } + } + }, + "greeting": { + "source_hash": "185f8db32271", + "source_locale": "en", + "updated_at": "2026-01-01T00:00:00.000Z", + "translations": { + "es": { + "locale": "es", + "hash": "es_greeting_h", + "translated_at": "2026-01-01T00:00:00.000Z" + }, + "fr": { + "locale": "fr", + "hash": "fr_greeting_h", + "translated_at": "2026-01-01T00:00:00.000Z" + } + } + } + } + }, + "stats": { + "total_keys": 2, + "total_translations": 4, + "last_sync": "2026-01-01T00:00:00.000Z" + } +} diff --git a/tests/fixtures/sync/lockfiles/partial.lock b/tests/fixtures/sync/lockfiles/partial.lock new file mode 100644 index 0000000..80d38d8 --- /dev/null +++ b/tests/fixtures/sync/lockfiles/partial.lock @@ -0,0 +1,27 @@ +{ + "_comment": "Auto-generated by deepl sync. To resolve merge conflicts, accept either version and re-run deepl sync.", + "version": 1, + "generated_at": "2026-01-01T00:00:00.000Z", + "source_locale": "en", + "entries": { + "locales/en.json": { + "greeting": { + "source_hash": "185f8db32271", + "source_locale": "en", + "updated_at": "2026-01-01T00:00:00.000Z", + "translations": { + "es": { + "locale": "es", + "hash": "abc123def456", + "translated_at": "2026-01-01T00:00:00.000Z" + } + } + } + } + }, + "stats": { + "total_keys": 1, + "total_translations": 1, + "last_sync": "2026-01-01T00:00:00.000Z" + } +} diff --git a/tests/fixtures/sync/lockfiles/with-deleted.lock b/tests/fixtures/sync/lockfiles/with-deleted.lock new file mode 100644 index 0000000..da56ce8 --- /dev/null +++ b/tests/fixtures/sync/lockfiles/with-deleted.lock @@ -0,0 +1,27 @@ +{ + "_comment": "Auto-generated by deepl sync. To resolve merge conflicts, accept either version and re-run deepl sync.", + "version": 1, + "generated_at": "2026-01-01T00:00:00.000Z", + "source_locale": "en", + "entries": { + "locales/en.json": { + "old_key": { + "source_hash": "702a34973f97", + "source_locale": "en", + "updated_at": "2026-01-01T00:00:00.000Z", + "translations": { + "es": { + "locale": "es", + "hash": "es_old_hash", + "translated_at": "2026-01-01T00:00:00.000Z" + } + } + } + } + }, + "stats": { + "total_keys": 1, + "total_translations": 1, + "last_sync": "2026-01-01T00:00:00.000Z" + } +} diff --git a/tests/helpers/assert-error-envelope.ts b/tests/helpers/assert-error-envelope.ts new file mode 100644 index 0000000..abf6039 --- /dev/null +++ b/tests/helpers/assert-error-envelope.ts @@ -0,0 +1,163 @@ +/** + * Test helper: validates the JSON error envelope emitted on stderr by + * `deepl sync --format json` when a command fails. + * + * The envelope shape is shared by every subcommand (push/pull/resolve/export/ + * validate/audit/init/status/sync) and is the machine-readable + * contract script consumers rely on. This helper uses AJV to enforce the + * shape; any drift breaks the test instead of silently shipping a bad payload. + */ + +import Ajv, { type JSONSchemaType } from 'ajv'; + +export interface SyncJsonErrorEnvelope { + ok: false; + error: { + code: string; + message: string; + suggestion?: string; + }; + exitCode: number; +} + +export const ERROR_ENVELOPE_SCHEMA: JSONSchemaType = { + type: 'object', + additionalProperties: false, + required: ['ok', 'error', 'exitCode'], + properties: { + ok: { type: 'boolean', const: false }, + error: { + type: 'object', + additionalProperties: false, + required: ['code', 'message'], + properties: { + code: { type: 'string', minLength: 1 }, + message: { type: 'string', minLength: 1 }, + suggestion: { type: 'string', minLength: 1, nullable: true }, + }, + }, + exitCode: { type: 'integer', minimum: 1 }, + }, +}; + +export interface SyncInitJsonSuccessEnvelope { + ok: true; + created: { + configPath: string; + sourceLocale: string; + targetLocales: string[]; + keys?: number; + }; +} + +export const INIT_SUCCESS_ENVELOPE_SCHEMA: JSONSchemaType = { + type: 'object', + additionalProperties: false, + required: ['ok', 'created'], + properties: { + ok: { type: 'boolean', const: true }, + created: { + type: 'object', + additionalProperties: false, + required: ['configPath', 'sourceLocale', 'targetLocales'], + properties: { + configPath: { type: 'string', minLength: 1 }, + sourceLocale: { type: 'string', minLength: 1 }, + targetLocales: { + type: 'array', + items: { type: 'string', minLength: 1 }, + minItems: 1, + }, + keys: { type: 'integer', minimum: 0, nullable: true }, + }, + }, + }, +}; + +// AJV v8 default export is a class under the CJS type shim; treat both shapes +// so jest-via-ts-jest and runtime both resolve. In practice ts-jest sees the +// CJS shim while runtime sees the ESM re-export. +const AjvCtor: typeof Ajv = + (Ajv as unknown as { default?: typeof Ajv }).default ?? Ajv; + +const ajv = new AjvCtor({ strict: false, allErrors: true }); +const validateError = ajv.compile(ERROR_ENVELOPE_SCHEMA); +const validateInitSuccess = ajv.compile(INIT_SUCCESS_ENVELOPE_SCHEMA); + +/** + * Extract the last JSON object from `stderr` and assert it matches the + * error envelope schema. Parent-process progress lines (e.g. "Detecting + * i18n files...") can land on stderr before the envelope, so we match the + * final `{...}` block rather than parsing all of stderr. + */ +export function assertErrorEnvelope( + stderr: string, + expectedCode: string, + expectedExitCode: number, +): SyncJsonErrorEnvelope { + const match = stderr.trim().match(/\{[\s\S]*\}\s*$/); + if (!match) { + throw new Error( + `No trailing JSON object found in stderr. stderr was:\n${stderr}`, + ); + } + + let parsed: unknown; + try { + parsed = JSON.parse(match[0]); + } catch (err) { + throw new Error( + `stderr trailing JSON is not parseable: ${(err as Error).message}\nPayload: ${match[0]}`, + { cause: err }, + ); + } + + if (!validateError(parsed)) { + throw new Error( + `Envelope failed AJV validation:\n${ajv.errorsText(validateError.errors)}\nPayload: ${JSON.stringify(parsed)}`, + ); + } + + if (parsed.error.code !== expectedCode) { + throw new Error( + `Envelope error.code = "${parsed.error.code}", expected "${expectedCode}". Payload: ${JSON.stringify(parsed)}`, + ); + } + if (parsed.exitCode !== expectedExitCode) { + throw new Error( + `Envelope exitCode = ${parsed.exitCode}, expected ${expectedExitCode}. Payload: ${JSON.stringify(parsed)}`, + ); + } + return parsed; +} + +/** + * Validate an `init --format json` success envelope on stdout. Separate from + * the error envelope because `init` is the only subcommand with a dedicated + * machine-readable success payload today. + */ +export function assertInitSuccessEnvelope(stdout: string): SyncInitJsonSuccessEnvelope { + const match = stdout.trim().match(/\{[\s\S]*\}\s*$/); + if (!match) { + throw new Error( + `No trailing JSON object found in stdout. stdout was:\n${stdout}`, + ); + } + + let parsed: unknown; + try { + parsed = JSON.parse(match[0]); + } catch (err) { + throw new Error( + `stdout trailing JSON is not parseable: ${(err as Error).message}\nPayload: ${match[0]}`, + { cause: err }, + ); + } + + if (!validateInitSuccess(parsed)) { + throw new Error( + `Init success envelope failed AJV validation:\n${ajv.errorsText(validateInitSuccess.errors)}\nPayload: ${JSON.stringify(parsed)}`, + ); + } + return parsed; +} diff --git a/tests/helpers/index.ts b/tests/helpers/index.ts index 6acdf08..7facea2 100644 --- a/tests/helpers/index.ts +++ b/tests/helpers/index.ts @@ -21,6 +21,15 @@ export { TEST_API_KEY, } from './nock-setup'; +export { + assertErrorEnvelope, + assertInitSuccessEnvelope, + ERROR_ENVELOPE_SCHEMA, + INIT_SUCCESS_ENVELOPE_SCHEMA, + type SyncJsonErrorEnvelope, + type SyncInitJsonSuccessEnvelope, +} from './assert-error-envelope'; + export { createMockDeepLClient, createMockConfigService, diff --git a/tests/helpers/mock-factories.ts b/tests/helpers/mock-factories.ts index b0fa936..30c53e1 100644 --- a/tests/helpers/mock-factories.ts +++ b/tests/helpers/mock-factories.ts @@ -34,15 +34,33 @@ type MockShape = { [K in keyof T as T[K] extends (...args: never[]) => unknown ? K : never]: jest.Mock; }; +// --------------------------------------------------------------------------- +// Unconfigured-mock guard: critical methods default to throwing so that tests +// which never supply explicit behavior do not silently pass against empty +// strings / empty arrays. Callers override on a per-test basis with +// `.mockResolvedValue(...)` or by passing the method in the factory overrides. +// --------------------------------------------------------------------------- +function unconfiguredAsync(methodName: string): jest.Mock { + return jest.fn().mockImplementation(() => { + return Promise.reject( + new Error( + `mock: ${methodName} was called without an explicit override. ` + + `Supply a mockResolvedValue or factory override so the assertion is non-vacuous.`, + ), + ); + }); +} + // --------------------------------------------------------------------------- // DeepLClient // --------------------------------------------------------------------------- function deepLClientDefaults(): MockShape { return { - translate: jest.fn().mockResolvedValue({ text: '', detectedSourceLang: undefined }), - translateBatch: jest.fn().mockResolvedValue([]), + translate: unconfiguredAsync('DeepLClient.translate'), + translateBatch: unconfiguredAsync('DeepLClient.translateBatch'), getUsage: jest.fn().mockResolvedValue({ character: { count: 0, limit: 0 } }), getSupportedLanguages: jest.fn().mockResolvedValue([]), + listTranslationMemories: jest.fn().mockResolvedValue([]), getGlossaryLanguages: jest.fn().mockResolvedValue([]), createGlossary: jest.fn().mockResolvedValue(null), listGlossaries: jest.fn().mockResolvedValue([]), @@ -124,9 +142,10 @@ export function createMockCacheService( // --------------------------------------------------------------------------- function translationServiceDefaults(): MockShape { return { - translate: jest.fn().mockResolvedValue({ text: '', detectedSourceLang: undefined }), - translateBatch: jest.fn().mockResolvedValue([]), - translateToMultiple: jest.fn().mockResolvedValue([]), + translate: unconfiguredAsync('TranslationService.translate'), + translateBatch: unconfiguredAsync('TranslationService.translateBatch'), + translateToMultiple: unconfiguredAsync('TranslationService.translateToMultiple'), + listTranslationMemories: jest.fn().mockResolvedValue([]), getUsage: jest.fn().mockResolvedValue({ character: { count: 0, limit: 0 } }), getSupportedLanguages: jest.fn().mockResolvedValue([]), }; @@ -191,7 +210,7 @@ export function createMockDocumentTranslationService( function fileTranslationServiceDefaults(): MockShape { return { translateFile: jest.fn().mockResolvedValue(undefined), - translateFileToMultiple: jest.fn().mockResolvedValue([]), + translateFileToMultiple: unconfiguredAsync('FileTranslationService.translateFileToMultiple'), getSupportedFileTypes: jest.fn().mockReturnValue(['.txt', '.md', '.json', '.yaml', '.yml']), isSupportedFile: jest.fn().mockReturnValue(true), }; @@ -227,8 +246,8 @@ export function createMockWatchService( // --------------------------------------------------------------------------- function writeServiceDefaults(): MockShape { return { - improve: jest.fn().mockResolvedValue([]), - getBestImprovement: jest.fn().mockResolvedValue(null), + improve: unconfiguredAsync('WriteService.improve'), + getBestImprovement: unconfiguredAsync('WriteService.getBestImprovement'), }; } diff --git a/tests/helpers/sync-harness.ts b/tests/helpers/sync-harness.ts new file mode 100644 index 0000000..cd172bb --- /dev/null +++ b/tests/helpers/sync-harness.ts @@ -0,0 +1,156 @@ +/** + * Shared test harness for sync integration tests. + * + * Exposes three helpers used across tests/integration/sync*.integration.test.ts: + * - createSyncHarness() -> wires up SyncService with the real deps and mock config/cache + * - buildSyncConfigYaml() -> serializes a .deepl-sync.yaml document from a plain object + * - seedLockFile() -> writes a valid .deepl-sync.lock to a test project root + * + * These replace ad-hoc template strings and duplicated service-wiring code that + * accumulated across the sync integration tests. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as YAML from 'yaml'; + +import { DeepLClient } from '../../src/api/deepl-client'; +import { TranslationService } from '../../src/services/translation'; +import { GlossaryService } from '../../src/services/glossary'; +import { SyncService } from '../../src/sync/sync-service'; +import { FormatRegistry } from '../../src/formats/index'; +import { JsonFormatParser } from '../../src/formats/json'; +import { YamlFormatParser } from '../../src/formats/yaml'; +import { AndroidXmlFormatParser } from '../../src/formats/android-xml'; +import { XliffFormatParser } from '../../src/formats/xliff'; +import { XcstringsFormatParser } from '../../src/formats/xcstrings'; +import { PoFormatParser } from '../../src/formats/po'; +import { TomlFormatParser } from '../../src/formats/toml'; +import { ArbFormatParser } from '../../src/formats/arb'; +import { IosStringsFormatParser } from '../../src/formats/ios-strings'; +import { PropertiesFormatParser } from '../../src/formats/properties'; +import type { FormatParser } from '../../src/formats/index'; +import type { SyncLockFile, SyncLockEntry } from '../../src/sync/types'; +import { LOCK_FILE_NAME, LOCK_FILE_VERSION, LOCK_FILE_COMMENT } from '../../src/sync/types'; +import { TEST_API_KEY } from './nock-setup'; +import { createMockConfigService, createMockCacheService } from './mock-factories'; + +export type SupportedParser = + | 'json' + | 'yaml' + | 'android_xml' + | 'xliff' + | 'xcstrings' + | 'po' + | 'toml' + | 'arb' + | 'ios_strings' + | 'properties'; + +export interface SyncHarness { + client: DeepLClient; + syncService: SyncService; + registry: FormatRegistry; + cleanup: () => void; +} + +export function createSyncHarness(opts: { parsers?: SupportedParser[]; maxRetries?: number } = {}): SyncHarness { + const client = new DeepLClient(TEST_API_KEY, { maxRetries: opts.maxRetries ?? 0 }); + const mockConfig = createMockConfigService({ + get: jest.fn(() => ({ + auth: {}, + api: { baseUrl: '', usePro: false }, + defaults: { targetLangs: [], formality: 'default', preserveFormatting: false }, + cache: { enabled: false }, + output: { format: 'text', color: true }, + proxy: {}, + })), + getValue: jest.fn(() => false), + }); + const mockCache = createMockCacheService(); + const translationService = new TranslationService(client, mockConfig, mockCache); + const glossaryService = new GlossaryService(client); + const registry = new FormatRegistry(); + for (const name of opts.parsers ?? ['json']) { + registry.register(parserFor(name)); + } + const syncService = new SyncService(translationService, glossaryService, registry); + return { client, syncService, registry, cleanup: () => client.destroy() }; +} + +function parserFor(name: SupportedParser): FormatParser { + switch (name) { + case 'json': return new JsonFormatParser(); + case 'yaml': return new YamlFormatParser(); + case 'android_xml': return new AndroidXmlFormatParser(); + case 'xliff': return new XliffFormatParser(); + case 'xcstrings': return new XcstringsFormatParser(); + case 'po': return new PoFormatParser(); + case 'toml': return new TomlFormatParser(); + case 'arb': return new ArbFormatParser(); + case 'ios_strings': return new IosStringsFormatParser(); + case 'properties': return new PropertiesFormatParser(); + } +} + +export interface SyncConfigYamlOpts { + sourceLocale?: string; + targetLocales?: string[]; + buckets?: Record>; + translation?: Record; + validation?: Record; + sync?: Record; + tms?: Record; + ignore?: string[]; + context?: Record | boolean; +} + +export function buildSyncConfigYaml(opts: SyncConfigYamlOpts = {}): string { + const config: Record = { + version: 1, + source_locale: opts.sourceLocale ?? 'en', + target_locales: opts.targetLocales ?? ['de'], + buckets: opts.buckets ?? { json: { include: ['locales/en.json'] } }, + }; + if (opts.translation) config['translation'] = opts.translation; + if (opts.validation) config['validation'] = opts.validation; + if (opts.sync) config['sync'] = opts.sync; + if (opts.tms) config['tms'] = opts.tms; + if (opts.ignore) config['ignore'] = opts.ignore; + if (opts.context !== undefined) config['context'] = opts.context; + return YAML.stringify(config); +} + +export function writeSyncConfig(dir: string, opts: SyncConfigYamlOpts = {}): string { + const yaml = buildSyncConfigYaml(opts); + const filePath = path.join(dir, '.deepl-sync.yaml'); + fs.writeFileSync(filePath, yaml, 'utf-8'); + return filePath; +} + +export interface SeedLockOpts { + sourceLocale?: string; + entries: Record>; +} + +export function seedLockFile(dir: string, opts: SeedLockOpts): void { + const sourceLocale = opts.sourceLocale ?? 'en'; + const now = new Date().toISOString(); + let totalKeys = 0; + let totalTranslations = 0; + for (const bucket of Object.values(opts.entries)) { + for (const entry of Object.values(bucket)) { + totalKeys += 1; + totalTranslations += Object.keys(entry.translations ?? {}).length; + } + } + const lockFile: SyncLockFile = { + _comment: LOCK_FILE_COMMENT, + version: LOCK_FILE_VERSION, + generated_at: now, + source_locale: sourceLocale, + entries: opts.entries, + stats: { total_keys: totalKeys, total_translations: totalTranslations, last_sync: now }, + }; + fs.writeFileSync(path.join(dir, LOCK_FILE_NAME), JSON.stringify(lockFile, null, 2) + '\n', 'utf-8'); +} diff --git a/tests/helpers/tms-nock.ts b/tests/helpers/tms-nock.ts new file mode 100644 index 0000000..1a4bdee --- /dev/null +++ b/tests/helpers/tms-nock.ts @@ -0,0 +1,89 @@ +/** + * Nock helpers for the TMS push/pull REST contract. + * + * The built-in TMS adapter in src/sync/tms-client.ts expects: + * PUT {server}/api/projects/{projectId}/keys/{keyPath} push a translation + * GET {server}/api/projects/{projectId}/keys/export?format=json&locale={locale} pull approved translations + * GET {server}/api/projects/{projectId} project status + * + * These helpers arm nock scopes against a well-known test server URL so + * sync-tms integration tests can assert wire behavior concisely. + */ + +import nock from 'nock'; + +export const TMS_BASE = 'https://tms.test'; +export const TMS_PROJECT = 'proj-42'; +export const TMS_API_KEY = 'tms-test-api-key'; +export const TMS_TOKEN = 'tms-test-token'; + +export type AuthExpectation = + | { apiKey: string } + | { token: string } + | { none: true }; + +function authHeader(auth: AuthExpectation | undefined): string | undefined { + if (!auth) return undefined; + if ('apiKey' in auth) return `ApiKey ${auth.apiKey}`; + if ('token' in auth) return `Bearer ${auth.token}`; + return undefined; +} + +export function expectTmsPush( + key: string, + locale: string, + value: string, + opts: { auth?: AuthExpectation; status?: number } = {}, +): nock.Scope { + const scope = nock(TMS_BASE); + const expectedBody = { locale, value }; + const encoded = encodeURIComponent(key); + let interceptor = scope.put(`/api/projects/${TMS_PROJECT}/keys/${encoded}`, expectedBody); + const header = authHeader(opts.auth); + if (header !== undefined) { + interceptor = interceptor.matchHeader('authorization', header); + } + return interceptor.reply(opts.status ?? 200, {}); +} + +export function expectTmsPull( + locale: string, + response: Record, + opts: { auth?: AuthExpectation; status?: number } = {}, +): nock.Scope { + const scope = nock(TMS_BASE); + let interceptor = scope + .get(`/api/projects/${TMS_PROJECT}/keys/export`) + .query({ format: 'json', locale }); + const header = authHeader(opts.auth); + if (header !== undefined) { + interceptor = interceptor.matchHeader('authorization', header); + } + return interceptor.reply(opts.status ?? 200, response); +} + +export function expectTmsError( + method: 'put' | 'get', + pathSuffix: string, + status: number, + body: Record = {}, +): nock.Scope { + const scope = nock(TMS_BASE); + if (method === 'put') { + return scope.put(new RegExp(`/api/projects/${TMS_PROJECT}/keys/.+`)).reply(status, body); + } + return scope.get(new RegExp(`/api/projects/${TMS_PROJECT}${pathSuffix}.*`)).reply(status, body); +} + +/** + * Standard `tms:` config block pointing at the nock-mocked TMS_BASE/TMS_PROJECT. + * Spread extra overrides (e.g. token) into the second arg. + */ +export function tmsConfig(overrides: Record = {}): Record { + return { + enabled: true, + server: TMS_BASE, + project_id: TMS_PROJECT, + ...overrides, + }; +} diff --git a/tests/integration/cli-glossary.integration.test.ts b/tests/integration/cli-glossary.integration.test.ts index 9ed9da3..91c971e 100644 --- a/tests/integration/cli-glossary.integration.test.ts +++ b/tests/integration/cli-glossary.integration.test.ts @@ -44,7 +44,7 @@ describe('Glossary CLI Integration', () => { expect.assertions(1); try { - runCLI('deepl glossary list', { stdio: 'pipe' }); + runCLI('deepl glossary list', { stdio: 'pipe', excludeApiKey: true }); } catch (error: any) { const output = error.stderr ?? error.stdout; // Should indicate API key is required diff --git a/tests/integration/cli-style-rules.integration.test.ts b/tests/integration/cli-style-rules.integration.test.ts index cabdeb3..3bf0dd7 100644 --- a/tests/integration/cli-style-rules.integration.test.ts +++ b/tests/integration/cli-style-rules.integration.test.ts @@ -54,7 +54,7 @@ describe('Style Rules CLI Integration', () => { it('should require API key for style-rules list', () => { expect.assertions(1); try { - runCLI('deepl style-rules list', { stdio: 'pipe' }); + runCLI('deepl style-rules list', { stdio: 'pipe', excludeApiKey: true }); } catch (error: any) { const output = error.stderr ?? error.stdout; expect(output).toMatch(/API key|auth|not set/i); @@ -64,7 +64,7 @@ describe('Style Rules CLI Integration', () => { it('should require API key for style-rules list --detailed', () => { expect.assertions(1); try { - runCLI('deepl style-rules list --detailed', { stdio: 'pipe' }); + runCLI('deepl style-rules list --detailed', { stdio: 'pipe', excludeApiKey: true }); } catch (error: any) { const output = error.stderr ?? error.stdout; expect(output).toMatch(/API key|auth|not set/i); @@ -75,7 +75,7 @@ describe('Style Rules CLI Integration', () => { expect.assertions(1); try { runCLI('deepl style-rules list --page 1 --page-size 10', { - stdio: 'pipe', + stdio: 'pipe', excludeApiKey: true, }); } catch (error: any) { const output = error.stderr ?? error.stdout; diff --git a/tests/integration/cli-translate.integration.test.ts b/tests/integration/cli-translate.integration.test.ts index eae75df..45e1523 100644 --- a/tests/integration/cli-translate.integration.test.ts +++ b/tests/integration/cli-translate.integration.test.ts @@ -5,8 +5,26 @@ import { execSync } from 'child_process'; import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; -import { createTestConfigDir, createTestDir, makeRunCLI } from '../helpers'; +import nock from 'nock'; +import { + createTestConfigDir, + createTestDir, + makeRunCLI, + DEEPL_FREE_API_URL, + TEST_API_KEY, + createMockConfigService, + createMockCacheService, + createMockFileTranslationService, + createMockDocumentTranslationService, +} from '../helpers'; +import { DeepLClient } from '../../src/api/deepl-client'; +import { TranslationService } from '../../src/services/translation'; +import { GlossaryService } from '../../src/services/glossary'; +import { TextTranslationHandler } from '../../src/cli/commands/translate/text-translation-handler'; +import type { HandlerContext, TranslateOptions } from '../../src/cli/commands/translate/types'; +import type { BatchTranslationService } from '../../src/services/batch-translation'; describe('Translate CLI Integration', () => { const testConfig = createTestConfigDir('test-translate'); @@ -734,7 +752,7 @@ describe('Translate CLI Integration', () => { it('should require API key for style-rules list', () => { expect.assertions(1); try { - runCLI('deepl style-rules list', { stdio: 'pipe' }); + runCLI('deepl style-rules list', { stdio: 'pipe', excludeApiKey: true }); } catch (error: any) { const output = error.stderr ?? error.stdout; expect(output).toMatch(/API key|auth/i); @@ -1005,3 +1023,213 @@ describe('Translate CLI Integration', () => { }); }); }); + +/** + * Translation Memory wiring — full-stack integration coverage. + * + * These exercise the full stack (handler → resolver → service → HTTP) against + * a real DeepLClient so nock can assert wire-level request bodies. Each + * describe ends with `nock.isDone()` so any unmatched or missing interceptor + * fails the test. + */ +describe('Translate CLI --translation-memory integration (nock)', () => { + const TM_UUID = '11111111-2222-3333-4444-555555555555'; + const GLOSSARY_UUID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + const prevApiKey = process.env['DEEPL_API_KEY']; + const prevConfigDir = process.env['DEEPL_CONFIG_DIR']; + + let tmpConfigDir: string; + let client: DeepLClient; + let handler: TextTranslationHandler; + + beforeEach(() => { + tmpConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-tm-int-')); + process.env['DEEPL_CONFIG_DIR'] = tmpConfigDir; + process.env['DEEPL_API_KEY'] = TEST_API_KEY; + + const mockConfig = createMockConfigService({ + get: jest.fn(() => ({ + auth: {}, + api: { baseUrl: DEEPL_FREE_API_URL, usePro: false }, + defaults: { targetLangs: [], formality: 'default', preserveFormatting: false }, + cache: { enabled: false }, + output: { format: 'text', color: false }, + proxy: {}, + })), + getValue: jest.fn((key: string) => { + if (key === 'auth.apiKey') return TEST_API_KEY; + if (key === 'cache.enabled') return false; + return undefined; + }), + }); + const mockCache = createMockCacheService(); + + client = new DeepLClient(TEST_API_KEY, { maxRetries: 0 }); + const translationService = new TranslationService(client, mockConfig, mockCache); + const glossaryService = new GlossaryService(client); + + const ctx: HandlerContext = { + translationService, + glossaryService, + fileTranslationService: createMockFileTranslationService(), + batchTranslationService: {} as jest.Mocked, + documentTranslationService: createMockDocumentTranslationService(), + config: mockConfig, + }; + handler = new TextTranslationHandler(ctx); + }); + + afterEach(() => { + client.destroy(); + nock.cleanAll(); + if (fs.existsSync(tmpConfigDir)) { + fs.rmSync(tmpConfigDir, { recursive: true, force: true }); + } + if (prevApiKey !== undefined) { + process.env['DEEPL_API_KEY'] = prevApiKey; + } else { + delete process.env['DEEPL_API_KEY']; + } + if (prevConfigDir !== undefined) { + process.env['DEEPL_CONFIG_DIR'] = prevConfigDir; + } else { + delete process.env['DEEPL_CONFIG_DIR']; + } + }); + + describe('happy path — name resolves, threshold defaults to 75', () => { + it('sends translation_memory_id and translation_memory_threshold=75 when only --translation-memory is set', async () => { + const listScope = nock(DEEPL_FREE_API_URL) + .get('/v3/translation_memories') + .reply(200, { + translation_memories: [ + { translation_memory_id: TM_UUID, name: 'my-tm', source_language: 'en', target_languages: ['de'] }, + ], + }); + + const translateScope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate', (body: Record) => { + expect(body['translation_memory_id']).toBe(TM_UUID); + expect(body['translation_memory_threshold']).toBe('75'); + return true; + }) + .reply(200, { translations: [{ text: 'Hallo', detected_source_language: 'EN' }] }); + + const options: TranslateOptions = { + to: 'de', + from: 'en', + translationMemory: 'my-tm', + cache: false, + }; + + await handler.translateText('Hi', options); + + expect(listScope.isDone()).toBe(true); + expect(translateScope.isDone()).toBe(true); + expect(nock.isDone()).toBe(true); + }); + }); + + describe('explicit --tm-threshold 80 with UUID input', () => { + it('sends translation_memory_threshold=80 and skips the list call', async () => { + const translateScope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate', (body: Record) => { + expect(body['translation_memory_id']).toBe(TM_UUID); + expect(body['translation_memory_threshold']).toBe('80'); + return true; + }) + .reply(200, { translations: [{ text: 'Hallo', detected_source_language: 'EN' }] }); + + const options: TranslateOptions = { + to: 'de', + from: 'en', + translationMemory: TM_UUID, + tmThreshold: 80, + cache: false, + }; + + await handler.translateText('Hi', options); + + expect(nock.pendingMocks()).not.toContain( + `GET ${DEEPL_FREE_API_URL}:443/v3/translation_memories`, + ); + expect(translateScope.isDone()).toBe(true); + expect(nock.isDone()).toBe(true); + }); + }); + + describe('omit-both — no TM flags', () => { + it('sends neither translation_memory_id nor translation_memory_threshold', async () => { + const translateScope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate', (body: Record) => { + expect(body['translation_memory_id']).toBeUndefined(); + expect(body['translation_memory_threshold']).toBeUndefined(); + return true; + }) + .reply(200, { translations: [{ text: 'Hallo', detected_source_language: 'EN' }] }); + + const options: TranslateOptions = { + to: 'de', + from: 'en', + cache: false, + }; + + await handler.translateText('Hi', options); + + expect(translateScope.isDone()).toBe(true); + expect(nock.isDone()).toBe(true); + }); + }); + + describe('glossary + translation memory on the same call', () => { + it('sends both glossary_id and translation_memory_id in one request', async () => { + const glossaryListScope = nock(DEEPL_FREE_API_URL) + .get('/v3/glossaries') + .reply(200, { + glossaries: [ + { + glossary_id: GLOSSARY_UUID, + name: 'my-glossary', + ready: true, + creation_time: '2026-04-19T00:00:00Z', + dictionaries: [ + { source_lang: 'EN', target_lang: 'DE', entry_count: 1 }, + ], + }, + ], + }); + + const tmListScope = nock(DEEPL_FREE_API_URL) + .get('/v3/translation_memories') + .reply(200, { + translation_memories: [ + { translation_memory_id: TM_UUID, name: 'my-tm', source_language: 'en', target_languages: ['de'] }, + ], + }); + + const translateScope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate', (body: Record) => { + expect(body['glossary_id']).toBe(GLOSSARY_UUID); + expect(body['translation_memory_id']).toBe(TM_UUID); + expect(body['translation_memory_threshold']).toBe('75'); + return true; + }) + .reply(200, { translations: [{ text: 'Hallo', detected_source_language: 'EN' }] }); + + const options: TranslateOptions = { + to: 'de', + from: 'en', + glossary: 'my-glossary', + translationMemory: 'my-tm', + cache: false, + }; + + await handler.translateText('Hi', options); + + expect(glossaryListScope.isDone()).toBe(true); + expect(tmListScope.isDone()).toBe(true); + expect(translateScope.isDone()).toBe(true); + expect(nock.isDone()).toBe(true); + }); + }); +}); diff --git a/tests/integration/cli-usage.integration.test.ts b/tests/integration/cli-usage.integration.test.ts index 1b2bf60..110996b 100644 --- a/tests/integration/cli-usage.integration.test.ts +++ b/tests/integration/cli-usage.integration.test.ts @@ -34,7 +34,7 @@ describe('Usage CLI Integration', () => { expect.assertions(1); try { - runCLI('deepl usage', { stdio: 'pipe' }); + runCLI('deepl usage', { stdio: 'pipe', excludeApiKey: true }); } catch (error: any) { const output = error.stderr ?? error.stdout; // Should indicate API key is required diff --git a/tests/integration/sync-auto-commit.integration.test.ts b/tests/integration/sync-auto-commit.integration.test.ts new file mode 100644 index 0000000..5d3d380 --- /dev/null +++ b/tests/integration/sync-auto-commit.integration.test.ts @@ -0,0 +1,245 @@ +/** + * Auto-commit preflight integration tests. + * + * These tests exercise `SyncCommand.run({ autoCommit: true })` against a real + * git repository initialized in a temp dir. The preflight semantics are + * git-specific (dirty tree detection, mid-rebase, detached HEAD) — mocking + * git defeats the point. + */ + +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +jest.unmock('child_process'); +jest.unmock('fast-glob'); + +import nock from 'nock'; +import { SyncCommand } from '../../src/cli/commands/sync-command'; +import { SyncService } from '../../src/sync/sync-service'; +import { TranslationService } from '../../src/services/translation'; +import { GlossaryService } from '../../src/services/glossary'; +import { DeepLClient } from '../../src/api/deepl-client'; +import { FormatRegistry } from '../../src/formats/index'; +import { JsonFormatParser } from '../../src/formats/json'; +import { ValidationError } from '../../src/utils/errors'; +import { DEEPL_FREE_API_URL, TEST_API_KEY } from '../helpers/nock-setup'; +import { createMockConfigService, createMockCacheService } from '../helpers/mock-factories'; + +function gitAvailable(): boolean { + try { + execFileSync('git', ['--version'], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +function git(cwd: string, args: string[]): string { + return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' }); +} + +function initRepo(cwd: string): void { + git(cwd, ['init', '-q', '-b', 'main']); + git(cwd, ['config', 'user.email', 'test@example.com']); + git(cwd, ['config', 'user.name', 'Test User']); + git(cwd, ['config', 'commit.gpgsign', 'false']); +} + +function createServices() { + const client = new DeepLClient(TEST_API_KEY, { maxRetries: 0 }); + const mockConfig = createMockConfigService({ + get: jest.fn(() => ({ + auth: {}, + api: { baseUrl: '', usePro: false }, + defaults: { targetLangs: [], formality: 'default', preserveFormatting: false }, + cache: { enabled: false }, + output: { format: 'text', color: true }, + proxy: {}, + })), + getValue: jest.fn(() => false), + }); + const mockCache = createMockCacheService(); + const translationService = new TranslationService(client, mockConfig, mockCache); + const glossaryService = new GlossaryService(client); + const registry = new FormatRegistry(); + registry.register(new JsonFormatParser()); + const syncService = new SyncService(translationService, glossaryService, registry); + return { client, syncService }; +} + +const SYNC_YAML = `version: 1 +source_locale: en +target_locales: + - de +buckets: + json: + include: + - "locales/en.json" +`; + +const SOURCE_JSON = JSON.stringify({ greeting: 'Hello' }, null, 2) + '\n'; + +function seedTranslateMock(): nock.Scope { + return nock(DEEPL_FREE_API_URL) + .post('/v2/translate') + .reply(200, { + translations: [ + { text: 'Hallo', detected_source_language: 'EN', billed_characters: 5 }, + ], + }); +} + +function seedRepo(tmpDir: string): void { + initRepo(tmpDir); + fs.writeFileSync(path.join(tmpDir, '.deepl-sync.yaml'), SYNC_YAML, 'utf-8'); + fs.mkdirSync(path.join(tmpDir, 'locales'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'locales', 'en.json'), SOURCE_JSON, 'utf-8'); + fs.writeFileSync(path.join(tmpDir, 'README.md'), '# test\n', 'utf-8'); + git(tmpDir, ['add', '.']); + git(tmpDir, ['commit', '-q', '-m', 'initial']); +} + +const describeIfGit = gitAvailable() ? describe : describe.skip; + +describeIfGit('SyncCommand auto-commit preflight', () => { + let tmpDir: string; + let client: DeepLClient; + let syncService: SyncService; + let originalCwd: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-sync-autocommit-')); + originalCwd = process.cwd(); + process.chdir(tmpDir); + const services = createServices(); + client = services.client; + syncService = services.syncService; + }); + + afterEach(() => { + process.chdir(originalCwd); + client.destroy(); + if (fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + nock.cleanAll(); + }); + + it('clean repo: auto-commit succeeds and stages only translation targets + lockfile', async () => { + seedRepo(tmpDir); + const scope = seedTranslateMock(); + + const command = new SyncCommand(syncService); + await command.run({ autoCommit: true }); + + expect(scope.isDone()).toBe(true); + + const head = git(tmpDir, ['rev-parse', 'HEAD']); + const firstCommit = git(tmpDir, ['rev-list', '--max-parents=0', 'HEAD']).trim(); + expect(head.trim()).not.toBe(firstCommit); + + const branch = git(tmpDir, ['symbolic-ref', '--short', 'HEAD']).trim(); + expect(branch).toBe('main'); + + const changedFiles = git(tmpDir, ['show', '--name-only', '--pretty=format:', 'HEAD']) + .split('\n') + .map(s => s.trim()) + .filter(Boolean) + .sort(); + expect(changedFiles).toEqual(['.deepl-sync.lock', 'locales/de.json']); + + const msg = git(tmpDir, ['log', '-1', '--pretty=%s']).trim(); + expect(msg).toContain('chore(i18n)'); + expect(msg).toContain('de'); + }); + + it('dirty tree with unrelated changes: auto-commit refused with ValidationError naming offending files', async () => { + seedRepo(tmpDir); + fs.writeFileSync(path.join(tmpDir, 'README.md'), '# changed\n', 'utf-8'); + fs.writeFileSync(path.join(tmpDir, 'src.ts'), 'export {};\n', 'utf-8'); + const scope = seedTranslateMock(); + + const command = new SyncCommand(syncService); + + await expect(command.run({ autoCommit: true })).rejects.toThrow(ValidationError); + try { + await command.run({ autoCommit: true }); + } catch (err) { + const msg = (err as Error).message; + expect(msg).toMatch(/Refusing to auto-commit/i); + expect(msg).toContain('README.md'); + expect(msg).toContain('src.ts'); + } + + scope.persist(); + nock.cleanAll(); + + const headMsg = git(tmpDir, ['log', '-1', '--pretty=%s']).trim(); + expect(headMsg).toBe('initial'); + }); + + it('mid-rebase state: auto-commit refused', async () => { + seedRepo(tmpDir); + // Simulate a mid-rebase repo by writing the rebase-merge directory. + const rebaseDir = path.join(tmpDir, '.git', 'rebase-merge'); + fs.mkdirSync(rebaseDir, { recursive: true }); + fs.writeFileSync(path.join(rebaseDir, 'head-name'), 'refs/heads/main\n', 'utf-8'); + seedTranslateMock(); + + const command = new SyncCommand(syncService); + await expect(command.run({ autoCommit: true })).rejects.toThrow(ValidationError); + try { + await command.run({ autoCommit: true }); + } catch (err) { + expect((err as Error).message).toMatch(/Refusing to auto-commit/i); + expect((err as Error).message).toMatch(/rebase|merge|cherry-pick|in progress/i); + } + }); + + it('detached HEAD: auto-commit refused', async () => { + seedRepo(tmpDir); + // Add a second commit then detach at the first. + fs.writeFileSync(path.join(tmpDir, 'x.txt'), 'x\n', 'utf-8'); + git(tmpDir, ['add', 'x.txt']); + git(tmpDir, ['commit', '-q', '-m', 'second']); + const first = git(tmpDir, ['rev-list', '--max-parents=0', 'HEAD']).trim(); + git(tmpDir, ['checkout', '-q', first]); + seedTranslateMock(); + + const command = new SyncCommand(syncService); + await expect(command.run({ autoCommit: true })).rejects.toThrow(ValidationError); + try { + await command.run({ autoCommit: true }); + } catch (err) { + expect((err as Error).message).toMatch(/Refusing to auto-commit/i); + expect((err as Error).message).toMatch(/detached HEAD/i); + } + }); + + it('clean repo, no lockfile update: auto-commit does not fail staging a nonexistent lockfile', async () => { + seedRepo(tmpDir); + const scope = seedTranslateMock(); + + // Run once to establish a lock file, then commit it ourselves so the next + // sync finds nothing to do (no lock write, no file writes). + const command = new SyncCommand(syncService); + await command.run({ autoCommit: true }); + expect(scope.isDone()).toBe(true); + + // Delete the lockfile without committing its removal, then sync again. + // The second sync must not stage .deepl-sync.lock when it wasn't produced. + // To simulate the "no lockfile written" case cleanly, we re-run with + // identical source — no diffs, no writes. + // First ensure the tree is clean. + git(tmpDir, ['status', '--porcelain']); // sanity + + // Second run: no API mocks needed since nothing to translate. + const command2 = new SyncCommand(syncService); + await expect(command2.run({ autoCommit: true })).resolves.toBeDefined(); + // HEAD should be the auto-commit from the first run; no new commit. + const commitCount = git(tmpDir, ['rev-list', '--count', 'HEAD']).trim(); + expect(Number(commitCount)).toBe(2); + }); +}); diff --git a/tests/integration/sync-concurrent.integration.test.ts b/tests/integration/sync-concurrent.integration.test.ts new file mode 100644 index 0000000..51efdb4 --- /dev/null +++ b/tests/integration/sync-concurrent.integration.test.ts @@ -0,0 +1,251 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +jest.unmock('fast-glob'); + +import nock from 'nock'; +import { SyncService } from '../../src/sync/sync-service'; +import { TranslationService } from '../../src/services/translation'; +import { GlossaryService } from '../../src/services/glossary'; +import { DeepLClient } from '../../src/api/deepl-client'; +import { FormatRegistry } from '../../src/formats/index'; +import { JsonFormatParser } from '../../src/formats/json'; +import { loadSyncConfig } from '../../src/sync/sync-config'; +import { ConfigError } from '../../src/utils/errors'; +import { PROCESS_LOCK_FILE_NAME } from '../../src/sync/sync-process-lock'; +import { DEEPL_FREE_API_URL, TEST_API_KEY } from '../helpers/nock-setup'; +import { createMockConfigService, createMockCacheService } from '../helpers/mock-factories'; +import { handleSyncPull } from '../../src/cli/commands/sync/register-sync-pull'; + +function createServices(): { client: DeepLClient; syncService: SyncService } { + const client = new DeepLClient(TEST_API_KEY, { maxRetries: 0 }); + const mockConfig = createMockConfigService({ + get: jest.fn(() => ({ + auth: {}, + api: { baseUrl: '', usePro: false }, + defaults: { targetLangs: [], formality: 'default', preserveFormatting: false }, + cache: { enabled: false }, + output: { format: 'text', color: true }, + proxy: {}, + })), + getValue: jest.fn(() => false), + }); + const mockCache = createMockCacheService(); + const translationService = new TranslationService(client, mockConfig, mockCache); + const glossaryService = new GlossaryService(client); + const registry = new FormatRegistry(); + registry.register(new JsonFormatParser()); + const syncService = new SyncService(translationService, glossaryService, registry); + return { client, syncService }; +} + +const BASIC_CONFIG_YAML = `version: 1 +source_locale: en +target_locales: + - de +buckets: + json: + include: + - "locales/en.json" +`; + +const SOURCE_JSON = JSON.stringify({ greeting: 'Hello' }, null, 2) + '\n'; + +function writeYamlConfig(dir: string, yaml: string): void { + fs.writeFileSync(path.join(dir, '.deepl-sync.yaml'), yaml, 'utf-8'); +} + +function writeSourceFile(dir: string, relPath: string, content: string): void { + const absPath = path.join(dir, relPath); + fs.mkdirSync(path.dirname(absPath), { recursive: true }); + fs.writeFileSync(absPath, content, 'utf-8'); +} + +describe('Sync Concurrent Runs Integration', () => { + let tmpDir: string; + let client: DeepLClient; + let syncService: SyncService; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-sync-concurrent-')); + const services = createServices(); + client = services.client; + syncService = services.syncService; + }); + + afterEach(() => { + client.destroy(); + if (fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + nock.cleanAll(); + }); + + it('releases the process lock so a subsequent sync in the same directory can proceed', async () => { + writeYamlConfig(tmpDir, BASIC_CONFIG_YAML); + writeSourceFile(tmpDir, 'locales/en.json', SOURCE_JSON); + + nock(DEEPL_FREE_API_URL) + .post('/v2/translate') + .reply(200, { + translations: [ + { text: 'Hallo', detected_source_language: 'EN', billed_characters: 5 }, + ], + }); + + const config = await loadSyncConfig(tmpDir); + const first = await syncService.sync(config); + expect(first.success).toBe(true); + + const pidFile = path.join(tmpDir, PROCESS_LOCK_FILE_NAME); + expect(fs.existsSync(pidFile)).toBe(false); + + nock(DEEPL_FREE_API_URL) + .post('/v2/translate') + .reply(200, { translations: [] }); + + const second = await syncService.sync(config); + expect(second.success).toBe(true); + expect(fs.existsSync(pidFile)).toBe(false); + }); + + it('reclaims a stale pidfile left behind by a crashed process and emits a warning', async () => { + writeYamlConfig(tmpDir, BASIC_CONFIG_YAML); + writeSourceFile(tmpDir, 'locales/en.json', SOURCE_JSON); + + const pidFile = path.join(tmpDir, PROCESS_LOCK_FILE_NAME); + const stalePid = findDeadPid(); + fs.writeFileSync( + pidFile, + JSON.stringify({ pid: stalePid, startedAt: new Date(Date.now() - 60_000).toISOString() }), + 'utf-8', + ); + + const warnSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + nock(DEEPL_FREE_API_URL) + .post('/v2/translate') + .reply(200, { + translations: [ + { text: 'Hallo', detected_source_language: 'EN', billed_characters: 5 }, + ], + }); + + try { + const config = await loadSyncConfig(tmpDir); + const result = await syncService.sync(config); + expect(result.success).toBe(true); + expect(fs.existsSync(pidFile)).toBe(false); + + const warnCalls = warnSpy.mock.calls.map(args => args.join(' ')).join('\n'); + expect(warnCalls.toLowerCase()).toContain('stale'); + } finally { + warnSpy.mockRestore(); + } + }); + + it('refuses to start and throws ConfigError when the pidfile names a live process', async () => { + writeYamlConfig(tmpDir, BASIC_CONFIG_YAML); + writeSourceFile(tmpDir, 'locales/en.json', SOURCE_JSON); + + const pidFile = path.join(tmpDir, PROCESS_LOCK_FILE_NAME); + fs.writeFileSync( + pidFile, + JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }), + 'utf-8', + ); + + const config = await loadSyncConfig(tmpDir); + + await expect(syncService.sync(config)).rejects.toMatchObject({ + name: 'ConfigError', + exitCode: 7, + }); + + expect(fs.existsSync(pidFile)).toBe(true); + + const raw = fs.readFileSync(pidFile, 'utf-8'); + const parsed = JSON.parse(raw) as { pid: number }; + expect(parsed.pid).toBe(process.pid); + + fs.unlinkSync(pidFile); + }); +}); + +function findDeadPid(): number { + for (let candidate = 99999; candidate < 200000; candidate++) { + try { + process.kill(candidate, 0); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ESRCH') { + return candidate; + } + } + } + throw new Error('could not find a dead PID for test setup'); +} + +// ConfigError import is validated against thrown error shape via toMatchObject above. +void ConfigError; + +describe('sync pull concurrent lock guard', () => { + const TMS_YAML = `version: 1 +source_locale: en +target_locales: + - de +buckets: + json: + include: + - "locales/en.json" +tms: + enabled: true + server: https://tms.test + project_id: proj-test +`; + + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-sync-pull-lock-')); + fs.writeFileSync(path.join(tmpDir, '.deepl-sync.yaml'), TMS_YAML, 'utf-8'); + const localesDir = path.join(tmpDir, 'locales'); + fs.mkdirSync(localesDir, { recursive: true }); + fs.writeFileSync(path.join(localesDir, 'en.json'), JSON.stringify({ hello: 'Hello' }, null, 2) + '\n', 'utf-8'); + process.env['TMS_API_KEY'] = 'test-key'; + }); + + afterEach(() => { + delete process.env['TMS_API_KEY']; + if (fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + nock.cleanAll(); + }); + + it('rejects with ConfigError when a concurrent deepl sync holds the pidfile lock', async () => { + const pidFile = path.join(tmpDir, PROCESS_LOCK_FILE_NAME); + fs.writeFileSync( + pidFile, + JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }), + 'utf-8', + ); + + const handleError = jest.fn((err: Error) => { throw err; }); + + await expect( + handleSyncPull({ syncConfig: path.join(tmpDir, '.deepl-sync.yaml'), format: 'text' }, handleError), + ).rejects.toMatchObject({ + name: 'ConfigError', + exitCode: 7, + }); + + expect(fs.existsSync(pidFile)).toBe(true); + const raw = fs.readFileSync(pidFile, 'utf-8'); + const parsed = JSON.parse(raw) as { pid: number }; + expect(parsed.pid).toBe(process.pid); + + fs.unlinkSync(pidFile); + }); +}); diff --git a/tests/integration/sync-export-path-traversal.integration.test.ts b/tests/integration/sync-export-path-traversal.integration.test.ts new file mode 100644 index 0000000..283dcbf --- /dev/null +++ b/tests/integration/sync-export-path-traversal.integration.test.ts @@ -0,0 +1,115 @@ +/** + * Security integration tests: `sync export` must refuse to read source files + * that resolve outside the configured project root. + * + * `followSymbolicLinks: false` in the bucket walker already prevents + * fast-glob from traversing symlinks inside the project tree. But an + * attacker who can influence `.deepl-sync.yaml` can still point a bucket's + * `include` pattern at an absolute path outside the project (e.g. + * `/etc/passwd` or a sibling repo containing secrets), and fast-glob will + * happily resolve and return that absolute path. The export pipeline would + * then read the file and embed its contents in the generated XLIFF. + * + * The fix root-anchors every source file against `config.projectRoot` via + * `assertPathWithinRoot`, matching the existing `--output` destination guard. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +jest.unmock('fast-glob'); + +import { exportTranslations } from '../../src/sync/sync-export'; +import { loadSyncConfig } from '../../src/sync/sync-config'; +import { ValidationError } from '../../src/utils/errors'; + +import { createSyncHarness, writeSyncConfig } from '../helpers/sync-harness'; + +function writeJson(dir: string, relPath: string, obj: unknown): void { + const abs = path.join(dir, relPath); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, JSON.stringify(obj, null, 2) + '\n', 'utf-8'); +} + +describe('sync export source-side path-traversal safety', () => { + let projectDir: string; + let outsideDir: string; + let outsideSecret: string; + let harness: ReturnType; + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-sync-export-traversal-proj-')); + outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-sync-export-traversal-secret-')); + outsideSecret = path.join(outsideDir, 'secret.json'); + fs.writeFileSync( + outsideSecret, + JSON.stringify({ exfiltrated: 'TOP_SECRET_VALUE' }, null, 2), + 'utf-8', + ); + harness = createSyncHarness({ parsers: ['json'] }); + }); + + afterEach(() => { + harness.cleanup(); + if (fs.existsSync(projectDir)) fs.rmSync(projectDir, { recursive: true, force: true }); + if (fs.existsSync(outsideDir)) fs.rmSync(outsideDir, { recursive: true, force: true }); + }); + + it('rejects absolute include patterns that resolve outside the project root', async () => { + writeJson(projectDir, 'locales/en.json', { greeting: 'Hello' }); + writeSyncConfig(projectDir, { + targetLocales: ['de'], + // Absolute include patterns bypass the cwd-anchored fast-glob search. + buckets: { json: { include: [path.join(outsideDir, '*.json')] } }, + }); + + const config = await loadSyncConfig(projectDir); + + await expect(exportTranslations(config, harness.registry)).rejects.toThrow(ValidationError); + await expect(exportTranslations(config, harness.registry)).rejects.toThrow( + /escapes project root/, + ); + }); + + it('does not embed outside-the-root file contents in the XLIFF output', async () => { + writeJson(projectDir, 'locales/en.json', { greeting: 'Hello' }); + writeSyncConfig(projectDir, { + targetLocales: ['de'], + buckets: { json: { include: [path.join(outsideDir, '*.json')] } }, + }); + + const config = await loadSyncConfig(projectDir); + + let caught: unknown; + let result: Awaited> | undefined; + try { + result = await exportTranslations(config, harness.registry); + } catch (err) { + caught = err; + } + + expect(caught).toBeInstanceOf(ValidationError); + expect(result).toBeUndefined(); + // And make sure the secret never leaked into an XLIFF buffer. + // (result is undefined, so this is belt-and-braces.) + if (result !== undefined) { + expect((result as { content: string }).content).not.toContain('TOP_SECRET_VALUE'); + } + }); + + it('allows source paths inside the project root', async () => { + writeJson(projectDir, 'locales/en.json', { greeting: 'Hello' }); + writeSyncConfig(projectDir, { + targetLocales: ['de'], + buckets: { json: { include: ['locales/*.json'] } }, + }); + + const config = await loadSyncConfig(projectDir); + const result = await exportTranslations(config, harness.registry); + + expect(result.files).toBe(1); + expect(result.keys).toBe(1); + expect(result.content).toContain('Hello'); + }); +}); diff --git a/tests/integration/sync-init-format-choices.integration.test.ts b/tests/integration/sync-init-format-choices.integration.test.ts new file mode 100644 index 0000000..160256f --- /dev/null +++ b/tests/integration/sync-init-format-choices.integration.test.ts @@ -0,0 +1,50 @@ +/** + * Integration test: asserts that the `--file-format` choices registered on + * `deepl sync init` match the canonical format keys exposed by the default + * {@link FormatRegistry}. Prevents silent divergence between parser + * registration and CLI help. + */ + +import { Command } from 'commander'; +import { registerSync } from '../../src/cli/commands/register-sync'; +import { + SUPPORTED_FORMAT_KEYS, + createDefaultRegistry, +} from '../../src/formats'; +import type { ServiceDeps } from '../../src/cli/commands/service-factory'; + +function makeDeps(): ServiceDeps { + const handleError = jest.fn(); + return { + createDeepLClient: jest.fn() as unknown as ServiceDeps['createDeepLClient'], + getApiKeyAndOptions: jest.fn() as unknown as ServiceDeps['getApiKeyAndOptions'], + getConfigService: jest.fn() as unknown as ServiceDeps['getConfigService'], + getCacheService: jest.fn() as unknown as ServiceDeps['getCacheService'], + handleError: handleError as unknown as ServiceDeps['handleError'], + }; +} + +function findFileFormatChoices(program: Command): readonly string[] | undefined { + const syncCmd = program.commands.find((c) => c.name() === 'sync'); + const initCmd = syncCmd?.commands.find((c) => c.name() === 'init'); + const fileFormatOpt = initCmd?.options.find((o) => o.long === '--file-format'); + return fileFormatOpt?.argChoices; +} + +describe('deepl sync init --file-format choices mirror the format registry', () => { + it('exposes exactly the canonical SUPPORTED_FORMAT_KEYS', () => { + const program = new Command(); + registerSync(program, makeDeps()); + const choices = findFileFormatChoices(program); + expect(choices).toBeDefined(); + expect([...choices!].sort()).toEqual([...SUPPORTED_FORMAT_KEYS].sort()); + }); + + it('matches the config keys of every parser the registry loads', async () => { + const program = new Command(); + registerSync(program, makeDeps()); + const choices = findFileFormatChoices(program); + const registry = await createDefaultRegistry(); + expect([...choices!].sort()).toEqual(registry.getFormatKeys().sort()); + }); +}); diff --git a/tests/integration/sync-init.integration.test.ts b/tests/integration/sync-init.integration.test.ts new file mode 100644 index 0000000..8a044ad --- /dev/null +++ b/tests/integration/sync-init.integration.test.ts @@ -0,0 +1,253 @@ +/** + * Integration tests for sync init auto-detection per i18n framework. + * + * Uses REAL fast-glob (not mocked) against real filesystem structures + * to verify that detectI18nFiles() produces include patterns that + * actually match source files — the exact bug that sync-qsn reported. + */ + +jest.unmock('fast-glob'); + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as YAML from 'yaml'; +import fg from 'fast-glob'; +import { + detectI18nFiles, + generateSyncConfig, +} from '../../src/sync/sync-init'; + +function makeTmpDir(label: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `deepl-sync-init-integ-${label}-`)); + return dir; +} + +function writeFile(root: string, relPath: string, content: string): void { + const absPath = path.join(root, relPath); + fs.mkdirSync(path.dirname(absPath), { recursive: true }); + fs.writeFileSync(absPath, content, 'utf-8'); +} + +interface ParsedConfig { + version: number; + source_locale: string; + target_locales: string[]; + buckets: Record; +} + +describe('sync init auto-detection with real fast-glob', () => { + let tmpDir: string; + + afterEach(() => { + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + describe('JSON (i18next)', () => { + it('should detect, generate config, and match source files with fast-glob', async () => { + tmpDir = makeTmpDir('json'); + writeFile(tmpDir, 'locales/en.json', '{"greeting":"Hello","farewell":"Goodbye"}'); + + const detected = await detectI18nFiles(tmpDir); + const project = detected.find(p => p.format === 'json'); + expect(project).toBeDefined(); + expect(project!.pattern).toBe('locales/en.json'); + expect(project!.targetPathPattern).toBeUndefined(); + expect(project!.keyCount).toBe(2); + + const yaml = generateSyncConfig({ + sourceLocale: 'en', + targetLocales: ['de'], + format: project!.format, + pattern: project!.pattern, + targetPathPattern: project!.targetPathPattern, + }); + const parsed = YAML.parse(yaml) as ParsedConfig; + expect(parsed.buckets['json']!.target_path_pattern).toBeUndefined(); + + const matched = await fg(parsed.buckets['json']!.include, { cwd: tmpDir }); + expect(matched).toContain('locales/en.json'); + }); + }); + + describe('YAML (Rails)', () => { + it('should detect, generate config, and match source files with fast-glob', async () => { + tmpDir = makeTmpDir('yaml'); + writeFile(tmpDir, 'locales/en.yaml', 'greeting: Hello\nfarewell: Goodbye\n'); + + const detected = await detectI18nFiles(tmpDir); + const project = detected.find(p => p.format === 'yaml'); + expect(project).toBeDefined(); + expect(project!.pattern).toBe('locales/en.yaml'); + expect(project!.targetPathPattern).toBeUndefined(); + + const yaml = generateSyncConfig({ + sourceLocale: 'en', + targetLocales: ['de'], + format: project!.format, + pattern: project!.pattern, + }); + const parsed = YAML.parse(yaml) as ParsedConfig; + + const matched = await fg(parsed.buckets['yaml']!.include, { cwd: tmpDir }); + expect(matched).toContain('locales/en.yaml'); + }); + }); + + describe('Android XML', () => { + it('should detect with target_path_pattern and match source files', async () => { + tmpDir = makeTmpDir('android'); + writeFile(tmpDir, 'res/values/strings.xml', + '\nApp'); + + const detected = await detectI18nFiles(tmpDir); + const project = detected.find(p => p.format === 'android_xml'); + expect(project).toBeDefined(); + expect(project!.pattern).toBe('res/values/strings.xml'); + expect(project!.targetPathPattern).toBe('res/values-{locale}/strings.xml'); + + const yaml = generateSyncConfig({ + sourceLocale: 'en', + targetLocales: ['de', 'fr'], + format: project!.format, + pattern: project!.pattern, + targetPathPattern: project!.targetPathPattern, + }); + const parsed = YAML.parse(yaml) as ParsedConfig; + expect(parsed.buckets['android_xml']!.target_path_pattern).toBe('res/values-{locale}/strings.xml'); + + const matched = await fg(parsed.buckets['android_xml']!.include, { cwd: tmpDir }); + expect(matched).toContain('res/values/strings.xml'); + }); + + it('should resolve target paths from target_path_pattern', async () => { + const { resolveTargetPath } = await import('../../src/sync/sync-utils'); + const result = resolveTargetPath( + 'res/values/strings.xml', 'en', 'de', + 'res/values-{locale}/strings.xml', + ); + expect(result).toBe('res/values-de/strings.xml'); + }); + }); + + describe('iOS Strings', () => { + it('should detect and match source files (locale in path)', async () => { + tmpDir = makeTmpDir('ios'); + writeFile(tmpDir, 'en.lproj/Localizable.strings', '"greeting" = "Hello";\n"farewell" = "Goodbye";'); + + const detected = await detectI18nFiles(tmpDir); + const project = detected.find(p => p.format === 'ios_strings'); + expect(project).toBeDefined(); + expect(project!.pattern).toBe('en.lproj/Localizable.strings'); + expect(project!.targetPathPattern).toBeUndefined(); + + const yaml = generateSyncConfig({ + sourceLocale: 'en', + targetLocales: ['de'], + format: project!.format, + pattern: project!.pattern, + }); + const parsed = YAML.parse(yaml) as ParsedConfig; + + const matched = await fg(parsed.buckets['ios_strings']!.include, { cwd: tmpDir }); + expect(matched).toContain('en.lproj/Localizable.strings'); + }); + }); + + describe('PO (Django)', () => { + it('should detect and match source files (locale in path)', async () => { + tmpDir = makeTmpDir('po'); + writeFile(tmpDir, 'locale/en/LC_MESSAGES/django.po', + 'msgid "greeting"\nmsgstr "Hello"\n'); + + const detected = await detectI18nFiles(tmpDir); + const project = detected.find(p => p.format === 'po'); + expect(project).toBeDefined(); + expect(project!.pattern).toBe('locale/en/LC_MESSAGES/*.po'); + expect(project!.targetPathPattern).toBeUndefined(); + + const yaml = generateSyncConfig({ + sourceLocale: 'en', + targetLocales: ['de'], + format: project!.format, + pattern: project!.pattern, + }); + const parsed = YAML.parse(yaml) as ParsedConfig; + + const matched = await fg(parsed.buckets['po']!.include, { cwd: tmpDir }); + expect(matched).toContain('locale/en/LC_MESSAGES/django.po'); + }); + }); + + describe('XLIFF (Angular)', () => { + it('should detect with target_path_pattern and match source files', async () => { + tmpDir = makeTmpDir('xliff'); + writeFile(tmpDir, 'src/locale/messages.xlf', + '\n'); + + const detected = await detectI18nFiles(tmpDir); + const project = detected.find(p => p.format === 'xliff'); + expect(project).toBeDefined(); + expect(project!.pattern).toBe('src/locale/messages.xlf'); + expect(project!.targetPathPattern).toBe('src/locale/messages.{locale}.xlf'); + + const yaml = generateSyncConfig({ + sourceLocale: 'en', + targetLocales: ['de'], + format: project!.format, + pattern: project!.pattern, + targetPathPattern: project!.targetPathPattern, + }); + const parsed = YAML.parse(yaml) as ParsedConfig; + expect(parsed.buckets['xliff']!.target_path_pattern).toBe('src/locale/messages.{locale}.xlf'); + + const matched = await fg(parsed.buckets['xliff']!.include, { cwd: tmpDir }); + expect(matched).toContain('src/locale/messages.xlf'); + }); + + it('should resolve target paths from target_path_pattern', async () => { + const { resolveTargetPath } = await import('../../src/sync/sync-utils'); + const result = resolveTargetPath( + 'src/locale/messages.xlf', 'en', 'de', + 'src/locale/messages.{locale}.xlf', + ); + expect(result).toBe('src/locale/messages.de.xlf'); + }); + }); + + describe('ARB (Flutter)', () => { + it('should detect and match source files (locale in filename)', async () => { + tmpDir = makeTmpDir('arb'); + // pubspec.yaml is required by the ARB detector (Flutter-only gate). + writeFile(tmpDir, 'pubspec.yaml', 'name: app\n'); + writeFile(tmpDir, 'l10n/app_en.arb', '{"greeting":"Hello"}'); + + const detected = await detectI18nFiles(tmpDir); + const project = detected.find(p => p.format === 'arb'); + expect(project).toBeDefined(); + expect(project!.pattern).toBe('l10n/app_en.arb'); + expect(project!.targetPathPattern).toBeUndefined(); + + const yaml = generateSyncConfig({ + sourceLocale: 'en', + targetLocales: ['de'], + format: project!.format, + pattern: project!.pattern, + }); + const parsed = YAML.parse(yaml) as ParsedConfig; + + const matched = await fg(parsed.buckets['arb']!.include, { cwd: tmpDir }); + expect(matched).toContain('l10n/app_en.arb'); + }); + + it('does NOT detect ARB when pubspec.yaml is absent (Flutter-gate)', async () => { + tmpDir = makeTmpDir('arb-no-pubspec'); + writeFile(tmpDir, 'l10n/app_en.arb', '{"greeting":"Hello"}'); + + const detected = await detectI18nFiles(tmpDir); + expect(detected.find(p => p.format === 'arb')).toBeUndefined(); + }); + }); +}); diff --git a/tests/integration/sync-locale-translator-plural-perf.integration.test.ts b/tests/integration/sync-locale-translator-plural-perf.integration.test.ts new file mode 100644 index 0000000..4120f4a --- /dev/null +++ b/tests/integration/sync-locale-translator-plural-perf.integration.test.ts @@ -0,0 +1,82 @@ +/** + * Gated performance regression test for pluralSlots batchIndices membership check. + * + * Run with: SYNC_BENCH=1 npx jest sync-locale-translator-plural-perf + * + * Validates that the hot path in LocaleTranslator.translate — iterating pluralSlots + * and checking membership in batchIndices / indices — runs in O(N) via Set.has + * rather than O(N²) via Array.includes. + * + * Fixture: 5000 diffs × 1 plural slot each = 5000 pluralSlots, 5000 batchIndices. + * O(N²) at V8 linear-scan speeds (~100M ops/s) ≈ 0.25s per loop site; + * three sites × 50-locale outer = ~37s. O(N) target: <1s total. + */ + +import type { PluralSlot } from '../../src/sync/sync-message-preprocess'; + +const PLURAL_COUNT = 5_000; +const LOCALE_COUNT = 50; +const THRESHOLD_MS = 1_000; + +function buildFixture(n: number): { batchIndices: number[]; pluralSlots: PluralSlot[] } { + const batchIndices: number[] = []; + const pluralSlots: PluralSlot[] = []; + for (let i = 0; i < n; i++) { + batchIndices.push(i); + pluralSlots.push({ diffIndex: i, format: 'po', slotKey: 'msgid_plural', textIndex: n + i }); + } + return { batchIndices, pluralSlots }; +} + +describe('pluralSlots batchIndices membership lookup', () => { + it('Set.has produces identical membership results to Array.includes for known inputs', () => { + const batchIndices = [0, 2, 4, 6, 8]; + const batchSet = new Set(batchIndices); + const slots: PluralSlot[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(i => ({ + diffIndex: i, + format: 'po', + slotKey: 'msgid_plural', + textIndex: 100 + i, + })); + + for (const slot of slots) { + expect(batchSet.has(slot.diffIndex)).toBe(batchIndices.includes(slot.diffIndex)); + } + }); + + if (process.env['SYNC_BENCH']) { + it('Array.includes O(N²) baseline is measurably slow', () => { + const { batchIndices, pluralSlots } = buildFixture(PLURAL_COUNT); + const start = Date.now(); + let hits = 0; + for (let locale = 0; locale < LOCALE_COUNT; locale++) { + for (const slot of pluralSlots) { + if (batchIndices.includes(slot.diffIndex)) { + hits++; + } + } + } + const elapsed = Date.now() - start; + expect(hits).toBe(PLURAL_COUNT * LOCALE_COUNT); + console.log(`[bench] Array.includes baseline: ${elapsed}ms (hits=${hits})`); + }); + + it('Set.has O(N) lookup completes in under 1s for 50 locales × 5K pluralSlots', () => { + const { batchIndices, pluralSlots } = buildFixture(PLURAL_COUNT); + const batchSet = new Set(batchIndices); + const start = Date.now(); + let hits = 0; + for (let locale = 0; locale < LOCALE_COUNT; locale++) { + for (const slot of pluralSlots) { + if (batchSet.has(slot.diffIndex)) { + hits++; + } + } + } + const elapsed = Date.now() - start; + expect(hits).toBe(PLURAL_COUNT * LOCALE_COUNT); + expect(elapsed).toBeLessThan(THRESHOLD_MS); + console.log(`[bench] Set.has O(N): ${elapsed}ms (hits=${hits})`); + }); + } +}); diff --git a/tests/integration/sync-php-arrays.integration.test.ts b/tests/integration/sync-php-arrays.integration.test.ts new file mode 100644 index 0000000..803336c --- /dev/null +++ b/tests/integration/sync-php-arrays.integration.test.ts @@ -0,0 +1,154 @@ +/** + * Integration test: fixture-driven Laravel PHP round-trip through the + * sync bucket walker. + * + * Exercises real filesystem reads against the 15-fixture corpus under + * tests/fixtures/sync/formats/laravel-php. Verifies the walker's partition + * into translatable + skipped entries, file-size + depth cap handling, and + * the byte-equal reconstruct gate on non-collision fixtures. Complements + * the unit-level assertions in tests/unit/formats/php-arrays.test.ts by + * proving the same guarantees hold through the walker's config-resolution + * path (glob, cap injection, parser re-instantiation for custom max_depth). + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +jest.unmock('fast-glob'); + +import { walkBuckets } from '../../src/sync/sync-bucket-walker'; +import { FormatRegistry } from '../../src/formats/index'; +import { PhpArraysFormatParser } from '../../src/formats/php-arrays'; +import type { ResolvedSyncConfig } from '../../src/sync/sync-config'; + +const CORPUS_DIR = path.resolve(__dirname, '../fixtures/sync/formats/laravel-php'); + +function makeRegistry(): FormatRegistry { + const registry = new FormatRegistry(); + registry.register(new PhpArraysFormatParser()); + return registry; +} + +function makeConfig(projectRoot: string, overrides: Partial = {}): ResolvedSyncConfig { + return { + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { laravel_php: { include: ['*.php'] } }, + configPath: path.join(projectRoot, '.deepl-sync.yaml'), + projectRoot, + overrides: {}, + ...overrides, + }; +} + +async function collect(iter: AsyncIterable): Promise { + const out: T[] = []; + for await (const x of iter) out.push(x); + return out; +} + +describe('sync-php-arrays corpus integration', () => { + let projectRoot: string; + + // Copies accept-class fixtures into a temp project root so the walker's + // glob resolves against a realistic layout (not the source-tree fixtures + // directory which holds reject-class fixtures alongside). + const ACCEPT_FIXTURES = [ + '01-single-quote-escape.php', + '02-double-quote-escapes.php', + '06-mixed-syntax.php', + '07-colon-placeholder.php', + '08-trailing-commas.php', + '09-ast-idempotence.php', + '10-empty-nested-array.php', + '12-escaped-dollar.php', + '13-utf8-bom.php', + '14-literal-pipe-in-prose.php', + '15-irregular-whitespace-and-comments.php', + ]; + + beforeEach(() => { + projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-sync-laravel-')); + for (const name of ACCEPT_FIXTURES) { + fs.copyFileSync(path.join(CORPUS_DIR, name), path.join(projectRoot, name)); + } + }); + + afterEach(() => { + if (fs.existsSync(projectRoot)) { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + + it('walks the full accept corpus through walkBuckets with no extract errors', async () => { + const result = await collect(walkBuckets(makeConfig(projectRoot), makeRegistry())); + expect(result).toHaveLength(ACCEPT_FIXTURES.length); + for (const walked of result) { + expect(walked.bucket).toBe('laravel_php'); + expect(walked.parser.configKey).toBe('laravel_php'); + } + }); + + it('byte-equal reconstruct on every walked file when translations equal source', async () => { + const result = await collect(walkBuckets(makeConfig(projectRoot), makeRegistry())); + for (const walked of result) { + const translations = walked.entries.map((e) => ({ ...e, translation: e.value })); + expect(walked.parser.reconstruct(walked.content, translations)).toBe(walked.content); + } + }); + + it('tags no entries as skipped for any corpus fixture (no pipe-pluralization in 01-15)', async () => { + const result = await collect(walkBuckets(makeConfig(projectRoot), makeRegistry())); + for (const walked of result) { + expect(walked.skippedEntries).toEqual([]); + } + }); + + it('honors sync.limits.max_file_bytes by skipping an oversize file', async () => { + const bigPath = path.join(projectRoot, 'big.php'); + const padding = 'x'.repeat(5000); + fs.writeFileSync( + bigPath, + ` 'Hello'];\n`, + 'utf-8', + ); + + const config = makeConfig(projectRoot, { + sync: { + concurrency: 5, + batch_size: 50, + limits: { max_file_bytes: 1000 }, + }, + }); + const result = await collect(walkBuckets(config, makeRegistry())); + expect(result.some((w) => w.sourceFile === bigPath)).toBe(false); + }); + + it('instantiates laravel_php with an override max_depth when sync.limits.max_depth is set', async () => { + // Shallow cap forces all fixtures that have ≥2 levels of nesting + // (06, 08, 09, 10, 15) to be skipped via PhpArraysCapExceededError. + const config = makeConfig(projectRoot, { + sync: { + concurrency: 5, + batch_size: 50, + limits: { max_depth: 1 }, + }, + }); + const result = await collect(walkBuckets(config, makeRegistry())); + const remaining = result.map((w) => path.basename(w.sourceFile)).sort(); + // Only files whose return array has no nested arrays survive maxDepth=1: + // 01, 02, 07, 12, 13, 14. Files 06/08/09/10/15 get skipped. + expect(remaining).toEqual( + [ + '01-single-quote-escape.php', + '02-double-quote-escapes.php', + '07-colon-placeholder.php', + '12-escaped-dollar.php', + '13-utf8-bom.php', + '14-literal-pipe-in-prose.php', + ].sort(), + ); + }); +}); diff --git a/tests/integration/sync-properties.integration.test.ts b/tests/integration/sync-properties.integration.test.ts new file mode 100644 index 0000000..0bf2a25 --- /dev/null +++ b/tests/integration/sync-properties.integration.test.ts @@ -0,0 +1,96 @@ +/** + * Integration test: fixture-driven Java .properties round-trip through the sync pipeline. + * + * Reads tests/fixtures/sync/formats/properties/source.properties, runs it + * through SyncService with a nock-mocked DeepL response, and asserts the + * target de.properties on disk matches + * tests/fixtures/sync/formats/properties/expected-after-sync/de.properties. + * + * Closes a fixture-coverage gap: .properties was not previously exercised + * from disk fixtures, so PropertiesFormatParser.reconstruct regressions + * (escape handling, comment preservation) would slip past unit tests that + * only exercise in-memory strings. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +jest.unmock('fast-glob'); + +import nock from 'nock'; + +import { loadSyncConfig } from '../../src/sync/sync-config'; +import { PropertiesFormatParser } from '../../src/formats/properties'; +import { createSyncHarness, writeSyncConfig } from '../helpers/sync-harness'; +import { DEEPL_FREE_API_URL } from '../helpers/nock-setup'; + +const FIXTURE_DIR = path.resolve(__dirname, '../fixtures/sync/formats/properties'); + +describe('sync Java properties fixture round-trip', () => { + let tmpDir: string; + let harness: ReturnType; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-sync-properties-')); + harness = createSyncHarness({ parsers: ['properties'] }); + }); + + afterEach(() => { + harness.cleanup(); + if (fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + nock.cleanAll(); + }); + + it('translates source.properties and writes de.properties matching the expected fixture', async () => { + const source = fs.readFileSync(path.join(FIXTURE_DIR, 'source.properties'), 'utf-8'); + const expected = fs.readFileSync( + path.join(FIXTURE_DIR, 'expected-after-sync', 'de.properties'), + 'utf-8', + ); + + writeSyncConfig(tmpDir, { + targetLocales: ['de'], + buckets: { properties: { include: ['locales/en.properties'] } }, + }); + const sourcePath = path.join(tmpDir, 'locales', 'en.properties'); + fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); + fs.writeFileSync(sourcePath, source, 'utf-8'); + + const scope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate') + .reply(200, { + // Order matches alphabetical sort from PropertiesFormatParser.extract(): + // farewell, greeting, welcome + translations: [ + { text: 'Auf Wiedersehen', detected_source_language: 'EN', billed_characters: 15 }, + { text: 'Hallo', detected_source_language: 'EN', billed_characters: 5 }, + { text: 'Willkommen', detected_source_language: 'EN', billed_characters: 10 }, + ], + }); + + const config = await loadSyncConfig(tmpDir); + const result = await harness.syncService.sync(config); + + expect(result.success).toBe(true); + expect(result.newKeys).toBe(3); + expect(scope.isDone()).toBe(true); + + const targetPath = path.join(tmpDir, 'locales', 'de.properties'); + expect(fs.existsSync(targetPath)).toBe(true); + const written = fs.readFileSync(targetPath, 'utf-8'); + expect(written).toBe(expected); + + // Defense in depth: the preceding comment ("# Welcome screen messages") + // should be preserved verbatim in the target file. + expect(written).toContain('# Welcome screen messages'); + + const reparsed = new PropertiesFormatParser().extract(written); + const byKey = new Map(reparsed.map((e) => [e.key, e.value])); + expect(byKey.get('greeting')).toBe('Hallo'); + expect(byKey.get('farewell')).toBe('Auf Wiedersehen'); + expect(byKey.get('welcome')).toBe('Willkommen'); + }); +}); diff --git a/tests/integration/sync-scan-bounds.integration.test.ts b/tests/integration/sync-scan-bounds.integration.test.ts new file mode 100644 index 0000000..ceadfa2 --- /dev/null +++ b/tests/integration/sync-scan-bounds.integration.test.ts @@ -0,0 +1,121 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +jest.unmock('fast-glob'); + +import nock from 'nock'; +import { SyncService } from '../../src/sync/sync-service'; +import { TranslationService } from '../../src/services/translation'; +import { GlossaryService } from '../../src/services/glossary'; +import { DeepLClient } from '../../src/api/deepl-client'; +import { FormatRegistry } from '../../src/formats/index'; +import { JsonFormatParser } from '../../src/formats/json'; +import { loadSyncConfig } from '../../src/sync/sync-config'; +import { ValidationError } from '../../src/utils/errors'; +import { DEEPL_FREE_API_URL, TEST_API_KEY } from '../helpers/nock-setup'; +import { createMockConfigService, createMockCacheService } from '../helpers/mock-factories'; + +function createServices(): { client: DeepLClient; syncService: SyncService } { + const client = new DeepLClient(TEST_API_KEY, { maxRetries: 0 }); + const mockConfig = createMockConfigService({ + get: jest.fn(() => ({ + auth: {}, + api: { baseUrl: '', usePro: false }, + defaults: { targetLangs: [], formality: 'default', preserveFormatting: false }, + cache: { enabled: false }, + output: { format: 'text', color: true }, + proxy: {}, + })), + getValue: jest.fn(() => false), + }); + const mockCache = createMockCacheService(); + const translationService = new TranslationService(client, mockConfig, mockCache); + const glossaryService = new GlossaryService(client); + const registry = new FormatRegistry(); + registry.register(new JsonFormatParser()); + const syncService = new SyncService(translationService, glossaryService, registry); + return { client, syncService }; +} + +// Low cap so the test can create a realistic over-the-cap fixture cheaply. +const TEST_CAP = 10; +const OVER_CAP_FILES = 15; + +const CONFIG_YAML = `version: 1 +source_locale: en +target_locales: + - de +buckets: + json: + include: + - "locales/en.json" +context: + enabled: true + scan_paths: + - "src/**/*.tsx" +sync: + concurrency: 5 + batch_size: 50 + max_scan_files: ${TEST_CAP} +`; + +const SOURCE_JSON = JSON.stringify({ greeting: 'Hello' }, null, 2) + '\n'; + +describe('sync-service scan_paths: bounded walk prevents DoS on huge source trees', () => { + let tmpDir: string; + let client: DeepLClient; + let syncService: SyncService; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-sync-scan-bounds-')); + const services = createServices(); + client = services.client; + syncService = services.syncService; + }); + + afterEach(() => { + client.destroy(); + if (fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + nock.cleanAll(); + }); + + it(`throws ValidationError when scan_paths matches > max_scan_files (cap=${TEST_CAP}, fixture=${OVER_CAP_FILES})`, async () => { + const configPath = path.join(tmpDir, '.deepl-sync.yaml'); + fs.writeFileSync(configPath, CONFIG_YAML, 'utf-8'); + + const srcDir = path.join(tmpDir, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + for (let i = 0; i < OVER_CAP_FILES; i++) { + fs.writeFileSync( + path.join(srcDir, `File${i}.tsx`), + `export const k${i} = 'v${i}';\n`, + 'utf-8', + ); + } + const localePath = path.join(tmpDir, 'locales', 'en.json'); + fs.mkdirSync(path.dirname(localePath), { recursive: true }); + fs.writeFileSync(localePath, SOURCE_JSON, 'utf-8'); + + nock(DEEPL_FREE_API_URL) + .persist() + .post('/v2/translate') + .reply(200, { translations: [{ text: 'Hallo', detected_source_language: 'EN' }] }); + + const config = await loadSyncConfig(configPath, {}); + + let threw: Error | null = null; + try { + await syncService.sync(config, { dryRun: true }); + } catch (e) { + threw = e as Error; + } + + expect(threw).not.toBeNull(); + expect(threw).toBeInstanceOf(ValidationError); + expect(threw!.message).toMatch(/scan_paths matched/i); + expect(threw!.message).toMatch(/max/i); + }, 30_000); +}); diff --git a/tests/integration/sync-stale-lock-fg-coalesce.integration.test.ts b/tests/integration/sync-stale-lock-fg-coalesce.integration.test.ts new file mode 100644 index 0000000..66a7f98 --- /dev/null +++ b/tests/integration/sync-stale-lock-fg-coalesce.integration.test.ts @@ -0,0 +1,177 @@ +/** + * Regression test: stale-lock cleanup must invoke fg exactly once regardless + * of how many stale entries need scanning. + * + * Before: one fg per stale entry (N sequential full-tree scans for N entries). + * After: a single fg call with N patterns covers all stale basenames at once. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const fgCalls: Array = []; + +jest.mock('fast-glob', () => { + const real = jest.requireActual('fast-glob'); + const realFn = real.default ?? real; + const wrapped: any = async (patterns: string | string[], opts?: object): Promise => { + fgCalls.push(patterns); + return realFn(patterns, opts); + }; + // Attach static helpers so source code can call fg.escapePath etc. + Object.assign(wrapped, realFn); + return { __esModule: true, default: wrapped }; +}); + +import nock from 'nock'; +import { SyncService } from '../../src/sync/sync-service'; +import { TranslationService } from '../../src/services/translation'; +import { GlossaryService } from '../../src/services/glossary'; +import { DeepLClient } from '../../src/api/deepl-client'; +import { FormatRegistry } from '../../src/formats/index'; +import { JsonFormatParser } from '../../src/formats/json'; +import { loadSyncConfig } from '../../src/sync/sync-config'; +import { DEEPL_FREE_API_URL, TEST_API_KEY } from '../helpers/nock-setup'; +import { createMockConfigService, createMockCacheService } from '../helpers/mock-factories'; + +const STALE_COUNT = 50; + +const CONFIG_YAML = `version: 1 +source_locale: en +target_locales: + - de +buckets: + json: + include: + - "locales/en.json" +`; + +const SOURCE_JSON = JSON.stringify({ greeting: 'Hello' }, null, 2) + '\n'; + +function createServices(): { client: DeepLClient; syncService: SyncService } { + const client = new DeepLClient(TEST_API_KEY, { maxRetries: 0 }); + const mockConfig = createMockConfigService({ + get: jest.fn(() => ({ + auth: {}, + api: { baseUrl: '', usePro: false }, + defaults: { targetLangs: [], formality: 'default', preserveFormatting: false }, + cache: { enabled: false }, + output: { format: 'text', color: true }, + proxy: {}, + })), + getValue: jest.fn(() => false), + }); + const mockCache = createMockCacheService(); + const translationService = new TranslationService(client, mockConfig, mockCache); + const glossaryService = new GlossaryService(client); + const registry = new FormatRegistry(); + registry.register(new JsonFormatParser()); + const syncService = new SyncService(translationService, glossaryService, registry); + return { client, syncService }; +} + +describe('sync-service stale-lock fg coalesce', () => { + let tmpDir: string; + let client: DeepLClient; + let syncService: SyncService; + + beforeEach(() => { + fgCalls.length = 0; + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-sync-stale-fg-')); + const services = createServices(); + client = services.client; + syncService = services.syncService; + }); + + afterEach(() => { + client.destroy(); + if (fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + nock.cleanAll(); + }); + + it(`invokes fg exactly once for ${STALE_COUNT} stale lock entries`, async () => { + const configPath = path.join(tmpDir, '.deepl-sync.yaml'); + fs.writeFileSync(configPath, CONFIG_YAML, 'utf-8'); + + const localeDir = path.join(tmpDir, 'locales'); + fs.mkdirSync(localeDir, { recursive: true }); + fs.writeFileSync( + path.join(localeDir, 'en.json'), + SOURCE_JSON, + 'utf-8', + ); + fs.writeFileSync( + path.join(localeDir, 'de.json'), + JSON.stringify({ greeting: 'Hallo' }, null, 2) + '\n', + 'utf-8', + ); + + // Seed lock file with STALE_COUNT entries for files that no longer exist. + // Each entry must have the correct SyncLockFile shape: + // entries[filePath][keyName] = { source_hash, source_text, translations } + const lockPath = path.join(tmpDir, '.deepl-sync.lock'); + const entries: Record; + }>> = {}; + for (let i = 0; i < STALE_COUNT; i++) { + const filePath = 'old/path/stale_' + String(i) + '.json'; + entries[filePath] = { + greeting: { + source_hash: 'deadbeef' + String(i).padStart(4, '0'), + source_text: 'Hello', + translations: { de: { hash: 'aabbccdd', status: 'translated' } }, + }, + }; + } + // The real source file must also appear in entries so the sync doesn't + // re-translate it; we leave its hash blank so it registers as stale-key + // (not a stale lock GC candidate, since processedFiles will mark it seen). + entries['locales/en.json'] = { + greeting: { + source_hash: '', + source_text: '', + translations: {}, + }, + }; + const now = new Date().toISOString(); + fs.writeFileSync( + lockPath, + JSON.stringify({ + _comment: 'test', + version: 1, + generated_at: now, + source_locale: 'en', + entries, + stats: { total_keys: STALE_COUNT + 1, total_translations: STALE_COUNT, last_sync: now }, + glossary_ids: {}, + }, null, 2), + 'utf-8', + ); + + nock(DEEPL_FREE_API_URL) + .persist() + .post('/v2/translate') + .reply(200, { translations: [{ text: 'Hallo', detected_source_language: 'EN' }] }); + + const config = await loadSyncConfig(configPath, {}); + await syncService.sync(config, {}); + + // Count fg calls whose first pattern starts with "**/" — these are the + // stale-entry sweep calls. + const staleSweepCalls = fgCalls.filter((p) => { + const first = Array.isArray(p) ? p[0] : p; + return typeof first === 'string' && first.startsWith('**/'); + }); + + expect(staleSweepCalls).toHaveLength(1); + + const patterns = staleSweepCalls[0]; + expect(Array.isArray(patterns)).toBe(true); + expect((patterns as string[]).length).toBe(STALE_COUNT); + }, 30_000); +}); diff --git a/tests/integration/sync-symlink-safety.integration.test.ts b/tests/integration/sync-symlink-safety.integration.test.ts new file mode 100644 index 0000000..b674da5 --- /dev/null +++ b/tests/integration/sync-symlink-safety.integration.test.ts @@ -0,0 +1,204 @@ +/** + * Security integration tests: sync push/pull/export/validate must refuse to + * follow symbolic links when scanning source files via fast-glob. + * + * A malicious symlink committed in a repo (e.g., `locales/en.json` -> `/etc/passwd` + * or SSH keys) would otherwise be silently followed and its contents shipped to + * the TMS server, embedded in an exported XLIFF, or surfaced in validator error + * messages. sync-service.ts and sync-context.ts already pass + * `followSymbolicLinks: false` to fast-glob; these tests lock in the same + * policy for push, pull, export, and validate. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +jest.unmock('fast-glob'); + +import nock from 'nock'; + +import { createTmsClient } from '../../src/sync/tms-client'; +import { pushTranslations, pullTranslations } from '../../src/sync/sync-tms'; +import { exportTranslations } from '../../src/sync/sync-export'; +import { validateTranslations } from '../../src/sync/sync-validate'; +import { loadSyncConfig } from '../../src/sync/sync-config'; + +import { createSyncHarness, writeSyncConfig } from '../helpers/sync-harness'; +import { expectTmsPush, expectTmsPull, tmsConfig } from '../helpers/tms-nock'; + +function writeJson(dir: string, relPath: string, obj: unknown): void { + const abs = path.join(dir, relPath); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, JSON.stringify(obj, null, 2) + '\n', 'utf-8'); +} + +/** + * Create a symlink inside the project that points at a file OUTSIDE the + * project root. Returns the path to the outside-the-root target so tests can + * assert it was never read. + * + * Skips the test (not fails) on systems that cannot create symlinks (e.g., + * Windows without developer mode). + */ +function trySymlink(linkPath: string, targetPath: string): boolean { + try { + fs.mkdirSync(path.dirname(linkPath), { recursive: true }); + fs.symlinkSync(targetPath, linkPath); + return true; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'EPERM' || code === 'ENOSYS') return false; + throw err; + } +} + +describe('sync symlink safety (push/pull/export/validate)', () => { + let projectDir: string; + let outsideDir: string; + let outsideSecret: string; + let harness: ReturnType; + const originalEnv = { ...process.env }; + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-sync-symlink-proj-')); + outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-sync-symlink-secret-')); + outsideSecret = path.join(outsideDir, 'secret.json'); + fs.writeFileSync( + outsideSecret, + JSON.stringify({ exfiltrated: 'TOP_SECRET_VALUE' }, null, 2), + 'utf-8', + ); + harness = createSyncHarness({ parsers: ['json'] }); + delete process.env['TMS_API_KEY']; + delete process.env['TMS_TOKEN']; + }); + + afterEach(() => { + harness.cleanup(); + if (fs.existsSync(projectDir)) fs.rmSync(projectDir, { recursive: true, force: true }); + if (fs.existsSync(outsideDir)) fs.rmSync(outsideDir, { recursive: true, force: true }); + nock.cleanAll(); + process.env = { ...originalEnv }; + }); + + it('push: refuses to follow a symlink pointing outside the project root; real siblings still pushed', async () => { + // Project layout: + // locales/en.json -- real source + // locales/de.json -- real target (pushed) + // locales/secrets/en.json -- SYMLINK -> outside secret + // locales/secrets/de.json -- real file with "exfiltrated" key + // + // If sync-tms.ts FOLLOWS the symlink, fast-glob returns both en.json files. + // For each source, push reads its target-locale sibling (de.json in same + // dir) and pushes those keys. So "exfiltrated" would hit the wire. + // + // With followSymbolicLinks:false, the symlinked locales/secrets/en.json is + // silently skipped and only greeting is pushed. + if (!trySymlink(path.join(projectDir, 'locales', 'secrets', 'en.json'), outsideSecret)) return; + writeJson(projectDir, 'locales/en.json', { greeting: 'Hello' }); + writeJson(projectDir, 'locales/de.json', { greeting: 'Hallo' }); + writeJson(projectDir, 'locales/secrets/de.json', { exfiltrated: 'SHOULD_NOT_PUSH' }); + writeSyncConfig(projectDir, { + targetLocales: ['de'], + buckets: { json: { include: ['locales/**/en.json'] } }, + tms: tmsConfig(), + }); + process.env['TMS_API_KEY'] = 'env-key'; + + const config = await loadSyncConfig(projectDir); + const client = createTmsClient(config.tms!); + + // Only the real file's key is expected on the wire; the symlinked secret + // must not be read or transmitted. + const scope = expectTmsPush('greeting', 'de', 'Hallo', { auth: { apiKey: 'env-key' } }); + + const result = await pushTranslations(config, client, harness.registry); + expect(result.pushed).toBe(1); + expect(scope.isDone()).toBe(true); + // No request was made for an "exfiltrated" key. + expect(nock.pendingMocks()).toEqual([]); + }); + + it('pull: does not treat a symlink as a source file when scanning for locale targets', async () => { + if (!trySymlink(path.join(projectDir, 'locales', 'secrets', 'en.json'), outsideSecret)) return; + writeJson(projectDir, 'locales/en.json', { greeting: 'Hello' }); + writeSyncConfig(projectDir, { + targetLocales: ['de'], + buckets: { json: { include: ['locales/**/en.json'] } }, + tms: tmsConfig(), + }); + process.env['TMS_API_KEY'] = 'env-key'; + + const config = await loadSyncConfig(projectDir); + const client = createTmsClient(config.tms!); + + // The TMS server returns translations for BOTH "greeting" and + // "exfiltrated". If the symlinked source is read, pullTranslations will + // write locales/secrets/de.json containing the exfiltrated value. With + // the symlink skipped, only locales/de.json is written. + const pullScope = expectTmsPull( + 'de', + { greeting: 'Hallo', exfiltrated: 'SHOULD_NEVER_APPEAR' }, + { auth: { apiKey: 'env-key' } }, + ); + + const result = await pullTranslations(config, client, harness.registry); + expect(pullScope.isDone()).toBe(true); + expect(result.pulled).toBe(1); + + // The target file derived from the symlink (locales/secrets/de.json) must + // never have been written. + expect(fs.existsSync(path.join(projectDir, 'locales', 'secrets', 'de.json'))).toBe(false); + }); + + it('export: does not embed symlinked-file contents in XLIFF output', async () => { + if (!trySymlink(path.join(projectDir, 'locales', 'secrets', 'en.json'), outsideSecret)) return; + writeJson(projectDir, 'locales/en.json', { greeting: 'Hello' }); + writeSyncConfig(projectDir, { + targetLocales: ['de'], + buckets: { json: { include: ['locales/**/en.json'] } }, + }); + + const config = await loadSyncConfig(projectDir); + const result = await exportTranslations(config, harness.registry); + + expect(result.content).not.toContain('TOP_SECRET_VALUE'); + expect(result.content).not.toContain('exfiltrated'); + expect(result.content).toContain('greeting'); + expect(result.files).toBe(1); + }); + + it('validate: does not scan symlinked files when enumerating sources', async () => { + // The symlinked source file contains placeholders that, if read and + // validated, would surface in the issues list (mismatched placeholder + // count vs. the target file). + const maliciousSource = path.join(outsideDir, 'malicious-source.json'); + fs.writeFileSync( + maliciousSource, + JSON.stringify({ greeting: 'LEAKED %s %d %x' }, null, 2), + 'utf-8', + ); + if (!trySymlink(path.join(projectDir, 'locales', 'secrets', 'en.json'), maliciousSource)) return; + writeJson(projectDir, 'locales/en.json', { greeting: 'Hello %s' }); + writeJson(projectDir, 'locales/de.json', { greeting: 'Hallo %s' }); + // Target for the symlinked "source" -- no placeholder mismatches here so + // the only way to produce an issue tagged with "secrets" is to have read + // the symlinked source. + writeJson(projectDir, 'locales/secrets/de.json', { greeting: 'harmless' }); + writeSyncConfig(projectDir, { + targetLocales: ['de'], + buckets: { json: { include: ['locales/**/en.json'] } }, + }); + + const config = await loadSyncConfig(projectDir); + const result = await validateTranslations(config, harness.registry); + + // The symlinked source must not appear anywhere in the issues list nor + // contribute to totalChecked beyond the single real en.json entry. + expect(result.totalChecked).toBe(1); + for (const issue of result.issues) { + expect(issue.file).not.toContain('secrets'); + } + }); +}); diff --git a/tests/integration/sync-template-patterns-dedup-perf.integration.test.ts b/tests/integration/sync-template-patterns-dedup-perf.integration.test.ts new file mode 100644 index 0000000..1887d8d --- /dev/null +++ b/tests/integration/sync-template-patterns-dedup-perf.integration.test.ts @@ -0,0 +1,113 @@ +/** + * Gated performance regression test for template-patterns dedup before resolveTemplatePatterns. + * + * Run with: SYNC_BENCH=1 npx jest sync-template-patterns-dedup-perf + * + * Without dedup, 2K files × 20 template literals each = 40K entries looped against 10K keys + * yields 400M .test() calls (~8s). With dedup the 40K entries collapse to at most 20 unique + * patterns, so the loop is 20 × 10K = 200K .test() calls (<10ms). + */ + +import { resolveTemplatePatterns } from '../../src/sync/sync-context'; +import type { TemplatePatternMatch } from '../../src/sync/sync-context'; + +const FILE_COUNT = 2_000; +const PATTERNS_PER_FILE = 20; +const KEY_COUNT = 10_000; +const THRESHOLD_MS = 2_000; + +function buildFixture(): { patterns: TemplatePatternMatch[]; knownKeys: string[] } { + const patterns: TemplatePatternMatch[] = []; + for (let f = 0; f < FILE_COUNT; f++) { + for (let p = 0; p < PATTERNS_PER_FILE; p++) { + patterns.push({ + pattern: `section${p}.\${k}.title`, + filePath: `src/file${f}.tsx`, + line: p + 1, + surroundingCode: `t(\`section${p}.\${k}.title\`)`, + matchedFunction: 't', + }); + } + } + + const knownKeys: string[] = []; + for (let k = 0; k < KEY_COUNT; k++) { + knownKeys.push(`section${k % PATTERNS_PER_FILE}.item${k}.title`); + } + + return { patterns, knownKeys }; +} + +describe('resolveTemplatePatterns dedup', () => { + it('dedup collapses 40K dup-pattern entries to unique patterns, result matches single-entry baseline', () => { + const UNIQUE_PATTERNS = 5; + const DUP_COUNT = 1_000; + const knownKeys = ['a.foo.title', 'b.bar.title', 'unrelated']; + + const base: TemplatePatternMatch = { + pattern: 'a.${k}.title', + filePath: 'base.tsx', + line: 1, + surroundingCode: "t(`a.${k}.title`)", + matchedFunction: 't', + }; + + const uniqueExtras: TemplatePatternMatch[] = Array.from({ length: UNIQUE_PATTERNS - 1 }, (_, i) => ({ + pattern: `b.\${k${i}}.title`, + filePath: `extra${i}.tsx`, + line: i + 1, + surroundingCode: `t(\`b.\${k${i}}.title\`)`, + matchedFunction: 't', + })); + + const singlePatterns = [base, ...uniqueExtras]; + const dupPatterns = [ + ...Array.from({ length: DUP_COUNT }, () => ({ ...base })), + ...uniqueExtras, + ]; + + const resultSingle = resolveTemplatePatterns(singlePatterns, knownKeys); + const resultDup = resolveTemplatePatterns(dupPatterns, knownKeys); + + expect(resultDup.has('a.foo.title')).toBe(true); + expect(resultDup.has('b.bar.title')).toBe(true); + + const singleMatches = resultSingle.get('a.foo.title')!; + const dupMatches = resultDup.get('a.foo.title')!; + expect(dupMatches.length).toBe(singleMatches.length); + expect(dupMatches[0]!.filePath).toBe(singleMatches[0]!.filePath); + expect(dupMatches[0]!.line).toBe(singleMatches[0]!.line); + }); + + if (process.env['SYNC_BENCH']) { + it('O(F×K) baseline without dedup is measurably slow', () => { + const { patterns, knownKeys } = buildFixture(); + + const start = Date.now(); + const seen = new Set(); + const deduped = patterns.filter((t) => !seen.has(t.pattern) && seen.add(t.pattern)); + const dedupMs = Date.now() - start; + + const startSlow = Date.now(); + resolveTemplatePatterns(patterns, knownKeys); + const elapsed = Date.now() - startSlow; + + expect(deduped.length).toBeLessThan(patterns.length); + console.log(`[bench] patterns: ${patterns.length}, unique: ${seen.size}, keys: ${knownKeys.length}`); + console.log(`[bench] dedup cost: ${dedupMs}ms`); + console.log(`[bench] resolveTemplatePatterns (pre-dedup simulation): ${elapsed}ms`); + }); + + it('deduped resolveTemplatePatterns completes in under 2s for 2K-file × 40K-pattern fixture', () => { + const { patterns, knownKeys } = buildFixture(); + + const start = Date.now(); + const result = resolveTemplatePatterns(patterns, knownKeys); + const elapsed = Date.now() - start; + + expect(result.size).toBeGreaterThan(0); + expect(elapsed).toBeLessThan(THRESHOLD_MS); + console.log(`[bench] resolveTemplatePatterns (deduped): ${elapsed}ms, matched keys: ${result.size}`); + }); + } +}); diff --git a/tests/integration/sync-template-prep.integration.test.ts b/tests/integration/sync-template-prep.integration.test.ts new file mode 100644 index 0000000..ec300a0 --- /dev/null +++ b/tests/integration/sync-template-prep.integration.test.ts @@ -0,0 +1,133 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +jest.unmock('fast-glob'); + +import nock from 'nock'; +import { SyncService } from '../../src/sync/sync-service'; +import { TranslationService } from '../../src/services/translation'; +import { GlossaryService } from '../../src/services/glossary'; +import { DeepLClient } from '../../src/api/deepl-client'; +import { FormatRegistry } from '../../src/formats/index'; +import { JsonFormatParser } from '../../src/formats/json'; +import { loadSyncConfig } from '../../src/sync/sync-config'; +import { DEEPL_FREE_API_URL, TEST_API_KEY } from '../helpers/nock-setup'; +import { createMockConfigService, createMockCacheService } from '../helpers/mock-factories'; + +function createServices(): { client: DeepLClient; syncService: SyncService } { + const client = new DeepLClient(TEST_API_KEY, { maxRetries: 0 }); + const mockConfig = createMockConfigService({ + get: jest.fn(() => ({ + auth: {}, + api: { baseUrl: '', usePro: false }, + defaults: { targetLangs: [], formality: 'default', preserveFormatting: false }, + cache: { enabled: false }, + output: { format: 'text', color: true }, + proxy: {}, + })), + getValue: jest.fn(() => false), + }); + const mockCache = createMockCacheService(); + const translationService = new TranslationService(client, mockConfig, mockCache); + const glossaryService = new GlossaryService(client); + const registry = new FormatRegistry(); + registry.register(new JsonFormatParser()); + const syncService = new SyncService(translationService, glossaryService, registry); + return { client, syncService }; +} + +const CONFIG_YAML = `version: 1 +source_locale: en +target_locales: + - de +buckets: + json: + include: + - "locales/en.json" +context: + enabled: true + scan_paths: + - "src/**/*.tsx" +`; + +const SOURCE_TSX = ` +import { useT } from 'i18n'; +export function Features({ keys }: { keys: string[] }) { + const t = useT(); + return keys.map(k =>

{t(\`features.\${k}.title\`)}

); +} +`; + +const SOURCE_JSON = + JSON.stringify( + { + 'features.alpha.title': 'Alpha', + 'features.beta.title': 'Beta', + 'features.gamma.title': 'Gamma', + }, + null, + 2, + ) + '\n'; + +describe('sync-service template-pattern prep: source files read once per sync', () => { + let tmpDir: string; + let client: DeepLClient; + let syncService: SyncService; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-sync-tpl-prep-')); + const services = createServices(); + client = services.client; + syncService = services.syncService; + }); + + afterEach(() => { + client.destroy(); + if (fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + nock.cleanAll(); + jest.restoreAllMocks(); + }); + + it('reads each bucket source file at most once when template patterns are configured', async () => { + const configPath = path.join(tmpDir, '.deepl-sync.yaml'); + fs.writeFileSync(configPath, CONFIG_YAML, 'utf-8'); + const srcPath = path.join(tmpDir, 'src', 'Features.tsx'); + fs.mkdirSync(path.dirname(srcPath), { recursive: true }); + fs.writeFileSync(srcPath, SOURCE_TSX, 'utf-8'); + const localePath = path.join(tmpDir, 'locales', 'en.json'); + fs.mkdirSync(path.dirname(localePath), { recursive: true }); + fs.writeFileSync(localePath, SOURCE_JSON, 'utf-8'); + + nock(DEEPL_FREE_API_URL) + .persist() + .post('/v2/translate') + .reply(200, { + translations: [ + { text: 'Alpha-DE', detected_source_language: 'EN' }, + { text: 'Beta-DE', detected_source_language: 'EN' }, + { text: 'Gamma-DE', detected_source_language: 'EN' }, + ], + }); + + const config = await loadSyncConfig(configPath, {}); + + const readFileSpy = jest.spyOn(fs.promises, 'readFile'); + + await syncService.sync(config, { dryRun: false }); + + const localeAbs = fs.realpathSync(localePath); + const bucketReads = readFileSpy.mock.calls.filter((call) => { + const p = typeof call[0] === 'string' ? call[0] : String(call[0]); + try { + return fs.realpathSync(p) === localeAbs; + } catch { + return path.resolve(p) === path.resolve(localePath); + } + }).length; + + expect(bucketReads).toBeLessThanOrEqual(1); + }, 30_000); +}); diff --git a/tests/integration/sync-tms-push.integration.test.ts b/tests/integration/sync-tms-push.integration.test.ts new file mode 100644 index 0000000..b9d728f --- /dev/null +++ b/tests/integration/sync-tms-push.integration.test.ts @@ -0,0 +1,178 @@ +/** + * Integration test: walker skip-partition invariant on the TMS push/pull + * pipeline. + * + * Drives pushTranslations / pullTranslations against a real Laravel PHP + * source+target pair (from tests/fixtures/sync/laravel_php-pipe-plural/) with + * a nock-mocked TMS server. Asserts the exact set of PUT /keys/... calls and + * verifies that keys tagged as pipe-pluralization by the walker's skip + * partition never cross the TMS wire on push, and never overwrite the target + * file on pull. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +jest.unmock('fast-glob'); + +import nock from 'nock'; + +import { TmsClient } from '../../src/sync/tms-client'; +import { pushTranslations, pullTranslations } from '../../src/sync/sync-tms'; +import { FormatRegistry } from '../../src/formats/index'; +import { PhpArraysFormatParser } from '../../src/formats/php-arrays'; +import type { ResolvedSyncConfig } from '../../src/sync/sync-config'; + +import { TMS_BASE, TMS_PROJECT, expectTmsPush, expectTmsPull } from '../helpers/tms-nock'; + +const FIXTURE_DIR = path.resolve(__dirname, '../fixtures/sync/laravel_php-pipe-plural'); + +function makeConfig(projectRoot: string, overrides: Partial = {}): ResolvedSyncConfig { + return { + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { laravel_php: { include: ['en.php'] } }, + configPath: path.join(projectRoot, '.deepl-sync.yaml'), + projectRoot, + overrides: {}, + tms: { + enabled: true, + server: TMS_BASE, + project_id: TMS_PROJECT, + }, + ...overrides, + }; +} + +function makeRegistry(): FormatRegistry { + const registry = new FormatRegistry(); + registry.register(new PhpArraysFormatParser()); + return registry; +} + +function makeClient(): TmsClient { + return new TmsClient({ + serverUrl: TMS_BASE, + projectId: TMS_PROJECT, + apiKey: 'k', + }); +} + +describe('sync push/pull walker skip-partition integration', () => { + let projectRoot: string; + + beforeEach(() => { + projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-sync-skip-partition-')); + fs.copyFileSync(path.join(FIXTURE_DIR, 'en.php'), path.join(projectRoot, 'en.php')); + fs.copyFileSync(path.join(FIXTURE_DIR, 'de.php'), path.join(projectRoot, 'de.php')); + }); + + afterEach(() => { + if (fs.existsSync(projectRoot)) { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + nock.cleanAll(); + }); + + it('push: pipe-plural keys are not sent on the wire; only non-plural keys reach /keys/...', async () => { + // Scope exactly the calls we expect. If the pipe-plural leak regresses, + // nock will yield a "No match for request" failure for `apples` or `days`, + // and these scopes will report `isDone() === false` on the greeted ones. + const scopes = [ + expectTmsPush('greeting', 'de', 'Hallo'), + expectTmsPush('farewell', 'de', 'Tschüss'), + ]; + + const result = await pushTranslations(makeConfig(projectRoot), makeClient(), makeRegistry()); + + expect(result.pushed).toBe(2); + for (const scope of scopes) { + expect(scope.isDone()).toBe(true); + } + + const skippedKeys = result.skipped + .filter((s) => s.reason === 'pipe_pluralization') + .map((s) => s.key) + .sort(); + expect(skippedKeys).toEqual(['apples', 'days']); + expect(nock.isDone()).toBe(true); + }); + + it('pull: pipe-plural keys in the target file are not overwritten with TMS payload', async () => { + // TMS returns translations for every source key including the pipe-plural + // ones — a realistic "admin approved these in the TMS UI" scenario that + // previously caused the target pipe-plural value to be replaced by the + // single-string TMS payload, corrupting the Laravel pluralization syntax. + const scope = expectTmsPull('de', { + greeting: 'Hallo', + farewell: 'Tschüss', + apples: 'WRONG — pipe plural replacement', + days: 'WRONG — pipe plural replacement', + }); + + const result = await pullTranslations(makeConfig(projectRoot), makeClient(), makeRegistry()); + + expect(result.pulled).toBeGreaterThanOrEqual(2); + expect(scope.isDone()).toBe(true); + + const writtenDe = fs.readFileSync(path.join(projectRoot, 'de.php'), 'utf-8'); + // Pipe-plural values in the target file must be preserved verbatim from + // the pre-pull target (which matched the source fixture). If the partition + // regressed, the literal string 'WRONG — pipe plural replacement' would + // appear in one of the two plural slots. + expect(writtenDe).not.toContain('WRONG'); + expect(writtenDe).toContain('{0} No apples|{1} One apple|[2,*] Many apples'); + expect(writtenDe).toContain('[0,0] No days|[1,6] A few days|[7,*] Full week'); + expect(writtenDe).toContain("'greeting' => 'Hallo'"); + expect(writtenDe).toContain("'farewell' => 'Tschüss'"); + expect(nock.isDone()).toBe(true); + }); + + it('push: never issues a request for a pipe-plural key even when TMS is offline for those paths', async () => { + // Only arm the two non-plural endpoints. If a leak regresses, nock will + // reject the unmatched PUT with a NetConnectNotAllowedError, failing fast. + nock.disableNetConnect(); + try { + const scopes = [ + expectTmsPush('greeting', 'de', 'Hallo'), + expectTmsPush('farewell', 'de', 'Tschüss'), + ]; + + const result = await pushTranslations(makeConfig(projectRoot), makeClient(), makeRegistry()); + + expect(result.pushed).toBe(2); + for (const scope of scopes) { + expect(scope.isDone()).toBe(true); + } + } finally { + nock.enableNetConnect(); + } + }); + + it('push: records SkippedRecord for every (file, locale, pipe_plural_key) triple', async () => { + expectTmsPush('greeting', 'de', 'Hallo'); + expectTmsPush('farewell', 'de', 'Tschüss'); + expectTmsPush('greeting', 'fr', 'Hallo'); + expectTmsPush('farewell', 'fr', 'Tschüss'); + // Add a second target locale with the same target-file content so the + // walker emits skip records for both locales. + fs.copyFileSync(path.join(projectRoot, 'de.php'), path.join(projectRoot, 'fr.php')); + + const config = makeConfig(projectRoot, { target_locales: ['de', 'fr'] }); + const result = await pushTranslations(config, makeClient(), makeRegistry()); + + expect(result.pushed).toBe(4); + const keyed = result.skipped + .filter((s) => s.reason === 'pipe_pluralization') + .map((s) => `${s.locale}:${s.key}`) + .sort(); + expect(keyed).toEqual([ + 'de:apples', + 'de:days', + 'fr:apples', + 'fr:days', + ]); + }); +}); diff --git a/tests/integration/sync-tms.integration.test.ts b/tests/integration/sync-tms.integration.test.ts new file mode 100644 index 0000000..d20f233 --- /dev/null +++ b/tests/integration/sync-tms.integration.test.ts @@ -0,0 +1,221 @@ +/** + * Integration tests for the sync push/pull TMS adapter. + * + * These tests drive pushTranslations / pullTranslations against a nock-mocked + * TMS server and assert the wire contract, credential resolution, and error + * paths end to end at the service layer (not via execSync). For CLI-binary + * behavior, see tests/e2e/cli-sync-tms.e2e.test.ts. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +jest.unmock('fast-glob'); + +import nock from 'nock'; + +import { TmsClient, createTmsClient } from '../../src/sync/tms-client'; +import { pushTranslations, pullTranslations } from '../../src/sync/sync-tms'; +import { loadSyncConfig } from '../../src/sync/sync-config'; +import { LOCK_FILE_NAME } from '../../src/sync/types'; +import { ConfigError } from '../../src/utils/errors'; + +import { createSyncHarness, writeSyncConfig } from '../helpers/sync-harness'; +import { + TMS_BASE, + TMS_PROJECT, + expectTmsPush, + expectTmsPull, + tmsConfig, +} from '../helpers/tms-nock'; + +function writeJson(dir: string, relPath: string, obj: unknown): void { + const abs = path.join(dir, relPath); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, JSON.stringify(obj, null, 2) + '\n', 'utf-8'); +} + +describe('sync push/pull (TMS integration)', () => { + let tmpDir: string; + let harness: ReturnType; + let envSnapshot: NodeJS.ProcessEnv; + + beforeEach(() => { + envSnapshot = { ...process.env }; + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-sync-tms-')); + harness = createSyncHarness({ parsers: ['json'] }); + delete process.env['TMS_API_KEY']; + delete process.env['TMS_TOKEN']; + }); + + afterEach(() => { + harness.cleanup(); + if (fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + nock.cleanAll(); + process.env = envSnapshot; + }); + + // ---- Case 1: push happy path ---- + it('push: sends PUT per source key with ApiKey auth, returns pushed count', async () => { + writeSyncConfig(tmpDir, { targetLocales: ['de'], tms: tmsConfig() }); + writeJson(tmpDir, 'locales/en.json', { greeting: 'Hello', farewell: 'Goodbye' }); + writeJson(tmpDir, 'locales/de.json', { greeting: 'Hallo', farewell: 'Auf Wiedersehen' }); + + process.env['TMS_API_KEY'] = 'env-key'; + + const config = await loadSyncConfig(tmpDir); + const client = createTmsClient(config.tms!); + + const scopes = [ + expectTmsPush('farewell', 'de', 'Auf Wiedersehen', { auth: { apiKey: 'env-key' } }), + expectTmsPush('greeting', 'de', 'Hallo', { auth: { apiKey: 'env-key' } }), + ]; + + const result = await pushTranslations(config, client, harness.registry); + expect(result.pushed).toBe(2); + expect(result.skipped).toEqual([]); + for (const scope of scopes) { + expect(scope.isDone()).toBe(true); + } + }); + + // ---- Case 2: pull happy path ---- + it('pull: fetches translations, writes target file, updates lockfile with review_status=human_reviewed', async () => { + writeSyncConfig(tmpDir, { targetLocales: ['de'], tms: tmsConfig() }); + writeJson(tmpDir, 'locales/en.json', { greeting: 'Hello', farewell: 'Goodbye' }); + writeJson(tmpDir, 'locales/de.json', { greeting: 'OLD', farewell: 'STALE' }); + + process.env['TMS_API_KEY'] = 'env-key'; + + const config = await loadSyncConfig(tmpDir); + const client = createTmsClient(config.tms!); + + const pullScope = expectTmsPull( + 'de', + { greeting: 'Hallo (approved)', farewell: 'Tschüss (approved)' }, + { auth: { apiKey: 'env-key' } }, + ); + + const result = await pullTranslations(config, client, harness.registry); + expect(result.pulled).toBe(2); + expect(result.skipped).toEqual([]); + expect(pullScope.isDone()).toBe(true); + + const targetContent = JSON.parse( + fs.readFileSync(path.join(tmpDir, 'locales/de.json'), 'utf-8'), + ) as Record; + expect(targetContent['greeting']).toBe('Hallo (approved)'); + expect(targetContent['farewell']).toBe('Tschüss (approved)'); + + const lockContent = JSON.parse( + fs.readFileSync(path.join(tmpDir, LOCK_FILE_NAME), 'utf-8'), + ) as { entries: Record }>> }; + const bucketEntries = lockContent.entries['locales/en.json']!; + expect(bucketEntries['greeting']!.translations['de']!.review_status).toBe('human_reviewed'); + expect(bucketEntries['greeting']!.translations['de']!.status).toBe('translated'); + expect(typeof bucketEntries['greeting']!.translations['de']!.translated_at).toBe('string'); + }); + + // ---- Case 3: TMS_API_KEY env var precedence over config ---- + it('credential resolution: TMS_API_KEY env var overrides config.api_key', async () => { + writeSyncConfig(tmpDir, { tms: tmsConfig({ api_key: 'from-config' }) }); + writeJson(tmpDir, 'locales/en.json', { k: 'Hello' }); + writeJson(tmpDir, 'locales/de.json', { k: 'Hallo' }); + + process.env['TMS_API_KEY'] = 'from-env'; + + const config = await loadSyncConfig(tmpDir); + const client = createTmsClient(config.tms!); + + const scope = expectTmsPush('k', 'de', 'Hallo', { auth: { apiKey: 'from-env' } }); + + await pushTranslations(config, client, harness.registry); + expect(scope.isDone()).toBe(true); + }); + + // ---- Case 4: TMS_TOKEN env var → Bearer auth ---- + it('credential resolution: TMS_TOKEN env var produces Bearer auth header', async () => { + writeSyncConfig(tmpDir, { tms: tmsConfig() }); + writeJson(tmpDir, 'locales/en.json', { k: 'Hello' }); + writeJson(tmpDir, 'locales/de.json', { k: 'Hallo' }); + + process.env['TMS_TOKEN'] = 'the-token'; + + const config = await loadSyncConfig(tmpDir); + const client = createTmsClient(config.tms!); + + const scope = expectTmsPush('k', 'de', 'Hallo', { auth: { token: 'the-token' } }); + + await pushTranslations(config, client, harness.registry); + expect(scope.isDone()).toBe(true); + }); + + // ---- Case 5: secret-in-config warning ---- + it('credential resolution: emits a stderr warning when api_key is sourced from .deepl-sync.yaml', async () => { + writeSyncConfig(tmpDir, { tms: tmsConfig({ api_key: 'in-config' }) }); + + const warn = jest.spyOn(console, 'error').mockImplementation(() => undefined); + try { + const config = await loadSyncConfig(tmpDir); + createTmsClient(config.tms!); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/TMS API key found in config file.*TMS_API_KEY/), + ); + } finally { + warn.mockRestore(); + } + }); + + // ---- Case 6: HTTPS enforcement — localhost exempt ---- + it('URL validation: accepts http://localhost for dev mode', async () => { + const client = new TmsClient({ + serverUrl: 'http://localhost:3000', + projectId: TMS_PROJECT, + apiKey: 'k', + }); + const scope = nock('http://localhost:3000') + .put(`/api/projects/${TMS_PROJECT}/keys/greeting`) + .reply(200, {}); + + await expect(client.pushKey('greeting', 'de', 'Hallo')).resolves.toBeUndefined(); + expect(scope.isDone()).toBe(true); + }); + + // ---- Case 7: non-HTTPS non-localhost rejected with ConfigError ---- + it('URL validation: rejects non-HTTPS non-localhost URLs with ConfigError', async () => { + const client = new TmsClient({ + serverUrl: 'http://evil.example.com', + projectId: TMS_PROJECT, + apiKey: 'k', + }); + + await expect(client.pushKey('k', 'de', 'v')).rejects.toThrow(ConfigError); + await expect(client.pushKey('k', 'de', 'v')).rejects.toThrow(/HTTPS/); + }); + + // ---- Case 8: 401 surfaces as an actionable ConfigError ---- + it('error path: 401 from TMS surfaces as a ConfigError with a remediation hint', async () => { + writeSyncConfig(tmpDir, { tms: tmsConfig() }); + writeJson(tmpDir, 'locales/en.json', { k: 'Hello' }); + writeJson(tmpDir, 'locales/de.json', { k: 'Hallo' }); + + process.env['TMS_API_KEY'] = 'bogus'; + + const config = await loadSyncConfig(tmpDir); + const client = createTmsClient(config.tms!); + + nock(TMS_BASE) + .put(new RegExp(`/api/projects/${TMS_PROJECT}/keys/.+`)) + .reply(401, { error: 'Unauthorized' }); + + await expect(pushTranslations(config, client, harness.registry)).rejects.toThrow(ConfigError); + // Arm the nock scope again (the previous call consumed it) for the second assertion + nock(TMS_BASE) + .put(new RegExp(`/api/projects/${TMS_PROJECT}/keys/.+`)) + .reply(401, { error: 'Unauthorized' }); + await expect(pushTranslations(config, client, harness.registry)).rejects.toThrow(/TMS authentication failed \(401/); + }); +}); diff --git a/tests/integration/sync-toml.integration.test.ts b/tests/integration/sync-toml.integration.test.ts new file mode 100644 index 0000000..0cce85a --- /dev/null +++ b/tests/integration/sync-toml.integration.test.ts @@ -0,0 +1,93 @@ +/** + * Integration test: fixture-driven TOML round-trip through the sync pipeline. + * + * Reads tests/fixtures/sync/formats/toml/source.toml, runs it through + * SyncService with a nock-mocked DeepL response, and asserts the target + * de.toml on disk matches tests/fixtures/sync/formats/toml/expected-after-sync/de.toml. + * + * Closes a fixture-coverage gap: toml was not previously exercised from disk + * fixtures, so TomlFormatParser.reconstruct regressions would slip past unit + * tests that only exercise in-memory strings. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +jest.unmock('fast-glob'); + +import nock from 'nock'; + +import { loadSyncConfig } from '../../src/sync/sync-config'; +import { TomlFormatParser } from '../../src/formats/toml'; +import { createSyncHarness, writeSyncConfig } from '../helpers/sync-harness'; +import { DEEPL_FREE_API_URL } from '../helpers/nock-setup'; + +const FIXTURE_DIR = path.resolve(__dirname, '../fixtures/sync/formats/toml'); + +describe('sync TOML fixture round-trip', () => { + let tmpDir: string; + let harness: ReturnType; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-sync-toml-')); + harness = createSyncHarness({ parsers: ['toml'] }); + }); + + afterEach(() => { + harness.cleanup(); + if (fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + nock.cleanAll(); + }); + + it('translates source.toml and writes de.toml matching the expected fixture', async () => { + const source = fs.readFileSync(path.join(FIXTURE_DIR, 'source.toml'), 'utf-8'); + const expected = fs.readFileSync( + path.join(FIXTURE_DIR, 'expected-after-sync', 'de.toml'), + 'utf-8', + ); + + writeSyncConfig(tmpDir, { + targetLocales: ['de'], + buckets: { toml: { include: ['locales/en.toml'] } }, + }); + const sourcePath = path.join(tmpDir, 'locales', 'en.toml'); + fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); + fs.writeFileSync(sourcePath, source, 'utf-8'); + + const scope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate') + .reply(200, { + // Order matches alphabetical sort of keys from TomlFormatParser.extract(): + // farewell, greeting, nav.home, nav.settings + translations: [ + { text: 'Auf Wiedersehen', detected_source_language: 'EN', billed_characters: 15 }, + { text: 'Hallo', detected_source_language: 'EN', billed_characters: 5 }, + { text: 'Startseite', detected_source_language: 'EN', billed_characters: 10 }, + { text: 'Einstellungen', detected_source_language: 'EN', billed_characters: 13 }, + ], + }); + + const config = await loadSyncConfig(tmpDir); + const result = await harness.syncService.sync(config); + + expect(result.success).toBe(true); + expect(result.newKeys).toBe(4); + expect(scope.isDone()).toBe(true); + + const targetPath = path.join(tmpDir, 'locales', 'de.toml'); + expect(fs.existsSync(targetPath)).toBe(true); + const written = fs.readFileSync(targetPath, 'utf-8'); + expect(written).toBe(expected); + + // Defense in depth: reparse to confirm translations are extractable. + const reparsed = new TomlFormatParser().extract(written); + const byKey = new Map(reparsed.map((e) => [e.key, e.value])); + expect(byKey.get('greeting')).toBe('Hallo'); + expect(byKey.get('farewell')).toBe('Auf Wiedersehen'); + expect(byKey.get('nav.home')).toBe('Startseite'); + expect(byKey.get('nav.settings')).toBe('Einstellungen'); + }); +}); diff --git a/tests/integration/sync-watch-reliability.integration.test.ts b/tests/integration/sync-watch-reliability.integration.test.ts new file mode 100644 index 0000000..a2514ef --- /dev/null +++ b/tests/integration/sync-watch-reliability.integration.test.ts @@ -0,0 +1,310 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +jest.unmock('fast-glob'); + +import nock from 'nock'; +import { + createWatchController, + type WatchEventSource, +} from '../../src/cli/commands/sync-command'; +import { loadSyncConfig } from '../../src/sync/sync-config'; +import { createSyncHarness, writeSyncConfig } from '../helpers/sync-harness'; +import { DEEPL_FREE_API_URL } from '../helpers/nock-setup'; + +interface StubWatcher extends WatchEventSource { + emit: (event: 'change' | 'add', file?: string) => void; + close: jest.Mock, []>; +} + +function createStubWatcher(): StubWatcher { + const listeners: Partial void>>> = {}; + return { + on(event, listener) { + (listeners[event] ??= []).push(listener); + return this; + }, + emit(event, file) { + for (const l of listeners[event] ?? []) l(file); + }, + close: jest.fn().mockResolvedValue(undefined), + }; +} + +function deferred(): { promise: Promise; resolve: (v: T) => void; reject: (e: unknown) => void } { + let resolve!: (v: T) => void; + let reject!: (e: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe('watch mode reliability', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-watch-rel-')); + }); + + afterEach(() => { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { /* ignore */ } + }); + + describe('event coalescing (bpah)', () => { + it('queues a follow-up run when a change arrives during an in-flight sync', async () => { + const inFlight = deferred(); + const runSyncCalls: Array<() => void> = []; + const runSync = jest.fn(async (_signal, _backups) => { + const done = deferred(); + runSyncCalls.push(done.resolve); + if (runSyncCalls.length === 1) { + inFlight.resolve(); + } + await done.promise; + }); + + const watcher = createStubWatcher(); + const controller = createWatchController({ + watcher, + runSync, + projectRoot: tmpDir, + staleBackupAgeMs: 5 * 60_000, + }); + + const first = controller.runOnce(); + await inFlight.promise; + + // second event fires while first is running — should NOT be dropped + void controller.runOnce(); + + // drain: resolve first sync + runSyncCalls[0]!(); + await first; + // wait a tick for the queued follow-up to start + await Promise.resolve(); + await Promise.resolve(); + + expect(runSync).toHaveBeenCalledTimes(2); + // finish the queued follow-up + runSyncCalls[1]!(); + }); + + it('coalesces multiple events during a sync into a single follow-up run', async () => { + const runSyncCalls: Array<() => void> = []; + const firstStarted = deferred(); + const runSync = jest.fn(async () => { + const done = deferred(); + runSyncCalls.push(done.resolve); + if (runSyncCalls.length === 1) firstStarted.resolve(); + await done.promise; + }); + + const watcher = createStubWatcher(); + const controller = createWatchController({ + watcher, + runSync, + projectRoot: tmpDir, + staleBackupAgeMs: 5 * 60_000, + }); + + const first = controller.runOnce(); + await firstStarted.promise; + // three more events arrive during in-flight sync — expect only ONE follow-up + void controller.runOnce(); + void controller.runOnce(); + void controller.runOnce(); + + runSyncCalls[0]!(); + await first; + await Promise.resolve(); + await Promise.resolve(); + + expect(runSync).toHaveBeenCalledTimes(2); + runSyncCalls[1]!(); + }); + }); + + describe('.bak cleanup on SIGINT (79zz)', () => { + it('removes .bak files even when runSync throws after registering them', async () => { + const bakFile = path.join(tmpDir, 'messages.de.json.bak'); + const runSync = jest.fn(async (_signal, backups: Set) => { + fs.writeFileSync(bakFile, 'backup contents', 'utf-8'); + backups.add(bakFile); + throw new Error('simulated SIGINT-interrupted translation'); + }); + + const watcher = createStubWatcher(); + const controller = createWatchController({ + watcher, + runSync, + projectRoot: tmpDir, + staleBackupAgeMs: 5 * 60_000, + }); + + await controller.runOnce(); + expect(fs.existsSync(bakFile)).toBe(false); + }); + + it('clears tracked backups from shutdown before the in-flight runSync returns', async () => { + const bakFile = path.join(tmpDir, 'messages.de.json.bak'); + const holdRunSync = deferred(); + const runSyncStarted = deferred(); + const runSync = jest.fn(async (_signal, backups: Set) => { + fs.writeFileSync(bakFile, 'backup contents', 'utf-8'); + backups.add(bakFile); + runSyncStarted.resolve(); + await holdRunSync.promise; + }); + + const watcher = createStubWatcher(); + const controller = createWatchController({ + watcher, + runSync, + projectRoot: tmpDir, + staleBackupAgeMs: 5 * 60_000, + }); + + const running = controller.runOnce(); + await runSyncStarted.promise; + // shutdown mid-flight should not wait for runSync to finish before + // cleaning up the already-registered .bak file + await controller.shutdown(); + expect(fs.existsSync(bakFile)).toBe(false); + + // let runSync finally resolve to keep the test from leaving a pending promise + holdRunSync.resolve(); + await running; + }); + + it('sets cancellation flag on shutdown so in-flight runSync can abandon remaining locales', async () => { + let observedSignal: { cancelled: boolean } | undefined; + const signalVisible = deferred(); + const runSync = jest.fn(async (signal: { cancelled: boolean }) => { + observedSignal = signal; + signalVisible.resolve(); + // long-running sync + await new Promise(resolve => setTimeout(resolve, 20)); + }); + + const watcher = createStubWatcher(); + const controller = createWatchController({ + watcher, + runSync, + projectRoot: tmpDir, + staleBackupAgeMs: 5 * 60_000, + }); + + const running = controller.runOnce(); + await signalVisible.promise; + expect(observedSignal?.cancelled).toBe(false); + + await controller.shutdown(); + expect(observedSignal?.cancelled).toBe(true); + await running; + }); + }); + + describe('cancellation through SyncService (79zz)', () => { + afterEach(() => { + nock.cleanAll(); + }); + + it('skips remaining locales once the cancellation signal is set', async () => { + const harness = createSyncHarness({ parsers: ['json'] }); + try { + writeSyncConfig(tmpDir, { + targetLocales: ['de', 'fr', 'es'], + buckets: { json: { include: ['locales/en.json'] } }, + }); + const sourceDir = path.join(tmpDir, 'locales'); + fs.mkdirSync(sourceDir, { recursive: true }); + fs.writeFileSync( + path.join(sourceDir, 'en.json'), + JSON.stringify({ hello: 'Hello' }), + 'utf-8', + ); + + const cancellationSignal = { cancelled: false }; + const callCounts: Record = {}; + + const matcher = (lang: string) => + nock(DEEPL_FREE_API_URL) + .post('/v2/translate', (body: Record) => body['target_lang'] === lang) + .reply(200, () => { + callCounts[lang] = (callCounts[lang] ?? 0) + 1; + // once the first locale completes, signal cancellation + cancellationSignal.cancelled = true; + return { translations: [{ text: `${lang}-hello`, detected_source_language: 'EN', billed_characters: 5 }] }; + }); + + matcher('DE'); + matcher('FR'); + matcher('ES'); + + const config = await loadSyncConfig(tmpDir); + await harness.syncService.sync(config, { + cancellationSignal, + concurrency: 1, + }); + + const totalCalls = Object.values(callCounts).reduce((a, b) => a + b, 0); + expect(totalCalls).toBeLessThan(3); + } finally { + harness.cleanup(); + } + }); + }); + + describe('stale .bak sweep on startup (79zz)', () => { + let errSpy: jest.SpyInstance; + beforeEach(() => { + errSpy = jest.spyOn(console, 'error').mockImplementation(() => { /* silence Logger.warn */ }); + }); + afterEach(() => { + errSpy.mockRestore(); + }); + + it('removes stale .bak siblings older than the age threshold', async () => { + const staleBak = path.join(tmpDir, 'locales', 'de.json.bak'); + fs.mkdirSync(path.dirname(staleBak), { recursive: true }); + fs.writeFileSync(staleBak, 'stale', 'utf-8'); + // backdate the mtime by 10 minutes + const tenMinAgo = new Date(Date.now() - 10 * 60_000); + fs.utimesSync(staleBak, tenMinAgo, tenMinAgo); + + const watcher = createStubWatcher(); + const controller = createWatchController({ + watcher, + runSync: jest.fn(), + projectRoot: tmpDir, + staleBackupAgeMs: 5 * 60_000, + }); + + await controller.sweepStaleBackups(); + + expect(fs.existsSync(staleBak)).toBe(false); + }); + + it('leaves fresh .bak siblings alone during startup sweep', async () => { + const freshBak = path.join(tmpDir, 'locales', 'fr.json.bak'); + fs.mkdirSync(path.dirname(freshBak), { recursive: true }); + fs.writeFileSync(freshBak, 'fresh', 'utf-8'); + + const watcher = createStubWatcher(); + const controller = createWatchController({ + watcher, + runSync: jest.fn(), + projectRoot: tmpDir, + staleBackupAgeMs: 5 * 60_000, + }); + + await controller.sweepStaleBackups(); + expect(fs.existsSync(freshBak)).toBe(true); + }); + }); +}); diff --git a/tests/integration/sync-watch.integration.test.ts b/tests/integration/sync-watch.integration.test.ts new file mode 100644 index 0000000..8325101 --- /dev/null +++ b/tests/integration/sync-watch.integration.test.ts @@ -0,0 +1,88 @@ +import { attachDebouncedWatchLoop, type WatchEventSource } from '../../src/cli/commands/sync-command'; + +interface StubWatcher extends WatchEventSource { + emit: (event: 'change' | 'add', file?: string) => void; +} + +function createStubWatcher(): StubWatcher { + const listeners: Partial void>>> = {}; + return { + on(event, listener) { + (listeners[event] ??= []).push(listener); + return this; + }, + emit(event, file) { + for (const l of listeners[event] ?? []) l(file); + }, + }; +} + +describe('attachDebouncedWatchLoop', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('invokes onChange exactly once when several change events arrive within the debounce window', () => { + const watcher = createStubWatcher(); + const onChange = jest.fn(); + + attachDebouncedWatchLoop({ watcher, onChange, debounceMs: 200 }); + + watcher.emit('change', 'a.json'); + jest.advanceTimersByTime(50); + watcher.emit('change', 'a.json'); + jest.advanceTimersByTime(50); + watcher.emit('change', 'a.json'); + expect(onChange).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(200); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('treats add events identically to change events for debouncing', () => { + const watcher = createStubWatcher(); + const onChange = jest.fn(); + + attachDebouncedWatchLoop({ watcher, onChange, debounceMs: 150 }); + + watcher.emit('add', 'new.json'); + jest.advanceTimersByTime(149); + expect(onChange).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('fires onChange twice when two bursts of events are separated by more than the debounce window', () => { + const watcher = createStubWatcher(); + const onChange = jest.fn(); + + attachDebouncedWatchLoop({ watcher, onChange, debounceMs: 100 }); + + watcher.emit('change'); + jest.advanceTimersByTime(100); + expect(onChange).toHaveBeenCalledTimes(1); + + watcher.emit('change'); + jest.advanceTimersByTime(100); + expect(onChange).toHaveBeenCalledTimes(2); + }); + + it('clear() cancels a pending debounce timer so onChange is never invoked', () => { + const watcher = createStubWatcher(); + const onChange = jest.fn(); + + const handle = attachDebouncedWatchLoop({ watcher, onChange, debounceMs: 200 }); + + watcher.emit('change'); + jest.advanceTimersByTime(150); + // shutdown mid-debounce — must not invoke onChange + handle.clear(); + jest.advanceTimersByTime(200); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/integration/sync-xcstrings.integration.test.ts b/tests/integration/sync-xcstrings.integration.test.ts new file mode 100644 index 0000000..f2610a3 --- /dev/null +++ b/tests/integration/sync-xcstrings.integration.test.ts @@ -0,0 +1,94 @@ +/** + * Integration test: fixture-driven Xcode String Catalog (.xcstrings) round-trip + * through the sync pipeline. + * + * Reads tests/fixtures/sync/formats/xcstrings/source.xcstrings, runs it + * through SyncService with a nock-mocked DeepL response, and asserts the + * same Localizable.xcstrings file on disk (xcstrings is multi-locale — one + * file holds every locale) matches + * tests/fixtures/sync/formats/xcstrings/expected-after-sync/de.xcstrings. + * + * Closes a fixture-coverage gap: xcstrings multi-locale write serialization + * is called out as a landmine in docs/SYNC.md and was not previously + * exercised from disk fixtures, so XcstringsFormatParser.reconstruct + * regressions would silently corrupt user translation files. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +jest.unmock('fast-glob'); + +import nock from 'nock'; + +import { loadSyncConfig } from '../../src/sync/sync-config'; +import { XcstringsFormatParser } from '../../src/formats/xcstrings'; +import { createSyncHarness, writeSyncConfig } from '../helpers/sync-harness'; +import { DEEPL_FREE_API_URL } from '../helpers/nock-setup'; + +const FIXTURE_DIR = path.resolve(__dirname, '../fixtures/sync/formats/xcstrings'); + +describe('sync xcstrings fixture round-trip', () => { + let tmpDir: string; + let harness: ReturnType; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-sync-xcstrings-')); + harness = createSyncHarness({ parsers: ['xcstrings'] }); + }); + + afterEach(() => { + harness.cleanup(); + if (fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + nock.cleanAll(); + }); + + it('translates source.xcstrings and writes the de localization in the same file matching the expected fixture', async () => { + const source = fs.readFileSync(path.join(FIXTURE_DIR, 'source.xcstrings'), 'utf-8'); + const expected = fs.readFileSync( + path.join(FIXTURE_DIR, 'expected-after-sync', 'de.xcstrings'), + 'utf-8', + ); + + writeSyncConfig(tmpDir, { + targetLocales: ['de'], + buckets: { xcstrings: { include: ['Localizable.xcstrings'] } }, + }); + const sourcePath = path.join(tmpDir, 'Localizable.xcstrings'); + fs.writeFileSync(sourcePath, source, 'utf-8'); + + const scope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate') + .reply(200, { + // Order matches alphabetical sort from XcstringsFormatParser.extract(): + // farewell, greeting, welcome + translations: [ + { text: 'Auf Wiedersehen', detected_source_language: 'EN', billed_characters: 15 }, + { text: 'Hallo', detected_source_language: 'EN', billed_characters: 5 }, + { text: 'Willkommen', detected_source_language: 'EN', billed_characters: 10 }, + ], + }); + + const config = await loadSyncConfig(tmpDir); + const result = await harness.syncService.sync(config); + + expect(result.success).toBe(true); + expect(result.newKeys).toBe(3); + expect(scope.isDone()).toBe(true); + + // xcstrings is multi-locale: the en source and de localizations live in + // the SAME Localizable.xcstrings file, not a separate de.xcstrings file. + const written = fs.readFileSync(sourcePath, 'utf-8'); + expect(written).toBe(expected); + + // Defense in depth: re-extract each locale to confirm both are present. + const parser = new XcstringsFormatParser(); + const enEntries = parser.extract(written, 'en'); + const deEntries = parser.extract(written, 'de'); + expect(enEntries.map((e) => e.value).sort()).toEqual(['Goodbye', 'Hello', 'Welcome']); + expect(deEntries.map((e) => e.value).sort()).toEqual(['Auf Wiedersehen', 'Hallo', 'Willkommen']); + }); +}); diff --git a/tests/integration/sync.integration.test.ts b/tests/integration/sync.integration.test.ts new file mode 100644 index 0000000..f05dab3 --- /dev/null +++ b/tests/integration/sync.integration.test.ts @@ -0,0 +1,2536 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as querystring from 'querystring'; + +jest.unmock('fast-glob'); + +import nock from 'nock'; +import { SyncService } from '../../src/sync/sync-service'; +import { TranslationService } from '../../src/services/translation'; +import { GlossaryService } from '../../src/services/glossary'; +import { DeepLClient } from '../../src/api/deepl-client'; +import { FormatRegistry } from '../../src/formats/index'; +import { JsonFormatParser } from '../../src/formats/json'; +import { YamlFormatParser } from '../../src/formats/yaml'; +import { PoFormatParser } from '../../src/formats/po'; +import { TomlFormatParser } from '../../src/formats/toml'; +import { ArbFormatParser } from '../../src/formats/arb'; +import { IosStringsFormatParser } from '../../src/formats/ios-strings'; +import { PropertiesFormatParser } from '../../src/formats/properties'; +import { AndroidXmlFormatParser } from '../../src/formats/android-xml'; +import { loadSyncConfig } from '../../src/sync/sync-config'; +import { computeSourceHash } from '../../src/sync/sync-lock'; +import { LOCK_FILE_NAME } from '../../src/sync/types'; +import { ConfigError, ValidationError } from '../../src/utils/errors'; +import { DEEPL_FREE_API_URL, TEST_API_KEY } from '../helpers/nock-setup'; +import { createMockConfigService, createMockCacheService } from '../helpers/mock-factories'; + +function parseNockBody(body: unknown): Record { + if (typeof body === 'string') { + return querystring.parse(body) as Record; + } + return body as Record; +} + +function getTexts(body: unknown): string[] { + const parsed = parseNockBody(body); + const text = parsed['text']; + return Array.isArray(text) ? text : (text ? [text] : []); +} + +function createServices(opts: { withYaml?: boolean; withAndroid?: boolean } = {}) { + const client = new DeepLClient(TEST_API_KEY, { maxRetries: 0 }); + const mockConfig = createMockConfigService({ + get: jest.fn(() => ({ + auth: {}, + api: { baseUrl: '', usePro: false }, + defaults: { targetLangs: [], formality: 'default', preserveFormatting: false }, + cache: { enabled: false }, + output: { format: 'text', color: true }, + proxy: {}, + })), + getValue: jest.fn(() => false), + }); + const mockCache = createMockCacheService(); + const translationService = new TranslationService(client, mockConfig, mockCache); + const glossaryService = new GlossaryService(client); + const registry = new FormatRegistry(); + registry.register(new JsonFormatParser()); + if (opts.withYaml) { + registry.register(new YamlFormatParser()); + } + if (opts.withAndroid) { + registry.register(new AndroidXmlFormatParser()); + } + const syncService = new SyncService(translationService, glossaryService, registry); + return { client, syncService }; +} + +function writeYamlConfig(dir: string, yaml: string): void { + fs.writeFileSync(path.join(dir, '.deepl-sync.yaml'), yaml, 'utf-8'); +} + +function writeSourceFile(dir: string, relPath: string, content: string): void { + const absPath = path.join(dir, relPath); + fs.mkdirSync(path.dirname(absPath), { recursive: true }); + fs.writeFileSync(absPath, content, 'utf-8'); +} + +const BASIC_CONFIG_YAML = `version: 1 +source_locale: en +target_locales: + - de +buckets: + json: + include: + - "locales/en.json" +`; + +const SOURCE_JSON = JSON.stringify( + { greeting: 'Hello', farewell: 'Goodbye', welcome: 'Welcome' }, + null, + 2, +) + '\n'; + +describe('Sync Integration', () => { + let tmpDir: string; + let client: DeepLClient; + let syncService: SyncService; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-sync-integ-')); + const services = createServices(); + client = services.client; + syncService = services.syncService; + }); + + afterEach(() => { + client.destroy(); + if (fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + nock.cleanAll(); + }); + + describe('first sync', () => { + it('should translate all keys and write target file', async () => { + writeYamlConfig(tmpDir, BASIC_CONFIG_YAML); + writeSourceFile(tmpDir, 'locales/en.json', SOURCE_JSON); + + const scope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate') + .reply(200, { + translations: [ + { text: 'Auf Wiedersehen', detected_source_language: 'EN', billed_characters: 15 }, + { text: 'Hallo', detected_source_language: 'EN', billed_characters: 5 }, + { text: 'Willkommen', detected_source_language: 'EN', billed_characters: 10 }, + ], + }); + + const config = await loadSyncConfig(tmpDir); + const result = await syncService.sync(config); + + expect(result.success).toBe(true); + expect(result.newKeys).toBe(3); + expect(result.totalKeys).toBe(3); + expect(scope.isDone()).toBe(true); + + const targetFile = path.join(tmpDir, 'locales', 'de.json'); + expect(fs.existsSync(targetFile)).toBe(true); + const translated = JSON.parse(fs.readFileSync(targetFile, 'utf-8')); + expect(translated).toHaveProperty('greeting'); + expect(translated).toHaveProperty('farewell'); + expect(translated).toHaveProperty('welcome'); + }); + + it('should create lock file', async () => { + writeYamlConfig(tmpDir, BASIC_CONFIG_YAML); + writeSourceFile(tmpDir, 'locales/en.json', SOURCE_JSON); + + nock(DEEPL_FREE_API_URL) + .post('/v2/translate') + .reply(200, { + translations: [ + { text: 'Auf Wiedersehen', detected_source_language: 'EN', billed_characters: 15 }, + { text: 'Hallo', detected_source_language: 'EN', billed_characters: 5 }, + { text: 'Willkommen', detected_source_language: 'EN', billed_characters: 10 }, + ], + }); + + const config = await loadSyncConfig(tmpDir); + await syncService.sync(config); + + const lockPath = path.join(tmpDir, LOCK_FILE_NAME); + expect(fs.existsSync(lockPath)).toBe(true); + + const lockContent = JSON.parse(fs.readFileSync(lockPath, 'utf-8')); + expect(lockContent.version).toBe(1); + expect(lockContent.source_locale).toBe('en'); + expect(lockContent.entries['locales/en.json']).toBeDefined(); + + const entries = lockContent.entries['locales/en.json']; + expect(entries['greeting']).toBeDefined(); + expect(entries['greeting'].source_text).toBe('Hello'); + expect(entries['greeting'].translations['de']).toBeDefined(); + expect(entries['greeting'].translations['de'].status).toBe('translated'); + }); + + it('should send correct languages to API', async () => { + writeYamlConfig(tmpDir, BASIC_CONFIG_YAML); + writeSourceFile(tmpDir, 'locales/en.json', SOURCE_JSON); + + const scope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate', (body: Record) => { + return body['source_lang'] === 'EN' && body['target_lang'] === 'DE'; + }) + .reply(200, { + translations: [ + { text: 'Auf Wiedersehen', detected_source_language: 'EN', billed_characters: 15 }, + { text: 'Hallo', detected_source_language: 'EN', billed_characters: 5 }, + { text: 'Willkommen', detected_source_language: 'EN', billed_characters: 10 }, + ], + }); + + const config = await loadSyncConfig(tmpDir); + await syncService.sync(config); + + expect(scope.isDone()).toBe(true); + }); + + it('should handle multiple target locales', async () => { + const multiLocaleConfig = `version: 1 +source_locale: en +target_locales: + - de + - fr +buckets: + json: + include: + - "locales/en.json" +`; + writeYamlConfig(tmpDir, multiLocaleConfig); + writeSourceFile(tmpDir, 'locales/en.json', SOURCE_JSON); + + const deScope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate', (body: Record) => { + return body['target_lang'] === 'DE'; + }) + .reply(200, { + translations: [ + { text: 'Auf Wiedersehen', detected_source_language: 'EN', billed_characters: 15 }, + { text: 'Hallo', detected_source_language: 'EN', billed_characters: 5 }, + { text: 'Willkommen', detected_source_language: 'EN', billed_characters: 10 }, + ], + }); + + const frScope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate', (body: Record) => { + return body['target_lang'] === 'FR'; + }) + .reply(200, { + translations: [ + { text: 'Au revoir', detected_source_language: 'EN', billed_characters: 10 }, + { text: 'Bonjour', detected_source_language: 'EN', billed_characters: 7 }, + { text: 'Bienvenue', detected_source_language: 'EN', billed_characters: 9 }, + ], + }); + + const config = await loadSyncConfig(tmpDir); + const result = await syncService.sync(config); + + expect(result.success).toBe(true); + expect(deScope.isDone()).toBe(true); + expect(frScope.isDone()).toBe(true); + + expect(fs.existsSync(path.join(tmpDir, 'locales', 'de.json'))).toBe(true); + expect(fs.existsSync(path.join(tmpDir, 'locales', 'fr.json'))).toBe(true); + }); + }); + + describe('incremental sync', () => { + async function doFirstSync(): Promise { + nock(DEEPL_FREE_API_URL) + .post('/v2/translate') + .reply(200, { + translations: [ + { text: 'Auf Wiedersehen', detected_source_language: 'EN', billed_characters: 15 }, + { text: 'Hallo', detected_source_language: 'EN', billed_characters: 5 }, + { text: 'Willkommen', detected_source_language: 'EN', billed_characters: 10 }, + ], + }); + + const config = await loadSyncConfig(tmpDir); + await syncService.sync(config); + } + + it('should only translate changed keys', async () => { + writeYamlConfig(tmpDir, BASIC_CONFIG_YAML); + writeSourceFile(tmpDir, 'locales/en.json', SOURCE_JSON); + await doFirstSync(); + + const updated = JSON.stringify( + { greeting: 'Hi there', farewell: 'Goodbye', welcome: 'Welcome' }, + null, + 2, + ) + '\n'; + writeSourceFile(tmpDir, 'locales/en.json', updated); + + const scope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate', (body: Record) => { + const text = body['text']; + return text === 'Hi there' || (Array.isArray(text) && text.length === 1 && text[0] === 'Hi there'); + }) + .reply(200, { + translations: [ + { text: 'Hallo zusammen', detected_source_language: 'EN', billed_characters: 8 }, + ], + }); + + const config = await loadSyncConfig(tmpDir); + const result = await syncService.sync(config); + + expect(result.staleKeys).toBe(1); + expect(result.currentKeys).toBe(2); + expect(scope.isDone()).toBe(true); + }); + + it('should detect new keys', async () => { + writeYamlConfig(tmpDir, BASIC_CONFIG_YAML); + writeSourceFile(tmpDir, 'locales/en.json', SOURCE_JSON); + await doFirstSync(); + + const updated = JSON.stringify( + { greeting: 'Hello', farewell: 'Goodbye', welcome: 'Welcome', thanks: 'Thank you' }, + null, + 2, + ) + '\n'; + writeSourceFile(tmpDir, 'locales/en.json', updated); + + const scope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate', (body: Record) => { + const text = body['text']; + return text === 'Thank you' || (Array.isArray(text) && text.length === 1 && text[0] === 'Thank you'); + }) + .reply(200, { + translations: [ + { text: 'Danke', detected_source_language: 'EN', billed_characters: 9 }, + ], + }); + + const config = await loadSyncConfig(tmpDir); + const result = await syncService.sync(config); + + expect(result.newKeys).toBe(1); + expect(result.currentKeys).toBe(3); + expect(scope.isDone()).toBe(true); + }); + + it('should count deleted keys', async () => { + writeYamlConfig(tmpDir, BASIC_CONFIG_YAML); + writeSourceFile(tmpDir, 'locales/en.json', SOURCE_JSON); + await doFirstSync(); + + const reduced = JSON.stringify( + { greeting: 'Hello', farewell: 'Goodbye' }, + null, + 2, + ) + '\n'; + writeSourceFile(tmpDir, 'locales/en.json', reduced); + + const config = await loadSyncConfig(tmpDir); + const result = await syncService.sync(config); + + expect(result.deletedKeys).toBe(1); + expect(result.currentKeys).toBe(2); + expect(result.totalKeys).toBe(2); + }); + }); + + describe('frozen mode', () => { + it('should detect drift when new keys exist', async () => { + writeYamlConfig(tmpDir, BASIC_CONFIG_YAML); + writeSourceFile(tmpDir, 'locales/en.json', SOURCE_JSON); + + const config = await loadSyncConfig(tmpDir); + const result = await syncService.sync(config, { frozen: true }); + + expect(result.driftDetected).toBe(true); + expect(result.success).toBe(false); + expect(result.frozen).toBe(true); + + expect(fs.existsSync(path.join(tmpDir, LOCK_FILE_NAME))).toBe(false); + }); + + it('should not detect drift when all current', async () => { + writeYamlConfig(tmpDir, BASIC_CONFIG_YAML); + writeSourceFile(tmpDir, 'locales/en.json', SOURCE_JSON); + + nock(DEEPL_FREE_API_URL) + .post('/v2/translate') + .reply(200, { + translations: [ + { text: 'Auf Wiedersehen', detected_source_language: 'EN', billed_characters: 15 }, + { text: 'Hallo', detected_source_language: 'EN', billed_characters: 5 }, + { text: 'Willkommen', detected_source_language: 'EN', billed_characters: 10 }, + ], + }); + + const config = await loadSyncConfig(tmpDir); + await syncService.sync(config); + + const result = await syncService.sync(config, { frozen: true }); + + expect(result.driftDetected).toBe(false); + expect(result.success).toBe(true); + }); + + it('should detect drift when a source key is deleted after the last sync', async () => { + // Blocker case 17: exercises the deletedDiffs > 0 frozen branch. + // A CI pipeline that silently passes when a key is deleted would + // mean translated files and lockfile drift from the source of truth. + writeYamlConfig(tmpDir, BASIC_CONFIG_YAML); + writeSourceFile(tmpDir, 'locales/en.json', SOURCE_JSON); + + nock(DEEPL_FREE_API_URL) + .post('/v2/translate') + .reply(200, { + translations: [ + { text: 'Auf Wiedersehen', detected_source_language: 'EN', billed_characters: 15 }, + { text: 'Hallo', detected_source_language: 'EN', billed_characters: 5 }, + { text: 'Willkommen', detected_source_language: 'EN', billed_characters: 10 }, + ], + }); + + const configInitial = await loadSyncConfig(tmpDir); + await syncService.sync(configInitial); + + // Remove one key from the source and rerun with --frozen. + const reducedSource = JSON.stringify({ greeting: 'Hello', welcome: 'Welcome' }, null, 2) + '\n'; + writeSourceFile(tmpDir, 'locales/en.json', reducedSource); + + const configAfter = await loadSyncConfig(tmpDir); + const result = await syncService.sync(configAfter, { frozen: true }); + + expect(result.driftDetected).toBe(true); + expect(result.deletedKeys).toBeGreaterThanOrEqual(1); + expect(result.success).toBe(false); + }); + }); + + describe('dry run', () => { + it('should not call API', async () => { + writeYamlConfig(tmpDir, BASIC_CONFIG_YAML); + writeSourceFile(tmpDir, 'locales/en.json', SOURCE_JSON); + + const config = await loadSyncConfig(tmpDir); + const result = await syncService.sync(config, { dryRun: true }); + + expect(result.dryRun).toBe(true); + expect(result.newKeys).toBe(3); + expect(result.totalKeys).toBe(3); + }); + + it('should not write files', async () => { + writeYamlConfig(tmpDir, BASIC_CONFIG_YAML); + writeSourceFile(tmpDir, 'locales/en.json', SOURCE_JSON); + + const config = await loadSyncConfig(tmpDir); + await syncService.sync(config, { dryRun: true }); + + expect(fs.existsSync(path.join(tmpDir, 'locales', 'de.json'))).toBe(false); + expect(fs.existsSync(path.join(tmpDir, LOCK_FILE_NAME))).toBe(false); + }); + + it('should report correct counts', async () => { + writeYamlConfig(tmpDir, BASIC_CONFIG_YAML); + writeSourceFile(tmpDir, 'locales/en.json', SOURCE_JSON); + + const config = await loadSyncConfig(tmpDir); + const result = await syncService.sync(config, { dryRun: true }); + + expect(result.newKeys).toBe(3); + expect(result.staleKeys).toBe(0); + expect(result.deletedKeys).toBe(0); + expect(result.currentKeys).toBe(0); + expect(result.totalKeys).toBe(3); + }); + }); + + describe('force', () => { + it('should retranslate all keys', async () => { + writeYamlConfig(tmpDir, BASIC_CONFIG_YAML); + writeSourceFile(tmpDir, 'locales/en.json', SOURCE_JSON); + + nock(DEEPL_FREE_API_URL) + .post('/v2/translate') + .reply(200, { + translations: [ + { text: 'Auf Wiedersehen', detected_source_language: 'EN', billed_characters: 15 }, + { text: 'Hallo', detected_source_language: 'EN', billed_characters: 5 }, + { text: 'Willkommen', detected_source_language: 'EN', billed_characters: 10 }, + ], + }); + + const config = await loadSyncConfig(tmpDir); + await syncService.sync(config); + + const forceScope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate', (body: Record) => { + const texts = body['text'] as string[]; + return Array.isArray(texts) && texts.length === 3; + }) + .reply(200, { + translations: [ + { text: 'Auf Wiedersehen!', detected_source_language: 'EN', billed_characters: 16 }, + { text: 'Hallo!', detected_source_language: 'EN', billed_characters: 6 }, + { text: 'Willkommen!', detected_source_language: 'EN', billed_characters: 11 }, + ], + }); + + const result = await syncService.sync(config, { force: true }); + + expect(result.newKeys).toBe(3); + expect(result.currentKeys).toBe(0); + expect(forceScope.isDone()).toBe(true); + }); + }); + + describe('error handling', () => { + it('should throw on API 403', async () => { + writeYamlConfig(tmpDir, BASIC_CONFIG_YAML); + writeSourceFile(tmpDir, 'locales/en.json', SOURCE_JSON); + + nock(DEEPL_FREE_API_URL) + .post('/v2/translate') + .reply(403, { message: 'Invalid API key' }); + + const config = await loadSyncConfig(tmpDir); + await expect(syncService.sync(config)).rejects.toThrow(/Authentication failed/); + }); + + it('should handle empty source file', async () => { + writeYamlConfig(tmpDir, BASIC_CONFIG_YAML); + writeSourceFile(tmpDir, 'locales/en.json', '{}\n'); + + const config = await loadSyncConfig(tmpDir); + const result = await syncService.sync(config); + + expect(result.success).toBe(true); + expect(result.totalKeys).toBe(0); + expect(result.newKeys).toBe(0); + }); + }); + + // Non-watch runs sweep stale `.bak` siblings at startup. + describe('stale .bak sweep on non-watch sync', () => { + it('removes stale .bak siblings older than sync.bak_sweep_max_age_seconds', async () => { + writeYamlConfig(tmpDir, BASIC_CONFIG_YAML); + writeSourceFile(tmpDir, 'locales/en.json', '{}\n'); + + const staleBak = path.join(tmpDir, 'locales', 'de.json.bak'); + fs.mkdirSync(path.dirname(staleBak), { recursive: true }); + fs.writeFileSync(staleBak, 'orphan from prior crash', 'utf-8'); + // Backdate 10 minutes past the 5-minute default threshold. + const tenMinAgo = new Date(Date.now() - 10 * 60_000); + fs.utimesSync(staleBak, tenMinAgo, tenMinAgo); + + const config = await loadSyncConfig(tmpDir); + await syncService.sync(config); + + expect(fs.existsSync(staleBak)).toBe(false); + }); + + it('respects a user-configured bak_sweep_max_age_seconds override', async () => { + writeYamlConfig(tmpDir, `version: 1 +source_locale: en +target_locales: + - de +buckets: + json: + include: + - "locales/en.json" +sync: + bak_sweep_max_age_seconds: 60 +`); + writeSourceFile(tmpDir, 'locales/en.json', '{}\n'); + + const staleBak = path.join(tmpDir, 'locales', 'de.json.bak'); + fs.mkdirSync(path.dirname(staleBak), { recursive: true }); + fs.writeFileSync(staleBak, 'orphan', 'utf-8'); + // 2 minutes old — stale under the 60-second override, fresh under the default. + const twoMinAgo = new Date(Date.now() - 2 * 60_000); + fs.utimesSync(staleBak, twoMinAgo, twoMinAgo); + + const config = await loadSyncConfig(tmpDir); + await syncService.sync(config); + + expect(fs.existsSync(staleBak)).toBe(false); + }); + + it('leaves fresh .bak files alone', async () => { + writeYamlConfig(tmpDir, BASIC_CONFIG_YAML); + writeSourceFile(tmpDir, 'locales/en.json', '{}\n'); + + const freshBak = path.join(tmpDir, 'locales', 'de.json.bak'); + fs.mkdirSync(path.dirname(freshBak), { recursive: true }); + fs.writeFileSync(freshBak, 'recent', 'utf-8'); + + const config = await loadSyncConfig(tmpDir); + await syncService.sync(config); + + expect(fs.existsSync(freshBak)).toBe(true); + }); + + it('registers a SIGINT/SIGTERM cleanup handler during a non-watch run that unlinks tracked .bak paths', async () => { + writeYamlConfig(tmpDir, BASIC_CONFIG_YAML); + writeSourceFile(tmpDir, 'locales/en.json', '{"a":"1"}\n'); + + // Pre-create an orphan .bak from a simulated prior crash. It's fresh + // (so the startup sweep leaves it alone), but we'll make it known to + // the cleanup handler via the backupTracker option. This is what + // watch mode does today; non-watch runs share the same discipline so + // any sync code path gets SIGINT/SIGTERM cleanup. + const bakPath = path.join(tmpDir, 'locales', 'de.json.bak'); + fs.mkdirSync(path.dirname(bakPath), { recursive: true }); + fs.writeFileSync(bakPath, 'in-flight', 'utf-8'); + + // Baseline listener counts so we can assert the handler is attached + // during the run and detached after. + const sigintBefore = process.listenerCount('SIGINT'); + const sigtermBefore = process.listenerCount('SIGTERM'); + + let observedSigint = 0; + let observedSigterm = 0; + nock(DEEPL_FREE_API_URL) + .post('/v2/translate') + .reply(() => { + observedSigint = process.listenerCount('SIGINT') - sigintBefore; + observedSigterm = process.listenerCount('SIGTERM') - sigtermBefore; + return [200, { translations: [{ text: 'übersetzt', detected_source_language: 'EN', billed_characters: 1 }] }]; + }); + + const config = await loadSyncConfig(tmpDir); + await syncService.sync(config); + + expect(observedSigint).toBeGreaterThanOrEqual(1); + expect(observedSigterm).toBeGreaterThanOrEqual(1); + expect(process.listenerCount('SIGINT')).toBe(sigintBefore); + expect(process.listenerCount('SIGTERM')).toBe(sigtermBefore); + }); + }); + + describe('multi-bucket sync', () => { + let multiBucketDir: string; + let multiBucketClient: DeepLClient; + let multiBucketService: SyncService; + + beforeEach(() => { + multiBucketDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-sync-multi-')); + const services = createServices({ withYaml: true }); + multiBucketClient = services.client; + multiBucketService = services.syncService; + }); + + afterEach(() => { + multiBucketClient.destroy(); + if (fs.existsSync(multiBucketDir)) { + fs.rmSync(multiBucketDir, { recursive: true, force: true }); + } + }); + + it('should process both json and yaml buckets', async () => { + const multiBucketConfig = `version: 1 +source_locale: en +target_locales: + - de +buckets: + json: + include: + - "locales/en/*.json" + yaml: + include: + - "locales/en/*.yaml" +`; + writeYamlConfig(multiBucketDir, multiBucketConfig); + + const jsonContent = JSON.stringify({ title: 'Hello', subtitle: 'World' }, null, 2) + '\n'; + writeSourceFile(multiBucketDir, 'locales/en/messages.json', jsonContent); + + const yamlContent = 'nav:\n home: Home\n about: About\n'; + writeSourceFile(multiBucketDir, 'locales/en/navigation.yaml', yamlContent); + + const jsonScope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate', (body: Record) => { + const texts = body['text'] as string[] | undefined; + return Array.isArray(texts) && texts.includes('Hello'); + }) + .reply(200, { + translations: [ + { text: 'Hallo', detected_source_language: 'EN', billed_characters: 5 }, + { text: 'Welt', detected_source_language: 'EN', billed_characters: 5 }, + ], + }); + + const yamlScope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate', (body: Record) => { + const texts = body['text'] as string[] | undefined; + return Array.isArray(texts) && texts.includes('About'); + }) + .reply(200, { + translations: [ + { text: 'Uber', detected_source_language: 'EN', billed_characters: 5 }, + { text: 'Startseite', detected_source_language: 'EN', billed_characters: 10 }, + ], + }); + + const config = await loadSyncConfig(multiBucketDir); + const result = await multiBucketService.sync(config); + + expect(result.success).toBe(true); + expect(result.totalKeys).toBe(4); + expect(result.newKeys).toBe(4); + expect(jsonScope.isDone()).toBe(true); + expect(yamlScope.isDone()).toBe(true); + + const jsonTargetFile = path.join(multiBucketDir, 'locales', 'de', 'messages.json'); + expect(fs.existsSync(jsonTargetFile)).toBe(true); + + const yamlTargetFile = path.join(multiBucketDir, 'locales', 'de', 'navigation.yaml'); + expect(fs.existsSync(yamlTargetFile)).toBe(true); + + const lockPath = path.join(multiBucketDir, LOCK_FILE_NAME); + expect(fs.existsSync(lockPath)).toBe(true); + const lockContent = JSON.parse(fs.readFileSync(lockPath, 'utf-8')); + expect(lockContent.entries['locales/en/messages.json']).toBeDefined(); + expect(lockContent.entries['locales/en/navigation.yaml']).toBeDefined(); + }); + }); + + describe('ICU MessageFormat preservation', () => { + it('should translate ICU leaf text while preserving structure', async () => { + const icuConfig = `version: 1 +source_locale: en +target_locales: + - de +buckets: + json: + include: + - "locales/en.json" +`; + writeYamlConfig(tmpDir, icuConfig); + + // Keys sorted alphabetically by JsonFormatParser: greeting (idx 0), items (idx 1) + // The ICU string at idx 1 becomes __ICU_PLACEHOLDER_1__ in the main batch. + const sourceContent = JSON.stringify( + { + items: '{count, plural, one {# item} other {# items}}', + greeting: 'Hello', + }, + null, + 2, + ) + '\n'; + writeSourceFile(tmpDir, 'locales/en.json', sourceContent); + + // Main batch: "Hello" at idx 0 and the ICU placeholder at idx 1 + const mainScope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate', (body: Record) => { + const texts = getTexts(body); + return texts.some(t => t === 'Hello') && + texts.some(t => t.startsWith('__ICU_PLACEHOLDER_')); + }) + .reply(200, (_uri: string, rawBody: unknown) => { + const texts = getTexts(rawBody); + return { + translations: texts.map(t => ({ + text: t.startsWith('__ICU_PLACEHOLDER_') ? t : 'Hallo', + detected_source_language: 'EN', + billed_characters: 5, + })), + }; + }); + + // ICU segments batch: the leaf texts extracted from the plural branches + // "# item" and "# items" — with # protected as __VAR_HASH_0__ + const icuScope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate', (body: Record) => { + const texts = getTexts(body); + return texts.length === 2 && !texts.some(t => t.startsWith('__ICU_PLACEHOLDER_')); + }) + .reply(200, (_uri: string, rawBody: unknown) => { + const texts = getTexts(rawBody); + return { + translations: texts.map(t => ({ + text: t.replace(/item(s?)/, 'Artikel'), + detected_source_language: 'EN', + billed_characters: 7, + })), + }; + }); + + const config = await loadSyncConfig(tmpDir); + const result = await syncService.sync(config); + + expect(result.success).toBe(true); + expect(result.totalKeys).toBe(2); + expect(mainScope.isDone()).toBe(true); + expect(icuScope.isDone()).toBe(true); + + const targetFile = path.join(tmpDir, 'locales', 'de.json'); + expect(fs.existsSync(targetFile)).toBe(true); + const translated = JSON.parse(fs.readFileSync(targetFile, 'utf-8')); + + expect(translated['items']).toMatch(/\{count, plural,/); + expect(translated['items']).toMatch(/one \{/); + expect(translated['items']).toMatch(/other \{/); + expect(translated['items']).toContain('Artikel'); + + expect(translated['greeting']).toBe('Hallo'); + }); + }); + + describe('plural-is-ICU preservation', () => { + // Locks the preprocessor pipeline ordering: plural-expand → ICU-detect → translate + // → ICU-reassemble → plural-writeback. Any reorder silently leaks __ICU_PLACEHOLDER_ + // or translates into the wrong plural slot. + it('should translate ICU leaves inside Android plural quantities', async () => { + client.destroy(); + const services = createServices({ withAndroid: true }); + client = services.client; + const localSyncService = services.syncService; + + const pluralIcuConfig = `version: 1 +source_locale: en +target_locales: + - de +buckets: + android_xml: + include: + - "locales/en/strings.xml" +`; + writeYamlConfig(tmpDir, pluralIcuConfig); + + const sourceXml = ` + + Hello + + {n, plural, one {# widget}} + {n, plural, other {# widgets}} + + +`; + writeSourceFile(tmpDir, 'locales/en/strings.xml', sourceXml); + + // Main batch: "Hello" plus one or two ICU placeholders for the plural quantities + const mainScope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate', (body: Record) => { + const texts = getTexts(body); + return texts.some(t => t === 'Hello') && + texts.some(t => t.startsWith('__ICU_PLACEHOLDER_')); + }) + .reply(200, (_uri: string, rawBody: unknown) => { + const texts = getTexts(rawBody); + return { + translations: texts.map(t => ({ + text: t.startsWith('__ICU_PLACEHOLDER_') ? t : 'Hallo', + detected_source_language: 'EN', + billed_characters: 5, + })), + }; + }); + + // ICU segments batch: the leaf texts from the plural branches ("# widget", "# widgets") + // — with # protected via __VAR_HASH_N__ preservation + const icuScope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate', (body: Record) => { + const texts = getTexts(body); + return texts.length >= 1 && !texts.some(t => t.startsWith('__ICU_PLACEHOLDER_')); + }) + .reply(200, (_uri: string, rawBody: unknown) => { + const texts = getTexts(rawBody); + return { + translations: texts.map(t => ({ + text: t.replace(/widget(s?)/, (_m, s: string) => s ? 'Widgets' : 'Widget'), + detected_source_language: 'EN', + billed_characters: 7, + })), + }; + }).persist(); + + const config = await loadSyncConfig(tmpDir); + const result = await localSyncService.sync(config); + + expect(result.success).toBe(true); + expect(mainScope.isDone()).toBe(true); + expect(icuScope.isDone()).toBe(true); + + const targetFile = path.join(tmpDir, 'locales', 'de', 'strings.xml'); + expect(fs.existsSync(targetFile)).toBe(true); + const translatedXml = fs.readFileSync(targetFile, 'utf-8'); + + // Stop-the-line: no placeholder leaked into the final written file + expect(translatedXml).not.toContain('__ICU_PLACEHOLDER_'); + // Plural structure preserved + expect(translatedXml).toMatch(//); + + // Extract each quantity's inner text — bites the mutation: if plural-expand runs + // BEFORE ICU-detect the quantity values go through ICU reassemble and contain + // "Widget" / "Widgets". If the loop order is swapped, the "one" quantity bypasses + // ICU detection and the mock's fallback returns "Hallo" — which we reject here. + const oneMatch = translatedXml.match(/]*>([\s\S]*?)<\/item>/); + const otherMatch = translatedXml.match(/]*>([\s\S]*?)<\/item>/); + expect(oneMatch).not.toBeNull(); + expect(otherMatch).not.toBeNull(); + expect(oneMatch![1]).toContain('Widget'); + expect(oneMatch![1]).not.toContain('Widgets'); + expect(otherMatch![1]).toContain('Widgets'); + + // Plain string also translated + expect(translatedXml).toContain('Hallo'); + }); + }); + + describe('three-way translation partitioning', () => { + it('should route keys to correct paths based on context and element type', async () => { + const contextConfig = `version: 1 +source_locale: en +target_locales: + - de +buckets: + json: + include: + - "locales/en.json" +context: + enabled: true + scan_paths: + - "src/**/*.tsx" +translation: + instruction_templates: + button: "Keep concise." + th: "Table header." +`; + writeYamlConfig(tmpDir, contextConfig); + + const sourceJson = JSON.stringify( + { + 'hero.title': 'Welcome', + 'hero.subtitle': 'Start here', + 'btn.save': 'Save', + 'table.name': 'Name', + 'misc': 'Other', + }, + null, + 2, + ) + '\n'; + writeSourceFile(tmpDir, 'locales/en.json', sourceJson); + + const tsxContent = `import { t } from 'i18n'; +export function Page() { + return ( +
+

{t('hero.title')}

+

{t('hero.subtitle')}

+ + {t('table.name')} +
+ ); +} +`; + writeSourceFile(tmpDir, 'src/components/Page.tsx', tsxContent); + + const translateCalls: Array<{ texts: string[]; context?: string; instructions?: string[] }> = []; + + nock(DEEPL_FREE_API_URL) + .post('/v2/translate') + .times(10) + .reply(200, (_uri: string, rawBody: unknown) => { + const parsed = parseNockBody(rawBody); + const texts = getTexts(rawBody); + const context = parsed['context']; + const instructions = parsed['custom_instructions']; + translateCalls.push({ + texts, + context: Array.isArray(context) ? context[0] : context, + instructions: instructions ? (Array.isArray(instructions) ? instructions : [instructions]) : undefined, + }); + return { + translations: texts.map(t => ({ + text: t + '_DE', + detected_source_language: 'EN', + billed_characters: t.length, + })), + }; + }); + + const config = await loadSyncConfig(tmpDir); + const result = await syncService.sync(config); + + expect(result.success).toBe(true); + expect(result.totalKeys).toBe(5); + + expect(result.strategy).toBeDefined(); + expect(result.strategy!.context).toBeGreaterThan(0); + + const contextCalls = translateCalls.filter(c => c.context && c.context.length > 0); + expect(contextCalls.length).toBeGreaterThan(0); + + const targetFile = path.join(tmpDir, 'locales', 'de.json'); + expect(fs.existsSync(targetFile)).toBe(true); + const translated = JSON.parse(fs.readFileSync(targetFile, 'utf-8')); + expect(Object.keys(translated)).toHaveLength(5); + }); + }); + + describe('section-batched context', () => { + it('should batch keys from same section with shared context', async () => { + const sectionConfig = `version: 1 +source_locale: en +target_locales: + - de +buckets: + json: + include: + - "locales/en.json" +context: + enabled: true + scan_paths: + - "src/**/*.tsx" +`; + writeYamlConfig(tmpDir, sectionConfig); + + const sourceJson = JSON.stringify( + { + 'nav.home': 'Home', + 'nav.about': 'About', + 'nav.contact': 'Contact', + 'footer.copyright': 'Copyright', + }, + null, + 2, + ) + '\n'; + writeSourceFile(tmpDir, 'locales/en.json', sourceJson); + + const tsxContent = `import { t } from 'i18n'; +export function Layout() { + return ( + +
+

{t('footer.copyright')}

+
+ ); +} +`; + writeSourceFile(tmpDir, 'src/Layout.tsx', tsxContent); + + const translateCalls: Array<{ texts: string[]; context?: string }> = []; + + nock(DEEPL_FREE_API_URL) + .post('/v2/translate') + .times(10) + .reply(200, (_uri: string, rawBody: unknown) => { + const parsed = parseNockBody(rawBody); + const texts = getTexts(rawBody); + const context = parsed['context']; + translateCalls.push({ + texts, + context: Array.isArray(context) ? context[0] : context, + }); + return { + translations: texts.map(t => ({ + text: t + '_DE', + detected_source_language: 'EN', + billed_characters: t.length, + })), + }; + }); + + const config = await loadSyncConfig(tmpDir); + const result = await syncService.sync(config); + + expect(result.success).toBe(true); + expect(result.totalKeys).toBe(4); + + // Section batching: nav.* keys share section "nav" and should be batched together + const navBatch = translateCalls.find( + c => c.context && c.context.includes('nav') && c.texts.length >= 3, + ); + expect(navBatch).toBeDefined(); + expect(navBatch!.texts).toEqual(expect.arrayContaining(['Home', 'About', 'Contact'])); + + // Footer is a separate section batch + const footerBatch = translateCalls.find( + c => c.context?.includes('footer'), + ); + expect(footerBatch).toBeDefined(); + expect(footerBatch!.texts).toContain('Copyright'); + + // Verify all keys translated in target file + const targetFile = path.join(tmpDir, 'locales', 'de.json'); + expect(fs.existsSync(targetFile)).toBe(true); + const translated = JSON.parse(fs.readFileSync(targetFile, 'utf-8')); + expect(Object.keys(translated)).toHaveLength(4); + }); + }); + + describe('new locale addition', () => { + it('should translate all keys for a newly added locale', async () => { + writeYamlConfig(tmpDir, `version: 1 +source_locale: en +target_locales: + - de +buckets: + json: + include: + - "locales/en.json" +`); + writeSourceFile(tmpDir, 'locales/en.json', SOURCE_JSON); + + nock(DEEPL_FREE_API_URL) + .post('/v2/translate', (body: Record) => { + return body['target_lang'] === 'DE'; + }) + .reply(200, { + translations: [ + { text: 'Auf Wiedersehen', detected_source_language: 'EN', billed_characters: 15 }, + { text: 'Hallo', detected_source_language: 'EN', billed_characters: 5 }, + { text: 'Willkommen', detected_source_language: 'EN', billed_characters: 10 }, + ], + }); + + const firstConfig = await loadSyncConfig(tmpDir); + const firstResult = await syncService.sync(firstConfig); + expect(firstResult.success).toBe(true); + expect(fs.existsSync(path.join(tmpDir, 'locales', 'de.json'))).toBe(true); + + // Second sync: add fr + writeYamlConfig(tmpDir, `version: 1 +source_locale: en +target_locales: + - de + - fr +buckets: + json: + include: + - "locales/en.json" +`); + + // Track which target_lang values the API is called with + const calledLocales: string[] = []; + nock(DEEPL_FREE_API_URL) + .post('/v2/translate') + .times(5) + .reply(200, (_uri: string, rawBody: unknown) => { + const parsed = parseNockBody(rawBody); + const targetLang = parsed['target_lang']; + calledLocales.push(Array.isArray(targetLang) ? targetLang[0]! : targetLang as string); + return { + translations: [ + { text: 'Au revoir', detected_source_language: 'EN', billed_characters: 10 }, + { text: 'Bonjour', detected_source_language: 'EN', billed_characters: 7 }, + { text: 'Bienvenue', detected_source_language: 'EN', billed_characters: 9 }, + ], + }; + }); + + const secondConfig = await loadSyncConfig(tmpDir); + const secondResult = await syncService.sync(secondConfig); + expect(secondResult.success).toBe(true); + + // Only FR should have been called (DE already had all translations) + expect(calledLocales).toContain('FR'); + expect(calledLocales).not.toContain('DE'); + + const frFile = path.join(tmpDir, 'locales', 'fr.json'); + expect(fs.existsSync(frFile)).toBe(true); + const frTranslated = JSON.parse(fs.readFileSync(frFile, 'utf-8')); + expect(frTranslated).toHaveProperty('greeting'); + expect(frTranslated).toHaveProperty('farewell'); + expect(frTranslated).toHaveProperty('welcome'); + + // Lock file should have translations for both locales + const lockFilePath = path.join(tmpDir, LOCK_FILE_NAME); + const lockContent = JSON.parse(fs.readFileSync(lockFilePath, 'utf-8')); + const entries = lockContent.entries['locales/en.json']; + expect(entries['greeting'].translations['de']).toBeDefined(); + expect(entries['greeting'].translations['fr']).toBeDefined(); + expect(entries['greeting'].translations['de'].status).toBe('translated'); + expect(entries['greeting'].translations['fr'].status).toBe('translated'); + }); + }); + + describe('cost cap (max_characters) — new locale addition', () => { + // 10 keys, each ~50 chars → ~500 chars total for zh → exceeds cap of 100 + const KEYS_50_CHARS = Object.fromEntries( + Array.from({ length: 10 }, (_, i) => [`key${i}`, 'a'.repeat(50)]), + ); + + function seedLockWithDeOnly(dir: string, keys: Record): void { + const now = '2026-01-01T00:00:00Z'; + const entries: Record> = {}; + const fileEntries: Record = {}; + for (const [key, val] of Object.entries(keys)) { + const hash = computeSourceHash(val); + fileEntries[key] = { source_text: val, source_hash: hash, translations: { de: { hash, translated_at: now, status: 'translated' } } }; + } + entries['locales/en.json'] = fileEntries; + fs.writeFileSync( + path.join(dir, LOCK_FILE_NAME), + JSON.stringify({ version: 1, generated_at: now, source_locale: 'en', entries, stats: {} }, null, 2) + '\n', + 'utf-8', + ); + } + + it('should throw cost-cap ValidationError before any API call when adding a new locale', async () => { + writeYamlConfig(tmpDir, `version: 1 +source_locale: en +target_locales: + - de + - zh +sync: + max_characters: 100 +buckets: + json: + include: + - "locales/en.json" +`); + writeSourceFile(tmpDir, 'locales/en.json', JSON.stringify(KEYS_50_CHARS, null, 2) + '\n'); + seedLockWithDeOnly(tmpDir, KEYS_50_CHARS); + + // No nock scope — any HTTP call must fail the test + nock(DEEPL_FREE_API_URL).post('/v2/translate').reply(200, { translations: [] }); + + const config = await loadSyncConfig(tmpDir); + await expect(syncService.sync(config)).rejects.toThrow(ValidationError); + + // Confirm no HTTP requests were made + expect(nock.pendingMocks().length).toBe(1); // the unused mock is still pending + }); + + it('dry-run estimatedChars matches live-path preflight estimatedChars for new-locale addition', async () => { + writeYamlConfig(tmpDir, `version: 1 +source_locale: en +target_locales: + - de + - zh +sync: + max_characters: 999999 +buckets: + json: + include: + - "locales/en.json" +`); + writeSourceFile(tmpDir, 'locales/en.json', JSON.stringify(KEYS_50_CHARS, null, 2) + '\n'); + seedLockWithDeOnly(tmpDir, KEYS_50_CHARS); + + const config = await loadSyncConfig(tmpDir); + const dryResult = await syncService.sync(config, { dryRun: true }); + + // dry-run must estimate currentChars * newLocaleCount (10 keys * 50 chars * 1 new locale = 500) + expect(dryResult.estimatedCharacters).toBe(500); + + // The live path preflight must compute the same estimate; verify by setting cap just below it + const configAtCap = await loadSyncConfig(tmpDir); + (configAtCap as any).sync = { ...configAtCap.sync, max_characters: 499 }; + await expect(syncService.sync(configAtCap)).rejects.toThrow(ValidationError); + + const configAboveCap = await loadSyncConfig(tmpDir); + (configAboveCap as any).sync = { ...configAboveCap.sync, max_characters: 500 }; + nock(DEEPL_FREE_API_URL).post('/v2/translate').times(20).reply(200, { + translations: [{ text: 'zh-text', detected_source_language: 'EN', billed_characters: 1 }], + }); + await expect(syncService.sync(configAboveCap)).resolves.not.toThrow(); + }); + }); + + describe('error recovery', () => { + it('should retry on 429 and complete successfully', async () => { + const retryClient = new DeepLClient(TEST_API_KEY, { maxRetries: 1 }); + const mockConfig = createMockConfigService({ + get: jest.fn(() => ({ + auth: {}, + api: { baseUrl: '', usePro: false }, + defaults: { targetLangs: [], formality: 'default', preserveFormatting: false }, + cache: { enabled: false }, + output: { format: 'text', color: true }, + proxy: {}, + })), + getValue: jest.fn(() => false), + }); + const mockCache = createMockCacheService(); + const retryTranslation = new TranslationService(retryClient, mockConfig, mockCache); + const retryGlossary = new GlossaryService(retryClient); + const retryRegistry = new FormatRegistry(); + retryRegistry.register(new JsonFormatParser()); + const retrySyncService = new SyncService(retryTranslation, retryGlossary, retryRegistry); + + writeYamlConfig(tmpDir, `version: 1 +source_locale: en +target_locales: + - de +buckets: + json: + include: + - "locales/en.json" +`); + writeSourceFile(tmpDir, 'locales/en.json', JSON.stringify({ hello: 'Hello' }, null, 2) + '\n'); + + // First request: 429 with Retry-After: 0 + nock(DEEPL_FREE_API_URL) + .post('/v2/translate') + .reply(429, { message: 'Too many requests' }, { 'Retry-After': '0' }); + + // Second request (retry): 200 with translation + nock(DEEPL_FREE_API_URL) + .post('/v2/translate') + .reply(200, { + translations: [ + { text: 'Hallo', detected_source_language: 'EN', billed_characters: 5 }, + ], + }); + + const config = await loadSyncConfig(tmpDir); + const result = await retrySyncService.sync(config); + + expect(result.success).toBe(true); + expect(result.totalKeys).toBe(1); + + const targetFile = path.join(tmpDir, 'locales', 'de.json'); + expect(fs.existsSync(targetFile)).toBe(true); + const translated = JSON.parse(fs.readFileSync(targetFile, 'utf-8')); + expect(translated['hello']).toBe('Hallo'); + + retryClient.destroy(); + }); + + it('should retry on 503 and complete successfully', async () => { + const retryClient = new DeepLClient(TEST_API_KEY, { maxRetries: 1 }); + const mockConfig = createMockConfigService({ + get: jest.fn(() => ({ + auth: {}, + api: { baseUrl: '', usePro: false }, + defaults: { targetLangs: [], formality: 'default', preserveFormatting: false }, + cache: { enabled: false }, + output: { format: 'text', color: true }, + proxy: {}, + })), + getValue: jest.fn(() => false), + }); + const mockCache = createMockCacheService(); + const retryTranslation = new TranslationService(retryClient, mockConfig, mockCache); + const retryGlossary = new GlossaryService(retryClient); + const retryRegistry = new FormatRegistry(); + retryRegistry.register(new JsonFormatParser()); + const retrySyncService = new SyncService(retryTranslation, retryGlossary, retryRegistry); + + writeYamlConfig(tmpDir, BASIC_CONFIG_YAML); + writeSourceFile(tmpDir, 'locales/en.json', JSON.stringify({ hello: 'Hello' }, null, 2) + '\n'); + + const scope503 = nock(DEEPL_FREE_API_URL) + .post('/v2/translate') + .reply(503, { message: 'Service unavailable' }, { 'Retry-After': '0' }); + const scope200 = nock(DEEPL_FREE_API_URL) + .post('/v2/translate') + .reply(200, { + translations: [ + { text: 'Hallo', detected_source_language: 'EN', billed_characters: 5 }, + ], + }); + + const start = Date.now(); + const config = await loadSyncConfig(tmpDir); + const result = await retrySyncService.sync(config); + const elapsed = Date.now() - start; + + expect(result.success).toBe(true); + expect(result.totalKeys).toBe(1); + expect(scope503.isDone()).toBe(true); + expect(scope200.isDone()).toBe(true); + // Guard against runaway backoff — with Retry-After: 0 the whole retry should finish fast. + expect(elapsed).toBeLessThan(3000); + + const targetFile = path.join(tmpDir, 'locales', 'de.json'); + expect(fs.existsSync(targetFile)).toBe(true); + + retryClient.destroy(); + }); + + it('records per-locale failure in fileResults without aborting sibling locales', async () => { + const multiLocaleYaml = `version: 1 +source_locale: en +target_locales: + - de + - fr +buckets: + json: + include: + - "locales/en.json" +`; + writeYamlConfig(tmpDir, multiLocaleYaml); + writeSourceFile(tmpDir, 'locales/en.json', SOURCE_JSON); + + // de: 200 with translations. + const deScope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate', (body: Record) => body['target_lang'] === 'DE') + .reply(200, { + translations: [ + { text: 'Auf Wiedersehen', detected_source_language: 'EN', billed_characters: 15 }, + { text: 'Hallo', detected_source_language: 'EN', billed_characters: 5 }, + { text: 'Willkommen', detected_source_language: 'EN', billed_characters: 10 }, + ], + }); + + // fr: 500 (transient server error — sync catches per-locale, does not throw globally). + const frScope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate', (body: Record) => body['target_lang'] === 'FR') + .reply(500, { message: 'Internal Server Error' }); + + const config = await loadSyncConfig(tmpDir); + const result = await syncService.sync(config); + + expect(deScope.isDone()).toBe(true); + expect(frScope.isDone()).toBe(true); + + const deFile = path.join(tmpDir, 'locales', 'de.json'); + const frFile = path.join(tmpDir, 'locales', 'fr.json'); + expect(fs.existsSync(deFile)).toBe(true); + expect(fs.existsSync(frFile)).toBe(false); + + const deResult = result.fileResults.find((r) => r.locale === 'de'); + const frResult = result.fileResults.find((r) => r.locale === 'fr'); + expect(deResult?.written).toBe(true); + expect(deResult?.translated).toBeGreaterThan(0); + expect(frResult?.written).toBe(false); + expect(frResult?.failed).toBeGreaterThan(0); + }); + + it('splits large key sets across multiple POST calls at the 50-key batch boundary', async () => { + const sourceData: Record = {}; + const batch1: Array<{ text: string; detected_source_language: string; billed_characters: number }> = []; + const batch2: Array<{ text: string; detected_source_language: string; billed_characters: number }> = []; + for (let i = 0; i < 100; i++) { + const key = `key_${String(i).padStart(3, '0')}`; + sourceData[key] = `source ${key}`; + const translation = { + text: `translated ${key}`, + detected_source_language: 'EN', + billed_characters: 10, + }; + if (i < 50) batch1.push(translation); + else batch2.push(translation); + } + + writeYamlConfig(tmpDir, BASIC_CONFIG_YAML); + writeSourceFile(tmpDir, 'locales/en.json', JSON.stringify(sourceData, null, 2) + '\n'); + + const scope1 = nock(DEEPL_FREE_API_URL) + .post('/v2/translate', (body: unknown) => getTexts(body).length === 50) + .reply(200, { translations: batch1 }); + const scope2 = nock(DEEPL_FREE_API_URL) + .post('/v2/translate', (body: unknown) => getTexts(body).length === 50) + .reply(200, { translations: batch2 }); + + const config = await loadSyncConfig(tmpDir); + const result = await syncService.sync(config); + + expect(result.success).toBe(true); + expect(result.newKeys).toBe(100); + expect(scope1.isDone()).toBe(true); + expect(scope2.isDone()).toBe(true); + + const targetFile = path.join(tmpDir, 'locales', 'de.json'); + expect(fs.existsSync(targetFile)).toBe(true); + const translated = JSON.parse(fs.readFileSync(targetFile, 'utf-8')); + expect(Object.keys(translated)).toHaveLength(100); + expect(translated['key_000']).toBe('translated key_000'); + expect(translated['key_099']).toBe('translated key_099'); + }); + }); + + describe('xcstrings multi-locale round-trip', () => { + // Blocker case 18: xcstrings is the only multi-locale format and the + // only one whose reconstruct() mutates a file shared across locales. + // A parser regression here would silently corrupt user translation files. + it('translates en source into a de localization within the same .xcstrings file', async () => { + const { XcstringsFormatParser } = jest.requireActual('../../src/formats/xcstrings'); + const xcRegistry = new FormatRegistry(); + xcRegistry.register(new XcstringsFormatParser()); + const xcServices = createServicesWithRegistry(xcRegistry); + + const xcConfig = `version: 1 +source_locale: en +target_locales: + - de +buckets: + xcstrings: + include: + - "Localizable.xcstrings" +`; + writeYamlConfig(tmpDir, xcConfig); + + const xcFile: Record = { + sourceLanguage: 'en', + version: '1.0', + strings: { + greeting: { + comment: 'greeting shown on launch', + localizations: { + en: { stringUnit: { state: 'translated', value: 'Hello' } }, + }, + }, + farewell: { + localizations: { + en: { stringUnit: { state: 'translated', value: 'Goodbye' } }, + }, + }, + }, + }; + writeSourceFile(tmpDir, 'Localizable.xcstrings', JSON.stringify(xcFile, null, 2) + '\n'); + + nock(DEEPL_FREE_API_URL) + .post('/v2/translate') + .reply(200, { + translations: [ + { text: 'Auf Wiedersehen', detected_source_language: 'EN', billed_characters: 15 }, + { text: 'Hallo', detected_source_language: 'EN', billed_characters: 5 }, + ], + }); + + const config = await loadSyncConfig(tmpDir); + const result = await xcServices.syncService.sync(config); + + expect(result.success).toBe(true); + expect(result.newKeys).toBe(2); + + const written = JSON.parse( + fs.readFileSync(path.join(tmpDir, 'Localizable.xcstrings'), 'utf-8'), + ) as { strings: Record }> }; + + // en localizations preserved + expect(written.strings['greeting']!.localizations['en']!.stringUnit.value).toBe('Hello'); + expect(written.strings['farewell']!.localizations['en']!.stringUnit.value).toBe('Goodbye'); + // de localizations populated + expect(written.strings['greeting']!.localizations['de']!.stringUnit.value).toBe('Hallo'); + expect(written.strings['greeting']!.localizations['de']!.stringUnit.state).toBe('translated'); + expect(written.strings['farewell']!.localizations['de']!.stringUnit.value).toBe('Auf Wiedersehen'); + + xcServices.client.destroy(); + }); + }); + + describe('po round-trip', () => { + it('reconstructs a de.po file with translated msgstr, preserving msgctxt and long-string wrapping', async () => { + const poRegistry = new FormatRegistry(); + poRegistry.register(new PoFormatParser()); + const poServices = createServicesWithRegistry(poRegistry); + + writeYamlConfig(tmpDir, `version: 1 +source_locale: en +target_locales: + - de +buckets: + po: + include: + - "locales/en.po" +`); + + const longSource = + 'This is a very long description that should wrap across multiple lines when reconstructed because gettext prefers shorter lines for readability.'; + writeSourceFile( + tmpDir, + 'locales/en.po', + `msgctxt "button/save" +msgid "Save" +msgstr "" + +msgid "${longSource}" +msgstr "" +`, + ); + + const longTranslation = + 'Dies ist eine sehr lange Beschreibung, die beim Wiederaufbau ueber mehrere Zeilen umgebrochen werden sollte, da gettext kuerzere Zeilen zur besseren Lesbarkeit bevorzugt.'; + + const scope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate') + .reply(200, { + translations: [ + { text: 'Speichern', detected_source_language: 'EN', billed_characters: 4 }, + { text: longTranslation, detected_source_language: 'EN', billed_characters: longSource.length }, + ], + }); + + const config = await loadSyncConfig(tmpDir); + const result = await poServices.syncService.sync(config); + + expect(result.success).toBe(true); + expect(result.newKeys).toBe(2); + expect(scope.isDone()).toBe(true); + + const targetPath = path.join(tmpDir, 'locales', 'de.po'); + expect(fs.existsSync(targetPath)).toBe(true); + const written = fs.readFileSync(targetPath, 'utf-8'); + + expect(written).toContain('msgctxt "button/save"'); + expect(written).toContain('msgstr "Speichern"'); + expect(written).toContain(longTranslation.split(' ').slice(0, 3).join(' ')); + + const reparsed = new PoFormatParser().extract(written); + const byKey = new Map(reparsed.map((e) => [e.key, e.value])); + expect(byKey.size).toBe(2); + + poServices.client.destroy(); + }); + }); + + describe('toml round-trip', () => { + it('translates nested-table keys and preserves table structure on reconstruct', async () => { + const tomlRegistry = new FormatRegistry(); + tomlRegistry.register(new TomlFormatParser()); + const tomlServices = createServicesWithRegistry(tomlRegistry); + + writeYamlConfig(tmpDir, `version: 1 +source_locale: en +target_locales: + - de +buckets: + toml: + include: + - "locales/en.toml" +`); + writeSourceFile( + tmpDir, + 'locales/en.toml', + `[greetings] +hello = "Hello" + +[farewells] +goodbye = "Goodbye" +`, + ); + + const scope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate') + .reply(200, { + translations: [ + { text: 'Auf Wiedersehen', detected_source_language: 'EN', billed_characters: 15 }, + { text: 'Hallo', detected_source_language: 'EN', billed_characters: 5 }, + ], + }); + + const config = await loadSyncConfig(tmpDir); + const result = await tomlServices.syncService.sync(config); + + expect(result.success).toBe(true); + expect(result.newKeys).toBe(2); + expect(scope.isDone()).toBe(true); + + const targetPath = path.join(tmpDir, 'locales', 'de.toml'); + expect(fs.existsSync(targetPath)).toBe(true); + const written = fs.readFileSync(targetPath, 'utf-8'); + + const reparsed = new TomlFormatParser().extract(written); + const byKey = new Map(reparsed.map((e) => [e.key, e.value])); + expect(byKey.get('greetings.hello')).toBe('Hallo'); + expect(byKey.get('farewells.goodbye')).toBe('Auf Wiedersehen'); + + tomlServices.client.destroy(); + }); + }); + + describe('arb round-trip', () => { + it('translates user-facing keys while preserving @-prefixed metadata and placeholder markup', async () => { + const arbRegistry = new FormatRegistry(); + arbRegistry.register(new ArbFormatParser()); + const arbServices = createServicesWithRegistry(arbRegistry); + + writeYamlConfig(tmpDir, `version: 1 +source_locale: en +target_locales: + - de +buckets: + arb: + include: + - "locales/en.arb" +`); + + const sourceArb = { + greeting: 'Hello, {name}', + '@greeting': { + description: 'Greeting with a name placeholder', + placeholders: { name: { type: 'String' } }, + }, + welcome: 'Welcome', + }; + writeSourceFile(tmpDir, 'locales/en.arb', JSON.stringify(sourceArb, null, 2) + '\n'); + + const scope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate') + .reply(200, { + translations: [ + { text: 'Hallo, {name}', detected_source_language: 'EN', billed_characters: 13 }, + { text: 'Willkommen', detected_source_language: 'EN', billed_characters: 10 }, + ], + }); + + const config = await loadSyncConfig(tmpDir); + const result = await arbServices.syncService.sync(config); + + expect(result.success).toBe(true); + expect(result.newKeys).toBe(2); + expect(scope.isDone()).toBe(true); + + const targetPath = path.join(tmpDir, 'locales', 'de.arb'); + expect(fs.existsSync(targetPath)).toBe(true); + const written = JSON.parse(fs.readFileSync(targetPath, 'utf-8')) as Record; + + expect(written['greeting']).toBe('Hallo, {name}'); + expect(written['welcome']).toBe('Willkommen'); + expect(written['@greeting']).toEqual({ + description: 'Greeting with a name placeholder', + placeholders: { name: { type: 'String' } }, + }); + + arbServices.client.destroy(); + }); + }); + + describe('ios_strings round-trip', () => { + it('translates entries while re-escaping quotes and preserving %@ placeholders', async () => { + const iosRegistry = new FormatRegistry(); + iosRegistry.register(new IosStringsFormatParser()); + const iosServices = createServicesWithRegistry(iosRegistry); + + writeYamlConfig(tmpDir, `version: 1 +source_locale: en +target_locales: + - de +buckets: + ios_strings: + include: + - "locales/en.strings" +`); + writeSourceFile( + tmpDir, + 'locales/en.strings', + `"button_save" = "Save \\"Now\\""; +"greeting" = "Welcome %@"; +`, + ); + + const scope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate') + .reply(200, { + translations: [ + { text: 'Jetzt "speichern"', detected_source_language: 'EN', billed_characters: 15 }, + { text: 'Willkommen %@', detected_source_language: 'EN', billed_characters: 13 }, + ], + }); + + const config = await loadSyncConfig(tmpDir); + const result = await iosServices.syncService.sync(config); + + expect(result.success).toBe(true); + expect(result.newKeys).toBe(2); + expect(scope.isDone()).toBe(true); + + const targetPath = path.join(tmpDir, 'locales', 'de.strings'); + expect(fs.existsSync(targetPath)).toBe(true); + const written = fs.readFileSync(targetPath, 'utf-8'); + + expect(written).toContain('"button_save" = "Jetzt \\"speichern\\"";'); + expect(written).toContain('"greeting" = "Willkommen %@";'); + + const reparsed = new IosStringsFormatParser().extract(written); + const byKey = new Map(reparsed.map((e) => [e.key, e.value])); + expect(byKey.get('button_save')).toBe('Jetzt "speichern"'); + expect(byKey.get('greeting')).toBe('Willkommen %@'); + + iosServices.client.destroy(); + }); + }); + + describe('properties round-trip', () => { + it('handles keys with spaces, values containing =, and unicode characters', async () => { + const propRegistry = new FormatRegistry(); + propRegistry.register(new PropertiesFormatParser()); + const propServices = createServicesWithRegistry(propRegistry); + + writeYamlConfig(tmpDir, `version: 1 +source_locale: en +target_locales: + - de +buckets: + properties: + include: + - "locales/en.properties" +`); + writeSourceFile( + tmpDir, + 'locales/en.properties', + `greeting=Hello +formula=x=5 +cafe label=Café +`, + ); + + const scope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate') + .reply(200, { + translations: [ + { text: 'Café', detected_source_language: 'EN', billed_characters: 4 }, + { text: 'x=5', detected_source_language: 'EN', billed_characters: 3 }, + { text: 'Hallo', detected_source_language: 'EN', billed_characters: 5 }, + ], + }); + + const config = await loadSyncConfig(tmpDir); + const result = await propServices.syncService.sync(config); + + expect(result.success).toBe(true); + expect(result.newKeys).toBe(3); + expect(scope.isDone()).toBe(true); + + const targetPath = path.join(tmpDir, 'locales', 'de.properties'); + expect(fs.existsSync(targetPath)).toBe(true); + const written = fs.readFileSync(targetPath, 'utf-8'); + + const reparsed = new PropertiesFormatParser().extract(written); + const byKey = new Map(reparsed.map((e) => [e.key, e.value])); + expect(byKey.get('greeting')).toBe('Hallo'); + expect(byKey.get('formula')).toBe('x=5'); + expect(byKey.get('cafe label')).toBe('Café'); + + propServices.client.destroy(); + }); + }); +}); + +describe('Sync Integration — glossary: auto', () => { + let tmpDir: string; + let client: DeepLClient; + let syncService: SyncService; + + const sourceWithRepeatedTerm = JSON.stringify( + { + 'button.save': 'Save', + 'form.save': 'Save', + 'menu.save': 'Save', + greeting: 'Hello', + }, + null, + 2, + ) + '\n'; + + const autoGlossaryConfig = `version: 1 +source_locale: en +target_locales: + - de +buckets: + json: + include: + - "locales/en.json" +translation: + glossary: auto +`; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-sync-gl-auto-')); + const services = createServices(); + client = services.client; + syncService = services.syncService; + }); + + afterEach(() => { + client.destroy(); + if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); + nock.cleanAll(); + }); + + it('creates a new glossary after first sync and persists the glossary_id in the lockfile', async () => { + writeYamlConfig(tmpDir, autoGlossaryConfig); + writeSourceFile(tmpDir, 'locales/en.json', sourceWithRepeatedTerm); + + // translateBatch deduplicates via Set, so 3 "Save" occurrences collapse to 1 request text. + // Extraction order is alphabetical: button.save, form.save, greeting, menu.save. + // Dedup preserves first-seen order → texts sent: ["Save", "Hello"]. + const translateScope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate') + .reply(200, { + translations: [ + { text: 'Speichern', detected_source_language: 'EN', billed_characters: 4 }, + { text: 'Hallo', detected_source_language: 'EN', billed_characters: 5 }, + ], + }); + + // getGlossaryByName → listGlossaries: no existing glossary + const listScope = nock(DEEPL_FREE_API_URL) + .get('/v3/glossaries') + .reply(200, { glossaries: [] }); + + // createGlossary + const createScope = nock(DEEPL_FREE_API_URL) + .post('/v3/glossaries') + .reply(201, { + glossary_id: 'gl-test-en-de', + name: 'deepl-sync-en-de', + creation_time: '2026-04-19T12:00:00Z', + dictionaries: [{ source_lang: 'EN', target_lang: 'DE', entry_count: 1 }], + }); + + const config = await loadSyncConfig(tmpDir); + const result = await syncService.sync(config); + + expect(result.success).toBe(true); + expect(translateScope.isDone()).toBe(true); + expect(listScope.isDone()).toBe(true); + expect(createScope.isDone()).toBe(true); + + // Lockfile records the created glossary id for future runs + const lockFilePath = path.join(tmpDir, LOCK_FILE_NAME); + const lockContent = JSON.parse(fs.readFileSync(lockFilePath, 'utf-8')); + expect(lockContent.glossary_ids).toBeDefined(); + expect(lockContent.glossary_ids['en-de']).toBe('gl-test-en-de'); + }); + + it('updates the existing glossary on a re-sync without creating a new one', async () => { + writeYamlConfig(tmpDir, autoGlossaryConfig); + + // Source adds one new key on top of the previously-synced set. + writeSourceFile( + tmpDir, + 'locales/en.json', + JSON.stringify( + { + 'button.save': 'Save', + 'form.save': 'Save', + 'menu.save': 'Save', + greeting: 'Hello', + farewell: 'Goodbye', + }, + null, + 2, + ) + '\n', + ); + + // Pre-seed target file (previous sync's output). + writeSourceFile( + tmpDir, + 'locales/de.json', + JSON.stringify( + { + 'button.save': 'Speichern', + 'form.save': 'Speichern', + 'menu.save': 'Speichern', + greeting: 'Hallo', + }, + null, + 2, + ) + '\n', + ); + + const now = '2026-04-19T11:00:00Z'; + const saveHash = computeSourceHash('Save'); + const helloHash = computeSourceHash('Hello'); + const translatedDe = (hash: string) => ({ hash, translated_at: now, status: 'translated' as const }); + const seedEntries = { + 'locales/en.json': { + 'button.save': { source_text: 'Save', source_hash: saveHash, translations: { de: translatedDe(saveHash) } }, + 'form.save': { source_text: 'Save', source_hash: saveHash, translations: { de: translatedDe(saveHash) } }, + 'menu.save': { source_text: 'Save', source_hash: saveHash, translations: { de: translatedDe(saveHash) } }, + greeting: { source_text: 'Hello', source_hash: helloHash, translations: { de: translatedDe(helloHash) } }, + }, + }; + fs.writeFileSync( + path.join(tmpDir, LOCK_FILE_NAME), + JSON.stringify( + { + _comment: 'test lockfile', + version: 1, + generated_at: now, + source_locale: 'en', + glossary_ids: { 'en-de': 'gl-existing' }, + entries: seedEntries, + stats: { total_keys: 4, total_translations: 4, last_sync: now }, + }, + null, + 2, + ) + '\n', + 'utf-8', + ); + + // The new key "farewell" triggers one translate call for "Goodbye". + const translateScope = nock(DEEPL_FREE_API_URL) + .post('/v2/translate') + .reply(200, { + translations: [ + { text: 'Auf Wiedersehen', detected_source_language: 'EN', billed_characters: 7 }, + ], + }); + + // Existing glossary found by name (a POST /v3/glossaries would be the failure case). + const listScope = nock(DEEPL_FREE_API_URL) + .get('/v3/glossaries') + .reply(200, { + glossaries: [ + { + glossary_id: 'gl-existing', + name: 'deepl-sync-en-de', + creation_time: '2026-04-18T10:00:00Z', + dictionaries: [{ source_lang: 'EN', target_lang: 'DE', entry_count: 1 }], + }, + ], + }); + // Entries of existing glossary already match the single extracted term ("Save" -> "Speichern"), + // so no add/remove calls fire. + const entriesScope = nock(DEEPL_FREE_API_URL) + .get('/v3/glossaries/gl-existing/entries') + .query(true) + .reply(200, { + dictionaries: [ + { source_lang: 'EN', target_lang: 'DE', entries: 'Save\tSpeichern', entries_format: 'tsv' }, + ], + }); + + // If a translate or POST /v3/glossaries call happens, nock will raise an unmatched-request error + // (we have not mocked either), which fails the test. + + const config = await loadSyncConfig(tmpDir); + const result = await syncService.sync(config); + + expect(result.success).toBe(true); + expect(translateScope.isDone()).toBe(true); + expect(listScope.isDone()).toBe(true); + expect(entriesScope.isDone()).toBe(true); + + // Lockfile glossary_id preserved — not overwritten, not cleared. + const lockContent = JSON.parse(fs.readFileSync(path.join(tmpDir, LOCK_FILE_NAME), 'utf-8')); + expect(lockContent.glossary_ids['en-de']).toBe('gl-existing'); + }); +}); + +describe('Sync Integration — translation memory', () => { + const TM_UUID_MY = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + const TM_UUID_BASE = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; + const TM_UUID_DE_SPECIFIC = 'cccccccc-cccc-cccc-cccc-cccccccccccc'; + const TM_UUID_DE = 'dddddddd-dddd-dddd-dddd-dddddddddddd'; + const TM_UUID_FR = 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee'; + const TM_UUID_SHARED = 'ffffffff-ffff-ffff-ffff-ffffffffffff'; + + let tmpDir: string; + let client: DeepLClient; + let syncService: SyncService; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-sync-tm-')); + const services = createServices(); + client = services.client; + syncService = services.syncService; + }); + + afterEach(() => { + client.destroy(); + if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); + nock.cleanAll(); + }); + + interface TmEntry { + id: string; + name: string; + source: string; + target: string; + } + + function mockListTms(tms: TmEntry[], times: number = 1): nock.Scope { + const scope = nock(DEEPL_FREE_API_URL) + .get('/v3/translation_memories') + .times(times) + .reply(200, { + translation_memories: tms.map((t) => ({ + translation_memory_id: t.id, + name: t.name, + source_language: t.source, + target_languages: [t.target], + })), + }); + return scope; + } + + interface CapturedCall { + targetLang: string; + tmId: string | undefined; + tmThreshold: string | undefined; + body: Record; + } + + function mockTranslateCapture(capture: CapturedCall[], times: number): nock.Scope { + return nock(DEEPL_FREE_API_URL) + .post('/v2/translate') + .times(times) + .reply(200, (_uri: string, rawBody: unknown) => { + const parsed = parseNockBody(rawBody); + const targetLangRaw = parsed['target_lang']; + const targetLang = Array.isArray(targetLangRaw) ? targetLangRaw[0]! : (targetLangRaw as string); + const tmIdRaw = parsed['translation_memory_id']; + const tmThresholdRaw = parsed['translation_memory_threshold']; + capture.push({ + targetLang, + tmId: Array.isArray(tmIdRaw) ? tmIdRaw[0] : (tmIdRaw as string | undefined), + tmThreshold: Array.isArray(tmThresholdRaw) ? tmThresholdRaw[0] : (tmThresholdRaw as string | undefined), + body: parsed, + }); + const texts = getTexts(rawBody); + return { + translations: texts.map((t) => ({ + text: `${t}_${targetLang}`, + detected_source_language: 'EN', + billed_characters: t.length, + })), + }; + }); + } + + // ---- 1. Top-level-only TM ---- + // + // Brief asked for two target locales (de, fr) with a single top-level TM and + // "one list call, both translate bodies carry the same TM id". Task 3 wires + // the top-level pair-check against every effective target locale at once + // (sync-service.ts:163), which makes a single en→de TM incompatible with a + // [de, fr] target set — it throws before any translate call fires. The + // narrower assertion remains: top-level name resolves once and flows into + // the translate body with threshold defaulting to 75. + describe('top-level TM only', () => { + it('resolves via a single list call and threads id + default threshold into the translate body', async () => { + writeYamlConfig( + tmpDir, + `version: 1 +source_locale: en +target_locales: + - de +buckets: + json: + include: + - "locales/en.json" +translation: + translation_memory: "my-tm" +`, + ); + writeSourceFile(tmpDir, 'locales/en.json', JSON.stringify({ greeting: 'Hello' }, null, 2) + '\n'); + + const listScope = mockListTms([ + { id: TM_UUID_MY, name: 'my-tm', source: 'en', target: 'de' }, + ]); + + const calls: CapturedCall[] = []; + const translateScope = mockTranslateCapture(calls, 1); + + const config = await loadSyncConfig(tmpDir); + const result = await syncService.sync(config); + + expect(result.success).toBe(true); + expect(listScope.isDone()).toBe(true); + + const deCall = calls.find((c) => c.targetLang === 'DE'); + expect(deCall).toBeDefined(); + expect(deCall!.tmId).toBe(TM_UUID_MY); + expect(deCall!.tmThreshold).toBe('75'); + expect(translateScope.isDone()).toBe(true); + }); + }); + + // ---- 2. Per-locale override precedence ---- + describe('per-locale override precedence', () => { + it('prefers override TM id over top-level TM id for the override locale', async () => { + // Single target locale so the top-level pair-check passes. Both TM names + // hit the list endpoint once each (cache is keyed by name), so two list + // calls fire. The override TM wins on the de translate body. + writeYamlConfig( + tmpDir, + `version: 1 +source_locale: en +target_locales: + - de +buckets: + json: + include: + - "locales/en.json" +translation: + translation_memory: "base-tm" + locale_overrides: + de: + translation_memory: "de-specific-tm" +`, + ); + writeSourceFile(tmpDir, 'locales/en.json', JSON.stringify({ greeting: 'Hello' }, null, 2) + '\n'); + + const listScope = mockListTms( + [ + { id: TM_UUID_BASE, name: 'base-tm', source: 'en', target: 'de' }, + { id: TM_UUID_DE_SPECIFIC, name: 'de-specific-tm', source: 'en', target: 'de' }, + ], + 2, + ); + + const calls: CapturedCall[] = []; + const translateScope = mockTranslateCapture(calls, 1); + + const config = await loadSyncConfig(tmpDir); + const result = await syncService.sync(config); + + expect(result.success).toBe(true); + expect(listScope.isDone()).toBe(true); + + const deCall = calls.find((c) => c.targetLang === 'DE'); + expect(deCall!.tmId).toBe(TM_UUID_DE_SPECIFIC); + expect(translateScope.isDone()).toBe(true); + }); + + it('routes each locale to its own per-locale override TM id in a multi-locale sync', async () => { + // No top-level TM (avoids the multi-target pair-check ceiling), one + // override per locale. Two distinct names → two list calls. + writeYamlConfig( + tmpDir, + `version: 1 +source_locale: en +target_locales: + - de + - fr +buckets: + json: + include: + - "locales/en.json" +translation: + locale_overrides: + de: + translation_memory: "de-tm" + fr: + translation_memory: "fr-tm" +`, + ); + writeSourceFile(tmpDir, 'locales/en.json', JSON.stringify({ greeting: 'Hello' }, null, 2) + '\n'); + + const listScope = mockListTms( + [ + { id: TM_UUID_DE, name: 'de-tm', source: 'en', target: 'de' }, + { id: TM_UUID_FR, name: 'fr-tm', source: 'en', target: 'fr' }, + ], + 2, + ); + + const calls: CapturedCall[] = []; + const translateScope = mockTranslateCapture(calls, 2); + + const config = await loadSyncConfig(tmpDir); + const result = await syncService.sync(config); + + expect(result.success).toBe(true); + expect(listScope.isDone()).toBe(true); + + const deCall = calls.find((c) => c.targetLang === 'DE'); + const frCall = calls.find((c) => c.targetLang === 'FR'); + expect(deCall!.tmId).toBe(TM_UUID_DE); + expect(frCall!.tmId).toBe(TM_UUID_FR); + expect(translateScope.isDone()).toBe(true); + }); + }); + + // ---- 3. Threshold inheritance ---- + describe('threshold inheritance', () => { + it('uses per-locale threshold override when set', async () => { + writeYamlConfig( + tmpDir, + `version: 1 +source_locale: en +target_locales: + - de +buckets: + json: + include: + - "locales/en.json" +translation: + translation_memory: "my-tm" + translation_memory_threshold: 60 + locale_overrides: + de: + translation_memory_threshold: 85 +`, + ); + writeSourceFile(tmpDir, 'locales/en.json', JSON.stringify({ greeting: 'Hello' }, null, 2) + '\n'); + + const listScope = mockListTms([ + { id: TM_UUID_MY, name: 'my-tm', source: 'en', target: 'de' }, + ]); + + const calls: CapturedCall[] = []; + const translateScope = mockTranslateCapture(calls, 1); + + const config = await loadSyncConfig(tmpDir); + await syncService.sync(config); + + const deCall = calls.find((c) => c.targetLang === 'DE'); + expect(deCall!.tmId).toBe(TM_UUID_MY); + expect(deCall!.tmThreshold).toBe('85'); + expect(listScope.isDone()).toBe(true); + expect(translateScope.isDone()).toBe(true); + }); + + it('inherits top-level threshold when the locale has no override', async () => { + writeYamlConfig( + tmpDir, + `version: 1 +source_locale: en +target_locales: + - de +buckets: + json: + include: + - "locales/en.json" +translation: + translation_memory: "my-tm" + translation_memory_threshold: 60 +`, + ); + writeSourceFile(tmpDir, 'locales/en.json', JSON.stringify({ greeting: 'Hello' }, null, 2) + '\n'); + + const listScope = mockListTms([ + { id: TM_UUID_MY, name: 'my-tm', source: 'en', target: 'de' }, + ]); + + const calls: CapturedCall[] = []; + const translateScope = mockTranslateCapture(calls, 1); + + const config = await loadSyncConfig(tmpDir); + await syncService.sync(config); + + const deCall = calls.find((c) => c.targetLang === 'DE'); + expect(deCall!.tmThreshold).toBe('60'); + expect(listScope.isDone()).toBe(true); + expect(translateScope.isDone()).toBe(true); + }); + }); + + // ---- 4. Default threshold (75) ---- + describe('default threshold', () => { + it('emits translation_memory_threshold=75 when no threshold is set anywhere', async () => { + writeYamlConfig( + tmpDir, + `version: 1 +source_locale: en +target_locales: + - de +buckets: + json: + include: + - "locales/en.json" +translation: + translation_memory: "my-tm" +`, + ); + writeSourceFile(tmpDir, 'locales/en.json', JSON.stringify({ greeting: 'Hello' }, null, 2) + '\n'); + + const listScope = mockListTms([ + { id: TM_UUID_MY, name: 'my-tm', source: 'en', target: 'de' }, + ]); + + const calls: CapturedCall[] = []; + const translateScope = mockTranslateCapture(calls, 1); + + const config = await loadSyncConfig(tmpDir); + await syncService.sync(config); + + const deCall = calls.find((c) => c.targetLang === 'DE'); + expect(deCall!.tmThreshold).toBe('75'); + expect(listScope.isDone()).toBe(true); + expect(translateScope.isDone()).toBe(true); + }); + }); + + // ---- 5. Omit-both on no-TM config ---- + describe('omit-both on no-TM config', () => { + it('sends neither translation_memory_id nor translation_memory_threshold and makes zero list calls', async () => { + writeYamlConfig(tmpDir, BASIC_CONFIG_YAML); + writeSourceFile(tmpDir, 'locales/en.json', JSON.stringify({ greeting: 'Hello' }, null, 2) + '\n'); + + const calls: CapturedCall[] = []; + const translateScope = mockTranslateCapture(calls, 1); + + const config = await loadSyncConfig(tmpDir); + await syncService.sync(config); + + expect(calls.length).toBeGreaterThan(0); + for (const call of calls) { + expect(call.tmId).toBeUndefined(); + expect(call.tmThreshold).toBeUndefined(); + expect(call.body['translation_memory_id']).toBeUndefined(); + expect(call.body['translation_memory_threshold']).toBeUndefined(); + } + + const listMocksAfter = nock.pendingMocks().filter((m) => m.includes('/v3/translation_memories')); + expect(listMocksAfter).toEqual([]); + expect(translateScope.isDone()).toBe(true); + }); + }); + + // ---- 6. Lockfile non-recording (ADR Q7) ---- + describe('lockfile non-recording', () => { + it('does not write translation_memory_ids onto the lockfile after a TM-configured sync', async () => { + writeYamlConfig( + tmpDir, + `version: 1 +source_locale: en +target_locales: + - de +buckets: + json: + include: + - "locales/en.json" +translation: + translation_memory: "my-tm" +`, + ); + writeSourceFile(tmpDir, 'locales/en.json', JSON.stringify({ greeting: 'Hello' }, null, 2) + '\n'); + + const listScope = mockListTms([ + { id: TM_UUID_MY, name: 'my-tm', source: 'en', target: 'de' }, + ]); + const translateScope = mockTranslateCapture([], 1); + + const config = await loadSyncConfig(tmpDir); + const result = await syncService.sync(config); + expect(result.success).toBe(true); + + const lockContent = JSON.parse( + fs.readFileSync(path.join(tmpDir, LOCK_FILE_NAME), 'utf-8'), + ) as Record; + expect(lockContent['translation_memory_ids']).toBeUndefined(); + expect(listScope.isDone()).toBe(true); + expect(translateScope.isDone()).toBe(true); + }); + }); + + // ---- 7. Cache-collision regression guard (Integration's Layer-3 guardrail) ---- + // + // Shared TM name at top-level and per-locale override, with a per-locale + // target that the TM does not support. A silent cache hit on the second + // resolve would skip the pair check; the top-level pair-check catches the + // collision first in the current wiring, but the outcome the test locks + // in is the same: ConfigError, not silent acceptance. + describe('cache-collision regression guard', () => { + it('throws ConfigError when a shared TM name cannot cover every target locale', async () => { + writeYamlConfig( + tmpDir, + `version: 1 +source_locale: en +target_locales: + - de + - fr +buckets: + json: + include: + - "locales/en.json" +translation: + translation_memory: "shared" + locale_overrides: + fr: + translation_memory: "shared" +`, + ); + writeSourceFile(tmpDir, 'locales/en.json', JSON.stringify({ greeting: 'Hello' }, null, 2) + '\n'); + + const listScope = mockListTms( + [{ id: TM_UUID_SHARED, name: 'shared', source: 'en', target: 'de' }], + 1, + ); + + const config = await loadSyncConfig(tmpDir); + const caught = await syncService.sync(config).catch((e: unknown) => e); + expect(caught).toBeInstanceOf(ConfigError); + expect((caught as Error).message).toMatch(/does not support the requested language pair/); + + expect(listScope.isDone()).toBe(true); + }); + }); + + // ---- 8. Invalid YAML threshold ---- + describe('invalid YAML threshold', () => { + it('rejects translation_memory_threshold out of range at loadSyncConfig with key path in the message', async () => { + writeYamlConfig( + tmpDir, + `version: 1 +source_locale: en +target_locales: + - de +buckets: + json: + include: + - "locales/en.json" +translation: + translation_memory: "my-tm" + translation_memory_threshold: 999 +`, + ); + writeSourceFile(tmpDir, 'locales/en.json', JSON.stringify({ greeting: 'Hello' }, null, 2) + '\n'); + + await expect(loadSyncConfig(tmpDir)).rejects.toThrow(ConfigError); + await expect(loadSyncConfig(tmpDir)).rejects.toThrow( + /translation\.translation_memory_threshold must be an integer between 0 and 100/, + ); + }); + }); + + // ---- 9. Dry-run suppression ---- + describe('dry-run suppression', () => { + it('makes zero list calls and zero translate calls when dryRun is set even with TM configured', async () => { + writeYamlConfig( + tmpDir, + `version: 1 +source_locale: en +target_locales: + - de +buckets: + json: + include: + - "locales/en.json" +translation: + translation_memory: "my-tm" + locale_overrides: + de: + translation_memory: "de-specific-tm" +`, + ); + writeSourceFile(tmpDir, 'locales/en.json', JSON.stringify({ greeting: 'Hello' }, null, 2) + '\n'); + + // No nock interceptors registered — any outbound request would + // surface as an unmatched-request error and fail the test. + + const config = await loadSyncConfig(tmpDir); + const result = await syncService.sync(config, { dryRun: true }); + + expect(result.dryRun).toBe(true); + expect(nock.pendingMocks()).toEqual([]); + }); + }); +}); + +function createServicesWithRegistry(registry: FormatRegistry): { client: DeepLClient; syncService: SyncService } { + const client = new DeepLClient(TEST_API_KEY, { maxRetries: 0 }); + const mockConfig = createMockConfigService({ + get: jest.fn(() => ({ + auth: {}, + api: { baseUrl: '', usePro: false }, + defaults: { targetLangs: [], formality: 'default', preserveFormatting: false }, + cache: { enabled: false }, + output: { format: 'text', color: true }, + proxy: {}, + })), + getValue: jest.fn(() => false), + }); + const mockCache = createMockCacheService(); + const translationService = new TranslationService(client, mockConfig, mockCache); + const glossaryService = new GlossaryService(client); + const syncService = new SyncService(translationService, glossaryService, registry); + return { client, syncService }; +} diff --git a/tests/unit/atomic-write.test.ts b/tests/unit/atomic-write.test.ts index be0f638..dd50825 100644 --- a/tests/unit/atomic-write.test.ts +++ b/tests/unit/atomic-write.test.ts @@ -1,7 +1,12 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { atomicWriteFile, atomicWriteFileSync } from '../../src/utils/atomic-write'; +import { + atomicWriteFile, + atomicWriteFileSync, + __cleanupInFlightTmpFiles, + __getInFlightTmpCount, +} from '../../src/utils/atomic-write'; describe('atomicWriteFile', () => { let tmpDir: string; @@ -23,7 +28,8 @@ describe('atomicWriteFile', () => { it('should not leave .tmp file after success', async () => { const filePath = path.join(tmpDir, 'output.txt'); await atomicWriteFile(filePath, 'content', 'utf-8'); - expect(fs.existsSync(filePath + '.tmp')).toBe(false); + const leftover = fs.readdirSync(tmpDir).filter((f) => f.startsWith('output.txt.tmp.')); + expect(leftover).toHaveLength(0); }); it('should clean up .tmp file after rename failure', async () => { @@ -31,7 +37,8 @@ describe('atomicWriteFile', () => { const renameSpy = jest.spyOn(fs.promises, 'rename').mockRejectedValueOnce(new Error('rename failed')); await expect(atomicWriteFile(filePath, 'content', 'utf-8')).rejects.toThrow('rename failed'); - expect(fs.existsSync(filePath + '.tmp')).toBe(false); + const leftover = fs.readdirSync(tmpDir).filter((f) => f.startsWith('output.txt.tmp.')); + expect(leftover).toHaveLength(0); renameSpy.mockRestore(); }); @@ -49,6 +56,15 @@ describe('atomicWriteFile', () => { await atomicWriteFile(filePath, 'new content', 'utf-8'); expect(fs.readFileSync(filePath, 'utf-8')).toBe('new content'); }); + + it('should use a unique temp filename with PID and random suffix', async () => { + const filePath = path.join(tmpDir, 'output.txt'); + const writeSpy = jest.spyOn(fs.promises, 'writeFile'); + await atomicWriteFile(filePath, 'data', 'utf-8'); + const tmpPathArg = String(writeSpy.mock.calls[0]?.[0]); + expect(tmpPathArg).toMatch(new RegExp(`\\.tmp\\.${process.pid}\\.[a-z0-9]+$`)); + writeSpy.mockRestore(); + }); }); describe('atomicWriteFileSync', () => { @@ -71,14 +87,14 @@ describe('atomicWriteFileSync', () => { it('should not leave .tmp file after success', () => { const filePath = path.join(tmpDir, 'output.txt'); atomicWriteFileSync(filePath, 'content', 'utf-8'); - expect(fs.existsSync(filePath + '.tmp')).toBe(false); + const leftover = fs.readdirSync(tmpDir).filter((f) => f.startsWith('output.txt.tmp.')); + expect(leftover).toHaveLength(0); }); it('should clean up .tmp file after write failure to non-existent directory', () => { const filePath = path.join(tmpDir, 'nonexistent', 'deep', 'output.txt'); expect(() => atomicWriteFileSync(filePath, 'content', 'utf-8')).toThrow(); - expect(fs.existsSync(filePath + '.tmp')).toBe(false); }); it('should support Buffer content', () => { @@ -88,3 +104,92 @@ describe('atomicWriteFileSync', () => { expect(Buffer.compare(fs.readFileSync(filePath), buf)).toBe(0); }); }); + +describe('atomic-write crash cleanup', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'atomic-write-crash-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('tracks the in-flight .tmp sibling mid-write so a SIGINT cleanup can find it', async () => { + const filePath = path.join(tmpDir, 'output.txt'); + const countBefore = __getInFlightTmpCount(); + + let tmpPathObserved: string | null = null; + const writeSpy = jest.spyOn(fs.promises, 'writeFile').mockImplementationOnce(async (p) => { + tmpPathObserved = String(p); + // Write synchronously under our own hand so the file exists to be + // "discovered" by the cleanup helper, simulating a crash between + // write and rename. + fs.writeFileSync(tmpPathObserved, 'partial'); + expect(__getInFlightTmpCount()).toBe(countBefore + 1); + throw new Error('simulated crash'); + }); + + await expect(atomicWriteFile(filePath, 'content', 'utf-8')).rejects.toThrow('simulated crash'); + writeSpy.mockRestore(); + + expect(tmpPathObserved).not.toBeNull(); + // After rejection the registry must be empty again (cleanup path already ran). + expect(__getInFlightTmpCount()).toBe(countBefore); + }); + + it('__cleanupInFlightTmpFiles deletes tracked .tmp files and clears the registry', () => { + // Simulate the crash-in-middle state by seeding the tmp registry manually. + const tmpPath = path.join(tmpDir, 'output.txt.tmp.' + process.pid + '.xyz'); + fs.writeFileSync(tmpPath, 'partial'); + + // Fire through a real atomicWriteFile call whose underlying writeFile hangs: + // we'll use the synchronous helper instead. But easier: call the cleanup + // helper after poking at a private accessor to emulate tracked state. + const writeSpy = jest.spyOn(fs.promises, 'writeFile').mockImplementationOnce(async (p) => { + const actualTmp = String(p); + fs.writeFileSync(actualTmp, 'partial'); + // At this point the tmp is tracked. Trigger the public cleanup. + __cleanupInFlightTmpFiles(); + expect(fs.existsSync(actualTmp)).toBe(false); + throw new Error('abort after cleanup'); + }); + + const run = atomicWriteFile(path.join(tmpDir, 'output.txt'), 'content', 'utf-8'); + return expect(run).rejects.toThrow('abort after cleanup').then(() => { + writeSpy.mockRestore(); + // Seed tmp file should also have been cleaned if tracked — but it wasn't + // tracked (we wrote it ourselves) so it remains. That's expected; the + // helper only cleans up registered paths. + expect(fs.existsSync(tmpPath)).toBe(true); + }); + }); + + it('registers SIGINT/SIGTERM listeners while a write is pending and detaches after', async () => { + // Baseline listener counts before any write. + const sigintBase = process.listenerCount('SIGINT'); + const sigtermBase = process.listenerCount('SIGTERM'); + + let observedSigint = 0; + let observedSigterm = 0; + const writeSpy = jest.spyOn(fs.promises, 'writeFile').mockImplementationOnce(async (p) => { + // Mid-write: signal listeners should be attached. + observedSigint = process.listenerCount('SIGINT') - sigintBase; + observedSigterm = process.listenerCount('SIGTERM') - sigtermBase; + fs.writeFileSync(String(p), 'partial'); + throw new Error('abort mid-write'); + }); + + const run = atomicWriteFile(path.join(tmpDir, 'output.txt'), 'data', 'utf-8'); + await expect(run).rejects.toThrow('abort mid-write'); + writeSpy.mockRestore(); + + expect(observedSigint).toBeGreaterThanOrEqual(1); + expect(observedSigterm).toBeGreaterThanOrEqual(1); + // After the write resolved/rejected, the registry should be empty and + // listeners detached back to baseline. + expect(process.listenerCount('SIGINT')).toBe(sigintBase); + expect(process.listenerCount('SIGTERM')).toBe(sigtermBase); + }); +}); diff --git a/tests/unit/cli-did-you-mean.test.ts b/tests/unit/cli-did-you-mean.test.ts index 2ac554c..9eba8cf 100644 --- a/tests/unit/cli-did-you-mean.test.ts +++ b/tests/unit/cli-did-you-mean.test.ts @@ -6,24 +6,25 @@ import { execSync } from 'child_process'; import * as path from 'path'; describe('CLI did-you-mean suggestions', () => { - const cliPath = path.resolve(__dirname, '../../src/cli/index.ts'); + // Use the compiled CLI (same as every other e2e test). Previously this test + // invoked the TS source via `node --loader ts-node/esm`, which added + // 1.5-2s of cold-start overhead per call and pushed the 10s execSync + // timeout over the edge under full-suite parallelism. + const cliPath = path.resolve(__dirname, '../../dist/cli/index.js'); function runCLI(args: string): { stdout: string; stderr: string; exitCode: number } { try { - const stdout = execSync( - `node --loader ts-node/esm "${cliPath}" ${args}`, - { - encoding: 'utf-8', - env: { ...process.env, NODE_NO_WARNINGS: '1', NO_COLOR: '1' }, - timeout: 10000, - } - ); + const stdout = execSync(`node "${cliPath}" ${args}`, { + encoding: 'utf-8', + env: { ...process.env, NODE_NO_WARNINGS: '1', NO_COLOR: '1' }, + timeout: 10000, + }); return { stdout, stderr: '', exitCode: 0 }; } catch (error: any) { return { stdout: (error.stdout as string) ?? '', stderr: (error.stderr as string) ?? '', - exitCode: error.status as number, + exitCode: (error.status as number) ?? 1, }; } } diff --git a/tests/unit/cli-no-args-exit.test.ts b/tests/unit/cli-no-args-exit.test.ts index ed4cb6d..812923a 100644 --- a/tests/unit/cli-no-args-exit.test.ts +++ b/tests/unit/cli-no-args-exit.test.ts @@ -8,38 +8,32 @@ import { execSync } from 'child_process'; import * as path from 'path'; describe('CLI no-args exit code', () => { - const cliPath = path.resolve(__dirname, '../../src/cli/index.ts'); + // Use the compiled CLI (matches every other e2e test). Previously this + // test invoked the TS source via `node --loader ts-node/esm` which added + // 1.5-2s of cold-load per call; under full-suite jest parallelism the 10s + // execSync timeout occasionally fired and returned exitCode: null. + const cliPath = path.resolve(__dirname, '../../dist/cli/index.js'); it('should exit with code 0 when no arguments are provided', () => { - // Run the CLI with ts-node and capture exit code - // We use tsx/ts-node to execute the TypeScript source directly try { - const output = execSync( - `node --loader ts-node/esm "${cliPath}"`, - { - encoding: 'utf-8', - env: { ...process.env, NODE_NO_WARNINGS: '1' }, - timeout: 10000, - } - ); - // If it exits 0, execSync won't throw + const output = execSync(`node "${cliPath}"`, { + encoding: 'utf-8', + env: { ...process.env, NODE_NO_WARNINGS: '1' }, + timeout: 10000, + }); expect(output).toContain('deepl'); } catch (error: any) { - // If execSync throws, the exit code was non-zero fail(`CLI exited with code ${error.status as number}, expected 0. stderr: ${error.stderr as string}`); } }); it('should show help text when no arguments are provided', () => { try { - const output = execSync( - `node --loader ts-node/esm "${cliPath}"`, - { - encoding: 'utf-8', - env: { ...process.env, NODE_NO_WARNINGS: '1' }, - timeout: 10000, - } - ); + const output = execSync(`node "${cliPath}"`, { + encoding: 'utf-8', + env: { ...process.env, NODE_NO_WARNINGS: '1' }, + timeout: 10000, + }); expect(output).toContain('DeepL CLI'); expect(output).toContain('translate'); } catch (error: any) { diff --git a/tests/unit/cli/describe.test.ts b/tests/unit/cli/describe.test.ts new file mode 100644 index 0000000..07e11ae --- /dev/null +++ b/tests/unit/cli/describe.test.ts @@ -0,0 +1,94 @@ +import { Command } from 'commander'; +import { describeProgram } from '../../../src/cli/commands/describe'; + +describe('describeProgram', () => { + let program: Command; + + beforeEach(() => { + program = new Command(); + program + .name('deepl') + .description('DeepL CLI') + .version('1.0.0') + .option('-q, --quiet', 'Suppress output') + .option('-c, --config ', 'Config file', 'default.json'); + + program + .command('translate') + .description('Translate text or files') + .option('-t, --to ', 'Target language') + .option('-f, --from ', 'Source language'); + + const sync = program + .command('sync') + .alias('sy') + .description('Sync translations with TMS'); + sync.command('push').description('Push translations'); + sync.command('pull').description('Pull translations'); + }); + + describe('shape', () => { + it('returns program name and description at root', () => { + const result = describeProgram(program); + expect(result.name).toBe('deepl'); + expect(result.description).toBe('DeepL CLI'); + }); + + it('returns top-level global options with flags and description', () => { + const result = describeProgram(program); + const quiet = result.options.find((o) => o.flags.includes('--quiet')); + expect(quiet).toBeDefined(); + expect(quiet?.description).toBe('Suppress output'); + }); + + it('includes defaultValue on options that set one', () => { + const result = describeProgram(program); + const config = result.options.find((o) => o.flags.includes('--config')); + expect(config?.defaultValue).toBe('default.json'); + }); + + it('returns subcommands with their descriptions', () => { + const result = describeProgram(program); + const names = result.commands.map((c) => c.name); + expect(names).toContain('translate'); + expect(names).toContain('sync'); + }); + + it('captures subcommand options', () => { + const result = describeProgram(program); + const translate = result.commands.find((c) => c.name === 'translate'); + expect(translate).toBeDefined(); + const to = translate?.options.find((o) => o.flags.includes('--to')); + expect(to?.description).toBe('Target language'); + }); + + it('recurses into nested subcommands', () => { + const result = describeProgram(program); + const sync = result.commands.find((c) => c.name === 'sync'); + expect(sync).toBeDefined(); + const subNames = sync?.commands.map((c) => c.name); + expect(subNames).toEqual(expect.arrayContaining(['push', 'pull'])); + }); + + it('captures command aliases', () => { + const result = describeProgram(program); + const sync = result.commands.find((c) => c.name === 'sync'); + expect(sync?.aliases).toContain('sy'); + }); + + it('returns aliases as empty array when none set', () => { + const result = describeProgram(program); + const translate = result.commands.find((c) => c.name === 'translate'); + expect(translate?.aliases).toEqual([]); + }); + }); + + describe('serialization', () => { + it('produces JSON-serializable output', () => { + const result = describeProgram(program); + expect(() => JSON.stringify(result)).not.toThrow(); + const parsed = JSON.parse(JSON.stringify(result)); + expect(parsed.name).toBe('deepl'); + }); + }); +}); diff --git a/tests/unit/cli/register-sync-force-help.test.ts b/tests/unit/cli/register-sync-force-help.test.ts new file mode 100644 index 0000000..623256a --- /dev/null +++ b/tests/unit/cli/register-sync-force-help.test.ts @@ -0,0 +1,53 @@ +/** + * Help-text test for --force cost-cap warning. + * + * `--force` does two things that users conflate: + * 1. Retranslates all strings (ignores the lockfile delta). + * 2. Bypasses the `sync.max_characters` cost-cap preflight. + * + * The second effect is what surprises people — a CI run with --force can blow + * through the spend cap silently. The help surface should say so up front. + */ + +import { Command } from 'commander'; +import { registerSync } from '../../../src/cli/commands/register-sync'; +import type { ServiceDeps } from '../../../src/cli/commands/service-factory'; + +function makeDeps(): ServiceDeps { + return { + createDeepLClient: jest.fn() as unknown as ServiceDeps['createDeepLClient'], + getApiKeyAndOptions: jest.fn() as unknown as ServiceDeps['getApiKeyAndOptions'], + getConfigService: jest.fn() as unknown as ServiceDeps['getConfigService'], + getCacheService: jest.fn() as unknown as ServiceDeps['getCacheService'], + handleError: jest.fn() as unknown as ServiceDeps['handleError'], + }; +} + +function getForceOption() { + const program = new Command(); + registerSync(program, makeDeps()); + const sync = program.commands.find((c) => c.name() === 'sync'); + if (!sync) throw new Error('sync command not registered'); + const opts = (sync as unknown as { options: Array<{ flags: string; description: string }> }) + .options; + const opt = opts.find((o) => o.flags === '--force'); + if (!opt) throw new Error('--force option not found'); + return opt; +} + +describe('deepl sync --force help', () => { + it('mentions bypassing the sync.max_characters cost-cap', () => { + const opt = getForceOption(); + expect(opt.description).toMatch(/max_characters|cost.cap/i); + }); + + it('warns about API costs / billing', () => { + const opt = getForceOption(); + expect(opt.description).toMatch(/bill|cost|charge/i); + }); + + it('still describes the lockfile-ignoring behavior', () => { + const opt = getForceOption(); + expect(opt.description).toMatch(/retranslate|lock/i); + }); +}); diff --git a/tests/unit/cli/register-sync-init.test.ts b/tests/unit/cli/register-sync-init.test.ts new file mode 100644 index 0000000..f9ac0a8 --- /dev/null +++ b/tests/unit/cli/register-sync-init.test.ts @@ -0,0 +1,157 @@ +/** + * Unit tests for `deepl sync init`'s flag vocabulary. + * + * Exercises the --source-locale / --target-locales rename and the + * --source-lang / --target-langs deprecation aliases. The new primary + * flags work silently; the old aliases continue to work but emit a + * stderr deprecation warning pointing at the replacement. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { Command } from 'commander'; +import { registerSync } from '../../../src/cli/commands/register-sync'; +import type { ServiceDeps } from '../../../src/cli/commands/service-factory'; + +function makeDeps(handleError: jest.Mock): ServiceDeps { + return { + createDeepLClient: jest.fn() as unknown as ServiceDeps['createDeepLClient'], + getApiKeyAndOptions: jest.fn() as unknown as ServiceDeps['getApiKeyAndOptions'], + getConfigService: jest.fn() as unknown as ServiceDeps['getConfigService'], + getCacheService: jest.fn() as unknown as ServiceDeps['getCacheService'], + handleError: handleError as unknown as ServiceDeps['handleError'], + }; +} + +async function runSyncInit(argv: string[], deps: ServiceDeps): Promise { + const program = new Command(); + program.exitOverride(); + registerSync(program, deps); + await program.parseAsync(['node', 'deepl', 'sync', 'init', ...argv]); +} + +describe('deepl sync init flag vocabulary', () => { + let tmpDir: string; + let originalCwd: string; + let stderrSpy: jest.SpyInstance; + let stderrChunks: string[]; + let handleError: jest.Mock; + + beforeEach(() => { + originalCwd = process.cwd(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sync-init-vocab-')); + fs.mkdirSync(path.join(tmpDir, 'locales'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'locales', 'en.json'), '{}'); + process.chdir(tmpDir); + + stderrChunks = []; + stderrSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation((chunk: unknown): boolean => { + stderrChunks.push(typeof chunk === 'string' ? chunk : String(chunk)); + return true; + }); + + handleError = jest.fn(); + }); + + afterEach(() => { + stderrSpy.mockRestore(); + process.chdir(originalCwd); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function stderrText(): string { + return stderrChunks.join(''); + } + + it('--source-locale and --target-locales succeed without a deprecation warning', async () => { + const deps = makeDeps(handleError); + await runSyncInit( + [ + '--source-locale', 'en', + '--target-locales', 'de,fr', + '--file-format', 'json', + '--path', 'locales/en.json', + ], + deps, + ); + expect(handleError).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(tmpDir, '.deepl-sync.yaml'))).toBe(true); + expect(stderrText()).not.toMatch(/\[deprecated\]/); + }); + + it('--source-lang works but emits a stderr deprecation warning naming --source-locale', async () => { + const deps = makeDeps(handleError); + await runSyncInit( + [ + '--source-lang', 'en', + '--target-locales', 'de,fr', + '--file-format', 'json', + '--path', 'locales/en.json', + ], + deps, + ); + expect(handleError).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(tmpDir, '.deepl-sync.yaml'))).toBe(true); + const err = stderrText(); + expect(err).toMatch(/\[deprecated\] --source-lang is renamed to --source-locale/); + expect(err).toMatch(/next major release/); + }); + + it('--target-langs works but emits a stderr deprecation warning naming --target-locales', async () => { + const deps = makeDeps(handleError); + await runSyncInit( + [ + '--source-locale', 'en', + '--target-langs', 'de,fr', + '--file-format', 'json', + '--path', 'locales/en.json', + ], + deps, + ); + expect(handleError).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(tmpDir, '.deepl-sync.yaml'))).toBe(true); + const err = stderrText(); + expect(err).toMatch(/\[deprecated\] --target-langs is renamed to --target-locales/); + expect(err).toMatch(/next major release/); + }); + + it('both deprecated aliases together emit both warnings', async () => { + const deps = makeDeps(handleError); + await runSyncInit( + [ + '--source-lang', 'en', + '--target-langs', 'de,fr', + '--file-format', 'json', + '--path', 'locales/en.json', + ], + deps, + ); + expect(handleError).not.toHaveBeenCalled(); + const err = stderrText(); + expect(err).toMatch(/--source-lang is renamed to --source-locale/); + expect(err).toMatch(/--target-langs is renamed to --target-locales/); + }); + + it('new flag wins when both new and old are supplied (no warning for the flag with the new form)', async () => { + const deps = makeDeps(handleError); + await runSyncInit( + [ + '--source-locale', 'en', + '--source-lang', 'xx', + '--target-locales', 'de', + '--file-format', 'json', + '--path', 'locales/en.json', + ], + deps, + ); + expect(handleError).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(tmpDir, '.deepl-sync.yaml'))).toBe(true); + const yaml = fs.readFileSync(path.join(tmpDir, '.deepl-sync.yaml'), 'utf-8'); + expect(yaml).toMatch(/source_locale:\s*en/); + expect(yaml).not.toMatch(/source_locale:\s*xx/); + expect(stderrText()).not.toMatch(/--source-lang is renamed/); + }); +}); diff --git a/tests/unit/cli/register-sync-root.test.ts b/tests/unit/cli/register-sync-root.test.ts new file mode 100644 index 0000000..f47d0f1 --- /dev/null +++ b/tests/unit/cli/register-sync-root.test.ts @@ -0,0 +1,130 @@ +/** + * Unit tests for the `deepl sync` parent command handler. + * + * Covers the --context/--scan-context rename: bare --context and --no-context + * on `sync` now hard-error (exit 6, ValidationError) with a did-you-mean hint + * pointing at --scan-context. + */ + +import { Command } from 'commander'; +import { registerSync } from '../../../src/cli/commands/register-sync'; +import { ValidationError } from '../../../src/utils/errors'; +import type { ServiceDeps } from '../../../src/cli/commands/service-factory'; + +jest.mock('../../../src/cli/commands/service-factory', () => { + const actual = jest.requireActual('../../../src/cli/commands/service-factory'); + return { + ...(actual as object), + createSyncCommand: jest.fn(), + }; +}); + +const { createSyncCommand: mockCreateSyncCommand } = + require('../../../src/cli/commands/service-factory') as { + createSyncCommand: jest.Mock; + }; + +function makeDeps(handleError: jest.Mock): ServiceDeps { + return { + createDeepLClient: jest.fn() as unknown as ServiceDeps['createDeepLClient'], + getApiKeyAndOptions: jest.fn() as unknown as ServiceDeps['getApiKeyAndOptions'], + getConfigService: jest.fn() as unknown as ServiceDeps['getConfigService'], + getCacheService: jest.fn() as unknown as ServiceDeps['getCacheService'], + handleError: handleError as unknown as ServiceDeps['handleError'], + }; +} + +async function runSync(argv: string[], deps: ServiceDeps): Promise { + const program = new Command(); + program.exitOverride(); + registerSync(program, deps); + await program.parseAsync(['node', 'deepl', 'sync', ...argv]); +} + +describe('deepl sync --context hard-error', () => { + let handleError: jest.Mock; + let capturedSyncOptions: Record | undefined; + + beforeEach(() => { + handleError = jest.fn(); + capturedSyncOptions = undefined; + mockCreateSyncCommand.mockReset(); + mockCreateSyncCommand.mockImplementation(() => + Promise.resolve({ + run: jest.fn((opts: Record) => { + capturedSyncOptions = opts; + return Promise.resolve({ + success: true, + totalKeys: 0, + newKeys: 0, + staleKeys: 0, + deletedKeys: 0, + currentKeys: 0, + totalCharactersBilled: 0, + fileResults: [], + validationWarnings: 0, + validationErrors: 0, + estimatedCharacters: 0, + targetLocaleCount: 0, + dryRun: false, + frozen: false, + driftDetected: false, + lockUpdated: false, + }); + }), + }), + ); + }); + + it('rejects bare --context with a ValidationError that names --scan-context', async () => { + const deps = makeDeps(handleError); + await runSync(['--context'], deps); + expect(handleError).toHaveBeenCalledTimes(1); + const err = handleError.mock.calls[0]![0] as ValidationError; + expect(err).toBeInstanceOf(ValidationError); + expect(err.exitCode).toBe(6); + expect(err.message).toMatch(/--context is not a `deepl sync` flag/); + expect(err.message).toMatch(/`deepl translate --context ""` takes a string/); + expect(err.suggestion).toMatch(/--scan-context \/ --no-scan-context/); + expect(mockCreateSyncCommand).not.toHaveBeenCalled(); + }); + + it('rejects --no-context with the same error', async () => { + const deps = makeDeps(handleError); + await runSync(['--no-context'], deps); + expect(handleError).toHaveBeenCalledTimes(1); + const err = handleError.mock.calls[0]![0] as ValidationError; + expect(err).toBeInstanceOf(ValidationError); + expect(err.exitCode).toBe(6); + expect(err.suggestion).toMatch(/--scan-context/); + expect(mockCreateSyncCommand).not.toHaveBeenCalled(); + }); + + it('accepts --scan-context and dispatches with scanContext=true', async () => { + const deps = makeDeps(handleError); + await runSync(['--scan-context'], deps); + expect(handleError).not.toHaveBeenCalled(); + expect(mockCreateSyncCommand).toHaveBeenCalledTimes(1); + expect(capturedSyncOptions).toBeDefined(); + expect(capturedSyncOptions?.['scanContext']).toBe(true); + }); + + it('accepts --no-scan-context and dispatches with scanContext=false', async () => { + const deps = makeDeps(handleError); + await runSync(['--no-scan-context'], deps); + expect(handleError).not.toHaveBeenCalled(); + expect(mockCreateSyncCommand).toHaveBeenCalledTimes(1); + expect(capturedSyncOptions).toBeDefined(); + expect(capturedSyncOptions?.['scanContext']).toBe(false); + }); + + it('leaves scanContext undefined when neither flag is passed (config default wins)', async () => { + const deps = makeDeps(handleError); + await runSync([], deps); + expect(handleError).not.toHaveBeenCalled(); + expect(mockCreateSyncCommand).toHaveBeenCalledTimes(1); + expect(capturedSyncOptions).toBeDefined(); + expect(capturedSyncOptions?.['scanContext']).toBeUndefined(); + expect(capturedSyncOptions?.['context']).toBeUndefined(); + }); +}); diff --git a/tests/unit/cli/register-sync-scan-context-help.test.ts b/tests/unit/cli/register-sync-scan-context-help.test.ts new file mode 100644 index 0000000..30073be --- /dev/null +++ b/tests/unit/cli/register-sync-scan-context-help.test.ts @@ -0,0 +1,52 @@ +/** + * Help-text test for --no-scan-context. + * + * `--no-scan-context` only overrides `context.enabled` in .deepl-sync.yaml; + * other `context.*` settings (patterns, exclude, etc.) stay applied. The help + * text should say so up front — a terse "Disable source-code context scanning" + * invites the wrong mental model (that the whole context: block is ignored). + */ + +import { Command } from 'commander'; +import { registerSync } from '../../../src/cli/commands/register-sync'; +import type { ServiceDeps } from '../../../src/cli/commands/service-factory'; + +function makeDeps(): ServiceDeps { + return { + createDeepLClient: jest.fn() as unknown as ServiceDeps['createDeepLClient'], + getApiKeyAndOptions: jest.fn() as unknown as ServiceDeps['getApiKeyAndOptions'], + getConfigService: jest.fn() as unknown as ServiceDeps['getConfigService'], + getCacheService: jest.fn() as unknown as ServiceDeps['getCacheService'], + handleError: jest.fn() as unknown as ServiceDeps['handleError'], + }; +} + +function getSyncCommand(): Command { + const program = new Command(); + registerSync(program, makeDeps()); + const sync = program.commands.find((c) => c.name() === 'sync'); + if (!sync) throw new Error('sync command not registered'); + return sync; +} + +function findOption(cmd: Command, flag: string) { + const opts = (cmd as unknown as { options: Array<{ flags: string; description: string }> }) + .options; + return opts.find((o) => o.flags === flag); +} + +describe('deepl sync --no-scan-context help', () => { + it('notes that only context.enabled is overridden', () => { + const sync = getSyncCommand(); + const opt = findOption(sync, '--no-scan-context'); + if (!opt) throw new Error('--no-scan-context option not found'); + expect(opt.description).toMatch(/context\.enabled/); + }); + + it('notes that other context.* settings are preserved', () => { + const sync = getSyncCommand(); + const opt = findOption(sync, '--no-scan-context'); + if (!opt) throw new Error('--no-scan-context option not found'); + expect(opt.description).toMatch(/preserv|other context\.\*|kept/i); + }); +}); diff --git a/tests/unit/cli/register-sync-tms-help.test.ts b/tests/unit/cli/register-sync-tms-help.test.ts new file mode 100644 index 0000000..ffe4c18 --- /dev/null +++ b/tests/unit/cli/register-sync-tms-help.test.ts @@ -0,0 +1,69 @@ +/** + * Help-text tests for sync push/pull TMS onboarding. + * + * Users discovering push/pull via --help see only --locale and --sync-config. + * Runtime throws ConfigError ("TMS integration not configured") when the + * tms: block is absent, which is correct but happens too late — the help + * surface should document the required YAML block and env vars up front. + */ + +import { Command } from 'commander'; +import { registerSync } from '../../../src/cli/commands/register-sync'; +import type { ServiceDeps } from '../../../src/cli/commands/service-factory'; + +function makeDeps(): ServiceDeps { + return { + createDeepLClient: jest.fn() as unknown as ServiceDeps['createDeepLClient'], + getApiKeyAndOptions: jest.fn() as unknown as ServiceDeps['getApiKeyAndOptions'], + getConfigService: jest.fn() as unknown as ServiceDeps['getConfigService'], + getCacheService: jest.fn() as unknown as ServiceDeps['getCacheService'], + handleError: jest.fn() as unknown as ServiceDeps['handleError'], + }; +} + +function renderHelp(name: 'push' | 'pull'): string { + const program = new Command(); + registerSync(program, makeDeps()); + const sync = program.commands.find((c) => c.name() === 'sync'); + if (!sync) throw new Error('sync command not registered'); + const sub = sync.commands.find((c) => c.name() === name); + if (!sub) throw new Error(`sync ${name} command not registered`); + let captured = ''; + sub.configureOutput({ writeOut: (s: string) => { captured += s; } }); + sub.outputHelp(); + return captured; +} + +describe('deepl sync push --help (TMS onboarding hint)', () => { + const help = renderHelp('push'); + + it('mentions the required tms: YAML block', () => { + expect(help).toMatch(/tms:/); + }); + + it('mentions TMS_API_KEY and TMS_TOKEN env vars', () => { + expect(help).toMatch(/TMS_API_KEY/); + expect(help).toMatch(/TMS_TOKEN/); + }); + + it('points users to docs/SYNC.md for the full REST contract', () => { + expect(help).toMatch(/docs\/SYNC\.md/); + }); +}); + +describe('deepl sync pull --help (TMS onboarding hint)', () => { + const help = renderHelp('pull'); + + it('mentions the required tms: YAML block', () => { + expect(help).toMatch(/tms:/); + }); + + it('mentions TMS_API_KEY and TMS_TOKEN env vars', () => { + expect(help).toMatch(/TMS_API_KEY/); + expect(help).toMatch(/TMS_TOKEN/); + }); + + it('points users to docs/SYNC.md for the full REST contract', () => { + expect(help).toMatch(/docs\/SYNC\.md/); + }); +}); diff --git a/tests/unit/cli/register-sync.commander-snapshot.test.ts b/tests/unit/cli/register-sync.commander-snapshot.test.ts new file mode 100644 index 0000000..5fdf86e --- /dev/null +++ b/tests/unit/cli/register-sync.commander-snapshot.test.ts @@ -0,0 +1,607 @@ +/** + * Commander-tree snapshot test for `deepl sync` and its subcommands. + * + * This test guards against observable CLI shape changes during the refactor + * that splits register-sync.ts into per-subcommand builder modules. It walks + * the commander tree produced by registerSync() and captures, for each + * command: name, description, options (flags, description, default, choices, + * negate), and nested subcommands. Any drift to the tree — a renamed flag, + * dropped description, changed default — breaks this test. + */ + +import { Command, Option } from 'commander'; +import { registerSync } from '../../../src/cli/commands/register-sync'; +import type { ServiceDeps } from '../../../src/cli/commands/service-factory'; + +interface OptionSnapshot { + flags: string; + description: string; + defaultValue: unknown; + negate: boolean; + choices: readonly string[] | undefined; + required: boolean; + optional: boolean; + variadic: boolean; + mandatory: boolean; +} + +interface CommandSnapshot { + name: string; + description: string; + options: OptionSnapshot[]; + subcommands: CommandSnapshot[]; +} + +function snapshotOption(opt: Option): OptionSnapshot { + return { + flags: opt.flags, + description: opt.description, + defaultValue: opt.defaultValue, + negate: opt.negate, + choices: opt.argChoices, + required: opt.required, + optional: opt.optional, + variadic: opt.variadic, + mandatory: opt.mandatory, + }; +} + +function snapshotCommand(cmd: Command): CommandSnapshot { + const options = cmd.options + .map((o) => snapshotOption(o)) + .sort((a, b) => a.flags.localeCompare(b.flags)); + const subcommands = cmd.commands + .map((c) => snapshotCommand(c)) + .sort((a, b) => a.name.localeCompare(b.name)); + return { + name: cmd.name(), + description: cmd.description(), + options, + subcommands, + }; +} + +function buildSyncTree(): CommandSnapshot { + const program = new Command(); + const deps: ServiceDeps = { + createDeepLClient: jest.fn() as unknown as ServiceDeps['createDeepLClient'], + getApiKeyAndOptions: jest.fn() as unknown as ServiceDeps['getApiKeyAndOptions'], + getConfigService: jest.fn() as unknown as ServiceDeps['getConfigService'], + getCacheService: jest.fn() as unknown as ServiceDeps['getCacheService'], + handleError: jest.fn() as unknown as ServiceDeps['handleError'], + }; + registerSync(program, deps); + const syncCmd = program.commands.find((c) => c.name() === 'sync'); + if (!syncCmd) throw new Error('sync command not registered'); + return snapshotCommand(syncCmd); +} + +describe('register-sync commander tree', () => { + it('matches the hand-written expected tree (guards CLI shape)', () => { + const actual = buildSyncTree(); + expect(actual).toEqual(EXPECTED); + }); + + it('registers exactly these subcommands', () => { + const actual = buildSyncTree(); + const names = actual.subcommands.map((c) => c.name); + expect(names).toEqual([ + 'audit', + 'export', + 'glossary-report', + 'init', + 'pull', + 'push', + 'resolve', + 'status', + 'validate', + ]); + }); + + it('keeps the hidden glossary-report rejector registered', () => { + const program = new Command(); + const deps: ServiceDeps = { + createDeepLClient: jest.fn() as unknown as ServiceDeps['createDeepLClient'], + getApiKeyAndOptions: jest.fn() as unknown as ServiceDeps['getApiKeyAndOptions'], + getConfigService: jest.fn() as unknown as ServiceDeps['getConfigService'], + getCacheService: jest.fn() as unknown as ServiceDeps['getCacheService'], + handleError: jest.fn() as unknown as ServiceDeps['handleError'], + }; + registerSync(program, deps); + const syncCmd = program.commands.find((c) => c.name() === 'sync'); + const legacy = syncCmd?.commands.find((c) => c.name() === 'glossary-report'); + expect(legacy).toBeDefined(); + }); + + it('parent sync command has --format with text default', () => { + const actual = buildSyncTree(); + const formatOpt = actual.options.find((o) => o.flags.includes('--format')); + expect(formatOpt).toBeDefined(); + expect(formatOpt?.defaultValue).toBe('text'); + expect(formatOpt?.choices).toEqual(['text', 'json']); + }); + + it('every subcommand with --format has text default and text/json choices', () => { + const actual = buildSyncTree(); + for (const sub of actual.subcommands) { + const formatOpt = sub.options.find((o) => o.flags.includes('--format')); + if (formatOpt) { + expect(formatOpt.defaultValue).toBe('text'); + expect(formatOpt.choices).toEqual(['text', 'json']); + } + } + }); +}); + +// Options are stored sorted by flags for stable comparison. +const PARENT_DRY_RUN: OptionSnapshot = { + flags: '--dry-run', + description: 'Show what would change without writing files or calling API', + defaultValue: undefined, + negate: false, + choices: undefined, + required: false, + optional: false, + variadic: false, + mandatory: false, +}; + +const RESOLVE_DRY_RUN: OptionSnapshot = { + flags: '--dry-run', + description: 'Preview the decision report without writing the lockfile', + defaultValue: undefined, + negate: false, + choices: undefined, + required: false, + optional: false, + variadic: false, + mandatory: false, +}; + +const PARENT_SYNC_CONFIG_OPT: OptionSnapshot = { + flags: '--sync-config ', + description: 'Path to .deepl-sync.yaml (default: auto-detect)', + defaultValue: undefined, + negate: false, + choices: undefined, + required: true, + optional: false, + variadic: false, + mandatory: false, +}; + +const SYNC_CONFIG_OPT: OptionSnapshot = { + flags: '--sync-config ', + description: 'Path to .deepl-sync.yaml', + defaultValue: undefined, + negate: false, + choices: undefined, + required: true, + optional: false, + variadic: false, + mandatory: false, +}; + +const LOCALE_FILTER_OPT: OptionSnapshot = { + flags: '--locale ', + description: 'Filter by locale (comma-separated)', + defaultValue: undefined, + negate: false, + choices: undefined, + required: true, + optional: false, + variadic: false, + mandatory: false, +}; + +const FORMAT_OPT: OptionSnapshot = { + flags: '--format ', + description: 'Output format', + defaultValue: 'text', + negate: false, + choices: ['text', 'json'], + required: true, + optional: false, + variadic: false, + mandatory: false, +}; + +const EXPECTED: CommandSnapshot = { + name: 'sync', + description: 'Synchronize translation files using .deepl-sync.yaml config', + options: ([ + { + flags: '--auto-commit', + description: 'Auto-commit translated files after sync', + defaultValue: undefined, + negate: false, + choices: undefined, + required: false, + optional: false, + variadic: false, + mandatory: false, + }, + { + flags: '--batch', + description: 'Force batch mode (fastest, no context or instructions)', + defaultValue: undefined, + negate: false, + choices: undefined, + required: false, + optional: false, + variadic: false, + mandatory: false, + }, + { + flags: '--ci', + description: 'Alias for --frozen', + defaultValue: undefined, + negate: false, + choices: undefined, + required: false, + optional: false, + variadic: false, + mandatory: false, + }, + { + flags: '--concurrency ', + description: 'Max parallel locale translations (default: 5)', + defaultValue: undefined, + negate: false, + choices: undefined, + required: true, + optional: false, + variadic: false, + mandatory: false, + }, + { + flags: '--context', + description: '', + defaultValue: undefined, + negate: false, + choices: undefined, + required: false, + optional: false, + variadic: false, + mandatory: false, + }, + { + flags: '--debounce ', + description: 'Debounce delay for watch mode (default: 500ms)', + defaultValue: undefined, + negate: false, + choices: undefined, + required: true, + optional: false, + variadic: false, + mandatory: false, + }, + PARENT_DRY_RUN, + { + flags: '--flag-for-review', + description: 'Mark translations as machine_translated in lock file for human review', + defaultValue: undefined, + negate: false, + choices: undefined, + required: false, + optional: false, + variadic: false, + mandatory: false, + }, + { + flags: '--force', + description: + 'Retranslate all strings, ignoring the lock file. WARNING: also bypasses the sync.max_characters cost-cap preflight — this can rebill every translated key and incur unexpected API costs. Prefer --dry-run first to see the character estimate.', + defaultValue: undefined, + negate: false, + choices: undefined, + required: false, + optional: false, + variadic: false, + mandatory: false, + }, + FORMAT_OPT, + { + flags: '--formality ', + description: 'Override formality level', + defaultValue: undefined, + negate: false, + choices: ['default', 'more', 'less', 'prefer_more', 'prefer_less', 'formal', 'informal'], + required: true, + optional: false, + variadic: false, + mandatory: false, + }, + { + flags: '--frozen', + description: 'Fail if any strings need translation (for CI)', + defaultValue: undefined, + negate: false, + choices: undefined, + required: false, + optional: false, + variadic: false, + mandatory: false, + }, + { + flags: '--glossary ', + description: 'Override glossary for all buckets', + defaultValue: undefined, + negate: false, + choices: undefined, + required: true, + optional: false, + variadic: false, + mandatory: false, + }, + { + flags: '--locale ', + description: 'Limit to specific target locales (comma-separated)', + defaultValue: undefined, + negate: false, + choices: undefined, + required: true, + optional: false, + variadic: false, + mandatory: false, + }, + { + flags: '--model-type ', + description: 'Override model type', + defaultValue: undefined, + negate: false, + choices: ['quality_optimized', 'prefer_quality_optimized', 'latency_optimized'], + required: true, + optional: false, + variadic: false, + mandatory: false, + }, + { + flags: '--no-batch', + description: + 'Force per-key mode (slowest, individual context per key). Default: section-batched context', + defaultValue: undefined, + negate: true, + choices: undefined, + required: false, + optional: false, + variadic: false, + mandatory: false, + }, + { + flags: '--no-context', + description: '', + defaultValue: undefined, + negate: true, + choices: undefined, + required: false, + optional: false, + variadic: false, + mandatory: false, + }, + { + flags: '--no-scan-context', + description: + 'Disable source-code context scanning for this run. Overrides context.enabled only; other context.* settings in .deepl-sync.yaml are preserved.', + defaultValue: undefined, + negate: true, + choices: undefined, + required: false, + optional: false, + variadic: false, + mandatory: false, + }, + { + flags: '--scan-context', + description: 'Scan source code for context (key paths, HTML element types)', + defaultValue: undefined, + negate: false, + choices: undefined, + required: false, + optional: false, + variadic: false, + mandatory: false, + }, + PARENT_SYNC_CONFIG_OPT, + { + flags: '--watch', + description: 'Watch source files and auto-sync on changes', + defaultValue: undefined, + negate: false, + choices: undefined, + required: false, + optional: false, + variadic: false, + mandatory: false, + }, + { + flags: '-y, --yes', + description: 'Skip --force confirmation prompt (required when CI=true)', + defaultValue: undefined, + negate: false, + choices: undefined, + required: false, + optional: false, + variadic: false, + mandatory: false, + }, + ] as OptionSnapshot[]).sort((a, b) => a.flags.localeCompare(b.flags)), + subcommands: [ + { + name: 'audit', + description: + 'Analyze translation consistency and detect terminology inconsistencies', + options: [FORMAT_OPT, SYNC_CONFIG_OPT].sort((a, b) => + a.flags.localeCompare(b.flags), + ), + subcommands: [], + }, + { + name: 'export', + description: 'Export source strings to XLIFF for CAT tool handoff', + options: [ + FORMAT_OPT, + LOCALE_FILTER_OPT, + { + flags: '--output ', + description: 'Write to file instead of stdout', + defaultValue: undefined, + negate: false, + choices: undefined, + required: true, + optional: false, + variadic: false, + mandatory: false, + }, + { + flags: '--overwrite', + description: 'Overwrite existing --output file', + defaultValue: undefined, + negate: false, + choices: undefined, + required: false, + optional: false, + variadic: false, + mandatory: false, + }, + SYNC_CONFIG_OPT, + ].sort((a, b) => a.flags.localeCompare(b.flags)), + subcommands: [], + }, + { + name: 'glossary-report', + description: '', + options: [], + subcommands: [], + }, + { + name: 'init', + description: 'Initialize .deepl-sync.yaml configuration', + options: [ + FORMAT_OPT, + { + flags: '--file-format ', + description: 'File format', + defaultValue: undefined, + negate: false, + choices: [ + 'json', + 'yaml', + 'po', + 'android_xml', + 'ios_strings', + 'arb', + 'xliff', + 'toml', + 'properties', + 'xcstrings', + 'laravel_php', + ], + required: true, + optional: false, + variadic: false, + mandatory: false, + }, + { + flags: '--path ', + description: 'Source file pattern', + defaultValue: undefined, + negate: false, + choices: undefined, + required: true, + optional: false, + variadic: false, + mandatory: false, + }, + { + flags: '--source-lang ', + description: '', + defaultValue: undefined, + negate: false, + choices: undefined, + required: true, + optional: false, + variadic: false, + mandatory: false, + }, + { + flags: '--source-locale ', + description: 'Source locale', + defaultValue: undefined, + negate: false, + choices: undefined, + required: true, + optional: false, + variadic: false, + mandatory: false, + }, + SYNC_CONFIG_OPT, + { + flags: '--target-langs ', + description: '', + defaultValue: undefined, + negate: false, + choices: undefined, + required: true, + optional: false, + variadic: false, + mandatory: false, + }, + { + flags: '--target-locales ', + description: 'Target locales (comma-separated)', + defaultValue: undefined, + negate: false, + choices: undefined, + required: true, + optional: false, + variadic: false, + mandatory: false, + }, + ].sort((a, b) => a.flags.localeCompare(b.flags)), + subcommands: [], + }, + { + name: 'pull', + description: 'Pull approved translations from a TMS', + options: [FORMAT_OPT, LOCALE_FILTER_OPT, SYNC_CONFIG_OPT].sort((a, b) => + a.flags.localeCompare(b.flags), + ), + subcommands: [], + }, + { + name: 'push', + description: 'Push translations to a TMS for human review', + options: [FORMAT_OPT, LOCALE_FILTER_OPT, SYNC_CONFIG_OPT].sort((a, b) => + a.flags.localeCompare(b.flags), + ), + subcommands: [], + }, + { + name: 'resolve', + description: 'Resolve git merge conflicts in .deepl-sync.lock', + options: [ + FORMAT_OPT, + RESOLVE_DRY_RUN, + SYNC_CONFIG_OPT, + ].sort((a, b) => a.flags.localeCompare(b.flags)), + subcommands: [], + }, + { + name: 'status', + description: 'Show translation coverage status', + options: [FORMAT_OPT, LOCALE_FILTER_OPT, SYNC_CONFIG_OPT].sort((a, b) => + a.flags.localeCompare(b.flags), + ), + subcommands: [], + }, + { + name: 'validate', + description: 'Validate translations for quality issues', + options: [FORMAT_OPT, LOCALE_FILTER_OPT, SYNC_CONFIG_OPT].sort((a, b) => + a.flags.localeCompare(b.flags), + ), + subcommands: [], + }, + ], +}; diff --git a/tests/unit/docs/sync-terminology.test.ts b/tests/unit/docs/sync-terminology.test.ts new file mode 100644 index 0000000..41cefe5 --- /dev/null +++ b/tests/unit/docs/sync-terminology.test.ts @@ -0,0 +1,84 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Terminology regression — keep the word "sync" from drifting back into prose + * as a bare verb. Policy: + * - Noun: "a sync run", "the sync command" — OK. + * - Gerund: "syncing" — avoided in prose; acceptable in runtime log strings + * (which are in src/, not audited here). + * - Verb: spell it as `deepl sync` (inline code) — bare-verb patterns like + * "to sync X" or "sync your Y" are not allowed. + * + * Code blocks (fenced ```...```) and inline-code spans (`...`) are exempted + * because they're literal CLI output or command strings. + */ + +const REPO_ROOT = path.resolve(__dirname, '..', '..', '..'); + +const DOCS = [ + path.join(REPO_ROOT, 'docs', 'API.md'), + path.join(REPO_ROOT, 'docs', 'SYNC.md'), + path.join(REPO_ROOT, 'docs', 'TROUBLESHOOTING.md'), + path.join(REPO_ROOT, 'README.md'), +]; + +// Bare-verb forms we want to stay out of prose. Keep patterns narrow: they +// match "sync" used as a verb, not "sync" used as a noun/attribute. +const BARE_VERB_PATTERNS: RegExp[] = [ + /\bto sync\b/i, // infinitive: "to sync your files" + /\bsync your\b/i, // imperative with possessive: "sync your strings" + /\bsync the (local|remote|content|files|strings|locales|project|glossar)/i, +]; + +/** + * Known bare-verb uses that read more naturally than the canonical rewrite. + * Each entry should point at a specific doc + snippet and explain why it's + * grandfathered; the test allows up to 5 such exceptions to avoid rubber-stamp + * growth. + */ +const TOLERATED: { doc: string; snippet: string; reason: string }[] = []; + +function stripExemptRegions(source: string): string { + // Blank out fenced code blocks while preserving line count so failure + // messages cite the real line number in the source doc. + let out = source.replace(/```[\s\S]*?```/g, (match) => + match.replace(/[^\n]/g, ''), + ); + // Drop inline code spans (single-line, so line count is unaffected). + out = out.replace(/`[^`\n]*`/g, ''); + return out; +} + +describe('sync terminology regression', () => { + it('keeps the TOLERATED exception list bounded', () => { + expect(TOLERATED.length).toBeLessThanOrEqual(5); + }); + + for (const docPath of DOCS) { + const rel = path.relative(REPO_ROOT, docPath); + + describe(`${rel}`, () => { + const raw = fs.readFileSync(docPath, 'utf-8'); + const prose = stripExemptRegions(raw); + const proseLines = prose.split('\n'); + + for (const pattern of BARE_VERB_PATTERNS) { + it(`has no bare-verb matches for ${String(pattern)}`, () => { + const hits: string[] = []; + proseLines.forEach((line, idx) => { + if (pattern.test(line)) { + const tolerated = TOLERATED.some( + (t) => t.doc === rel && line.includes(t.snippet), + ); + if (!tolerated) { + hits.push(` ${rel}:${idx + 1}: ${line.trim()}`); + } + } + }); + expect(hits).toEqual([]); + }); + } + }); + } +}); diff --git a/tests/unit/errors.test.ts b/tests/unit/errors.test.ts new file mode 100644 index 0000000..a410af0 --- /dev/null +++ b/tests/unit/errors.test.ts @@ -0,0 +1,55 @@ +/** + * Typed DeepLCLIError subclasses — symmetry checks across the error + * taxonomy. The classifier tests live in tests/unit/exit-codes.test.ts; + * this file asserts the class contracts that exit-code dispatch leans on. + */ + +import { + DeepLCLIError, + SyncConflictError, + SyncDriftError, +} from '../../src/utils/errors'; +import { ExitCode, exitCodeForError } from '../../src/utils/exit-codes'; + +describe('SyncConflictError', () => { + it('carries the SyncConflict exit code (11)', () => { + const err = new SyncConflictError('boom'); + expect(err.exitCode).toBe(ExitCode.SyncConflict); + expect(err.exitCode).toBe(11); + }); + + it('is a DeepLCLIError (instanceof)', () => { + const err = new SyncConflictError('boom'); + expect(err).toBeInstanceOf(DeepLCLIError); + expect(err).toBeInstanceOf(Error); + }); + + it('name is "SyncConflict" so the JSON envelope stays stable', () => { + const err = new SyncConflictError('boom'); + expect(err.name).toBe('SyncConflict'); + }); + + it('exposes the custom suggestion when provided', () => { + const err = new SyncConflictError('boom', 'do the thing'); + expect(err.suggestion).toBe('do the thing'); + }); + + it('falls back to a default suggestion when none provided', () => { + const err = new SyncConflictError('boom'); + expect(err.suggestion).toMatch(/conflict markers manually/); + expect(err.suggestion).toMatch(/deepl sync/); + }); + + it('is classified by exitCodeForError as SyncConflict', () => { + const err = new SyncConflictError('boom'); + expect(exitCodeForError(err)).toBe(ExitCode.SyncConflict); + }); +}); + +describe('SyncDriftError (regression guard after taxonomy additions)', () => { + it('still carries the SyncDrift exit code (10)', () => { + const err = new SyncDriftError('drift'); + expect(err.exitCode).toBe(ExitCode.SyncDrift); + expect(err.exitCode).toBe(10); + }); +}); diff --git a/tests/unit/file-translation-handler.test.ts b/tests/unit/file-translation-handler.test.ts index 1adde13..7ebc06e 100644 --- a/tests/unit/file-translation-handler.test.ts +++ b/tests/unit/file-translation-handler.test.ts @@ -150,6 +150,98 @@ describe('FileTranslationHandler', () => { expect(result).toContain('2 languages'); }); + describe('multi-target glossary + translation memory', () => { + const TM_UUID = '11111111-2222-3333-4444-555555555555'; + + beforeEach(() => { + mocks.fileTranslationService.translateFileToMultiple.mockResolvedValue([ + { targetLang: 'de' as any, text: 'Hallo', outputPath: '/tmp/out/file.de.txt' }, + { targetLang: 'fr' as any, text: 'Bonjour', outputPath: '/tmp/out/file.fr.txt' }, + ]); + }); + + it('throws ValidationError for --glossary without --from in multi-target', async () => { + const err = await handler + .translateFile('/tmp/file.txt', defaultOptions({ to: 'de,fr', output: '/tmp/out', glossary: 'my-glossary' })) + .catch(e => e); + expect(err).toBeInstanceOf(ValidationError); + expect((err as Error).message).toContain('Source language (--from) is required'); + }); + + it('throws ValidationError for --translation-memory without --from in multi-target', async () => { + const err = await handler + .translateFile('/tmp/file.txt', defaultOptions({ to: 'de,fr', output: '/tmp/out', translationMemory: 'my-tm' })) + .catch(e => e); + expect(err).toBeInstanceOf(ValidationError); + expect((err as ValidationError).exitCode).toBe(6); + expect((err as Error).message).toContain('--from is required when using --translation-memory'); + }); + + it('throws ValidationError for multi-target TM + latency_optimized model', async () => { + const err = await handler + .translateFile('/tmp/file.txt', defaultOptions({ + to: 'de,fr', output: '/tmp/out', + from: 'en', translationMemory: 'my-tm', modelType: 'latency_optimized', + })) + .catch(e => e); + expect(err).toBeInstanceOf(ValidationError); + expect((err as Error).message).toContain('requires quality_optimized model type'); + }); + + it('resolves glossary name to ID and passes it to translateFileToMultiple', async () => { + mocks.glossaryService.resolveGlossaryId.mockResolvedValue('glossary-abc-123'); + + await handler.translateFile('/tmp/file.txt', defaultOptions({ + to: 'de,fr', output: '/tmp/out', + from: 'en', glossary: 'my-glossary', + })); + + expect(mocks.glossaryService.resolveGlossaryId).toHaveBeenCalledTimes(1); + expect(mocks.glossaryService.resolveGlossaryId).toHaveBeenCalledWith('my-glossary'); + const call = mocks.fileTranslationService.translateFileToMultiple.mock.calls[0]!; + expect(call[2]).toEqual(expect.objectContaining({ glossaryId: 'glossary-abc-123' })); + }); + + it('TM UUID fast-path in multi-target: passes UUID through, skips listTranslationMemories, sets quality_optimized', async () => { + await handler.translateFile('/tmp/file.txt', defaultOptions({ + to: 'de,fr', output: '/tmp/out', + from: 'en', translationMemory: TM_UUID, + })); + + expect(mocks.translationService.listTranslationMemories).not.toHaveBeenCalled(); + const call = mocks.fileTranslationService.translateFileToMultiple.mock.calls[0]!; + expect(call[2]).toEqual(expect.objectContaining({ + translationMemoryId: TM_UUID, + modelType: 'quality_optimized', + })); + }); + + it('TM UUID + --tm-threshold passes through as translationMemoryThreshold in multi-target', async () => { + await handler.translateFile('/tmp/file.txt', defaultOptions({ + to: 'de,fr', output: '/tmp/out', + from: 'en', translationMemory: TM_UUID, tmThreshold: 85, + })); + + const call = mocks.fileTranslationService.translateFileToMultiple.mock.calls[0]!; + expect(call[2]).toEqual(expect.objectContaining({ translationMemoryThreshold: 85 })); + }); + + it('TM name resolution in multi-target: surfaces clean pair-check error when TM target_lang does not match all requested targets', async () => { + mocks.translationService.listTranslationMemories.mockResolvedValue([ + { translation_memory_id: TM_UUID, name: 'my-tm', source_language: 'en', target_languages: ['de'] }, + ]); + + const err = await handler + .translateFile('/tmp/file.txt', defaultOptions({ + to: 'de,fr', output: '/tmp/out', + from: 'en', translationMemory: 'my-tm', + })) + .catch(e => e); + expect((err as Error).message).toContain('does not support the requested language pair'); + expect(mocks.fileTranslationService.translateFileToMultiple).not.toHaveBeenCalled(); + }); + }); + it('should use text translation for small text-based files', async () => { mockedIsTextBasedFile.mockReturnValue(true); mockedGetFileSize.mockReturnValue(50); @@ -252,5 +344,122 @@ describe('FileTranslationHandler', () => { ); expect(result).toContain('Translated'); }); + + describe('--translation-memory', () => { + const TM_UUID = '11111111-2222-3333-4444-555555555555'; + + beforeEach(() => { + mockedIsTextBasedFile.mockReturnValue(true); + mockedGetFileSize.mockReturnValue(50); + }); + + it('throws ValidationError (exit 6) when --translation-memory used without --from', async () => { + const err = await handler + .translateFile('/tmp/file.txt', defaultOptions({ translationMemory: 'my-tm' })) + .catch(e => e); + expect(err).toBeInstanceOf(ValidationError); + expect((err as ValidationError).exitCode).toBe(6); + expect((err as Error).message).toContain('--from is required when using --translation-memory'); + }); + + it('throws ValidationError (exit 6) when combined with latency_optimized', async () => { + const err = await handler + .translateFile('/tmp/file.txt', defaultOptions({ + from: 'en', translationMemory: 'my-tm', modelType: 'latency_optimized', + })) + .catch(e => e); + expect(err).toBeInstanceOf(ValidationError); + expect((err as ValidationError).exitCode).toBe(6); + expect((err as Error).message).toContain('requires quality_optimized model type'); + }); + + it('resolves TM name and passes resolved UUID to translate() for small text files', async () => { + mocks.translationService.listTranslationMemories.mockResolvedValue([ + { translation_memory_id: TM_UUID, name: 'my-tm', source_language: 'en', target_languages: ['de'] }, + ]); + + await handler.translateFile('/tmp/file.txt', defaultOptions({ + from: 'en', translationMemory: 'my-tm', + })); + + expect(mocks.translationService.listTranslationMemories).toHaveBeenCalledTimes(1); + expect(mocks.translationService.translate).toHaveBeenCalledWith( + 'file content', + expect.objectContaining({ + targetLang: 'de', + translationMemoryId: TM_UUID, + modelType: 'quality_optimized', + }), + expect.any(Object) + ); + }); + + it('resolves TM name and passes resolved UUID to fileTranslationService.translateFile for structured files', async () => { + mockedIsStructuredFile.mockReturnValue(true); + mocks.translationService.listTranslationMemories.mockResolvedValue([ + { translation_memory_id: TM_UUID, name: 'my-tm', source_language: 'en', target_languages: ['de'] }, + ]); + + await handler.translateFile('/tmp/file.json', defaultOptions({ + from: 'en', translationMemory: 'my-tm', + })); + + expect(mocks.translationService.listTranslationMemories).toHaveBeenCalledTimes(1); + expect(mocks.fileTranslationService.translateFile).toHaveBeenCalledWith( + '/tmp/file.json', + '/tmp/output.txt', + expect.objectContaining({ + translationMemoryId: TM_UUID, + modelType: 'quality_optimized', + }), + expect.any(Object) + ); + }); + + it('passes --tm-threshold through as translationMemoryThreshold', async () => { + mocks.translationService.listTranslationMemories.mockResolvedValue([ + { translation_memory_id: TM_UUID, name: 'my-tm', source_language: 'en', target_languages: ['de'] }, + ]); + + await handler.translateFile('/tmp/file.txt', defaultOptions({ + from: 'en', translationMemory: 'my-tm', tmThreshold: 85, + })); + + expect(mocks.translationService.translate).toHaveBeenCalledWith( + 'file content', + expect.objectContaining({ translationMemoryThreshold: 85 }), + expect.any(Object) + ); + }); + + it('UUID fast-path: does NOT call listTranslationMemories when a UUID is passed', async () => { + await handler.translateFile('/tmp/file.txt', defaultOptions({ + from: 'en', translationMemory: TM_UUID, + })); + + expect(mocks.translationService.listTranslationMemories).not.toHaveBeenCalled(); + expect(mocks.translationService.translate).toHaveBeenCalledWith( + 'file content', + expect.objectContaining({ translationMemoryId: TM_UUID }), + expect.any(Object) + ); + }); + + it('forces modelType=quality_optimized when --translation-memory set and no --model-type given', async () => { + mocks.translationService.listTranslationMemories.mockResolvedValue([ + { translation_memory_id: TM_UUID, name: 'my-tm', source_language: 'en', target_languages: ['de'] }, + ]); + + await handler.translateFile('/tmp/file.txt', defaultOptions({ + from: 'en', translationMemory: 'my-tm', + })); + + expect(mocks.translationService.translate).toHaveBeenCalledWith( + 'file content', + expect.objectContaining({ modelType: 'quality_optimized' }), + expect.any(Object) + ); + }); + }); }); }); diff --git a/tests/unit/formats/android-xml.test.ts b/tests/unit/formats/android-xml.test.ts new file mode 100644 index 0000000..538db4b --- /dev/null +++ b/tests/unit/formats/android-xml.test.ts @@ -0,0 +1,290 @@ +import { createDefaultRegistry } from '../../../src/formats/index'; +import { AndroidXmlFormatParser } from '../../../src/formats/android-xml'; +import type { TranslatedEntry } from '../../../src/formats/format'; + +const parser = new AndroidXmlFormatParser(); + +describe('android-xml parser', () => { + it('should be registered in the default registry', async () => { + const registry = await createDefaultRegistry(); + const extensions = registry.getSupportedExtensions(); + expect(extensions.length).toBeGreaterThan(0); + }); + + describe('backslash escaping round-trip', () => { + it('should round-trip a string containing a literal backslash', () => { + const xml = ` + + C:\\\\Users\\\\test +`; + const entries = parser.extract(xml); + expect(entries).toHaveLength(1); + expect(entries[0]!.value).toBe('C:\\Users\\test'); + + const translated: TranslatedEntry[] = [ + { key: 'path', value: 'C:\\Users\\test', translation: 'C:\\Users\\test' }, + ]; + const result = parser.reconstruct(xml, translated); + expect(result).toContain('C:\\\\Users\\\\test'); + }); + + it('should unescape backslash as last step to avoid double-processing', () => { + const xml = ` + + line1\\nline2 +`; + const entries = parser.extract(xml); + expect(entries[0]!.value).toBe('line1\nline2'); + }); + + it('should treat \\\\n as backslash + n, not as a newline', () => { + const xml = ` + + prefix\\\\nsuffix +`; + const entries = parser.extract(xml); + expect(entries[0]!.value).toBe('prefix\\nsuffix'); + expect(entries[0]!.value).toHaveLength(14); + }); + + it('should treat \\\\\\\\ as a single backslash', () => { + const xml = ` + + one\\\\two +`; + const entries = parser.extract(xml); + expect(entries[0]!.value).toBe('one\\two'); + }); + + it('should handle escaped-backslash followed by escaped-apostrophe correctly', () => { + const xml = ` + + it\\\\\\'s +`; + const entries = parser.extract(xml); + expect(entries[0]!.value).toBe("it\\'s"); + }); + + it('should escape backslash as first step to avoid double-escaping', () => { + const xml = ` + + Hello +`; + const translated: TranslatedEntry[] = [ + { key: 'msg', value: 'Hello', translation: 'back\\slash' }, + ]; + const result = parser.reconstruct(xml, translated); + expect(result).toContain('back\\\\slash'); + }); + }); + + describe('preserve extra attributes on plurals and string-array', () => { + it('should preserve tools:ignore and other attributes on plurals', () => { + const xml = ` + + + %d item + %d items + +`; + const entries = parser.extract(xml); + expect(entries).toHaveLength(1); + expect(entries[0]!.key).toBe('items'); + + const translated: TranslatedEntry[] = [ + { + key: 'items', + value: '%d items', + translation: '%d Elemente', + metadata: { + plurals: [ + { quantity: 'one', value: '%d Element' }, + { quantity: 'other', value: '%d Elemente' }, + ], + }, + }, + ]; + const result = parser.reconstruct(xml, translated); + expect(result).toContain('tools:ignore="MissingQuantity"'); + }); + + it('should preserve extra attributes on string-array', () => { + const xml = ` + + + Red + Blue + +`; + const entries = parser.extract(xml); + expect(entries).toHaveLength(2); + + const translated: TranslatedEntry[] = [ + { key: 'colors.0', value: 'Red', translation: 'Rot' }, + { key: 'colors.1', value: 'Blue', translation: 'Blau' }, + ]; + const result = parser.reconstruct(xml, translated); + expect(result).toContain('tools:ignore="ExtraTranslation"'); + }); + }); + + describe('CDATA handling for plurals and string-array items', () => { + it('should extract CDATA values from plural items without literal markup', () => { + const xml = ` + + + + + +`; + + const entries = parser.extract(xml); + expect(entries).toHaveLength(1); + expect(entries[0]!.value).toBe('%d < items'); + expect(entries[0]!.metadata).toEqual({ + plurals: [ + { quantity: 'one', value: '1 < item' }, + { quantity: 'other', value: '%d < items' }, + ], + }); + }); + + it('should preserve CDATA wrappers when reconstructing plural items', () => { + const xml = ` + + + + + +`; + + const translated: TranslatedEntry[] = [ + { + key: 'items', + value: '%d < items', + translation: '%d < Elemente', + metadata: { + plurals: [ + { quantity: 'one', value: '1 < Element' }, + { quantity: 'other', value: '%d < Elemente' }, + ], + }, + }, + ]; + + const result = parser.reconstruct(xml, translated); + expect(result).toContain(''); + expect(result).toContain(''); + }); + + it('should preserve CDATA wrappers for string-array items during extract and reconstruct', () => { + const xml = ` + + + + + +`; + + const entries = parser.extract(xml); + expect(entries).toEqual([ + { key: 'labels.0', value: 'Less < More' }, + { key: 'labels.1', value: 'Rock & Roll' }, + ]); + + const translated: TranslatedEntry[] = [ + { key: 'labels.0', value: 'Less < More', translation: 'Weniger < Mehr' }, + { key: 'labels.1', value: 'Rock & Roll', translation: 'Rock & Roll DE' }, + ]; + + const result = parser.reconstruct(xml, translated); + expect(result).toContain(''); + expect(result).toContain(''); + }); + }); + + describe('remove deleted keys from reconstructed output', () => { + it('should remove string elements not present in entries', () => { + const xml = ` + + Hello + Goodbye + Thank you +`; + const translated: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hallo' }, + { key: 'thanks', value: 'Thank you', translation: 'Danke' }, + ]; + const result = parser.reconstruct(xml, translated); + expect(result).toContain('name="greeting"'); + expect(result).toContain('name="thanks"'); + expect(result).not.toContain('name="farewell"'); + expect(result).not.toContain('Goodbye'); + }); + + it('should remove plural elements not present in entries', () => { + const xml = ` + + + %d item + %d items + + + %d day + %d days + +`; + const translated: TranslatedEntry[] = [ + { + key: 'items', + value: '%d items', + translation: '%d Elemente', + metadata: { + plurals: [ + { quantity: 'one', value: '%d Element' }, + { quantity: 'other', value: '%d Elemente' }, + ], + }, + }, + ]; + const result = parser.reconstruct(xml, translated); + expect(result).toContain('name="items"'); + expect(result).not.toContain('name="days"'); + }); + + it('should remove string-array elements not present in entries', () => { + const xml = ` + + + Red + Blue + + + Small + Large + +`; + const translated: TranslatedEntry[] = [ + { key: 'colors.0', value: 'Red', translation: 'Rot' }, + { key: 'colors.1', value: 'Blue', translation: 'Blau' }, + ]; + const result = parser.reconstruct(xml, translated); + expect(result).toContain('name="colors"'); + expect(result).not.toContain('name="sizes"'); + }); + + it('should preserve translatable="false" strings even when not in entries', () => { + const xml = ` + + MyApp + Hello +`; + const translated: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hallo' }, + ]; + const result = parser.reconstruct(xml, translated); + expect(result).toContain('name="app_name"'); + expect(result).toContain('name="greeting"'); + }); + }); +}); diff --git a/tests/unit/formats/arb.test.ts b/tests/unit/formats/arb.test.ts new file mode 100644 index 0000000..0626b0e --- /dev/null +++ b/tests/unit/formats/arb.test.ts @@ -0,0 +1,332 @@ +import { createDefaultRegistry } from '../../../src/formats/index'; +import { ArbFormatParser } from '../../../src/formats/arb'; +import type { TranslatedEntry } from '../../../src/formats/format'; + +const parser = new ArbFormatParser(); + +describe('arb parser', () => { + it('should be registered in the default registry', async () => { + const registry = await createDefaultRegistry(); + const extensions = registry.getSupportedExtensions(); + expect(extensions.length).toBeGreaterThan(0); + }); + + describe('reconstruct removes deleted keys', () => { + it('should remove keys and their @metadata when absent from entries', () => { + const content = JSON.stringify({ + greeting: 'Hello', + '@greeting': { description: 'A greeting' }, + farewell: 'Goodbye', + '@farewell': { description: 'A farewell' }, + deleted_key: 'Remove me', + '@deleted_key': { description: 'Should be removed' }, + }, null, 2) + '\n'; + + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hola' }, + { key: 'farewell', value: 'Goodbye', translation: 'Adiós' }, + ]; + + const result = parser.reconstruct(content, entries); + const parsed = JSON.parse(result); + expect(parsed.greeting).toBe('Hola'); + expect(parsed.farewell).toBe('Adiós'); + expect(parsed).not.toHaveProperty('deleted_key'); + expect(parsed).not.toHaveProperty('@deleted_key'); + }); + }); + + describe('reconstruct inserts new keys', () => { + it('should add keys not present in the template', () => { + const content = JSON.stringify({ + greeting: 'Hello', + }, null, 2) + '\n'; + + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hola' }, + { key: 'new_key', value: 'New', translation: 'Nuevo' }, + ]; + + const result = parser.reconstruct(content, entries); + const parsed = JSON.parse(result); + expect(parsed.greeting).toBe('Hola'); + expect(parsed.new_key).toBe('Nuevo'); + }); + }); + + describe('reconstruct() applies translations to existing keys', () => { + it('should update values for all existing keys', () => { + const content = JSON.stringify({ + greeting: 'Hello', + '@greeting': { description: 'A greeting' }, + farewell: 'Goodbye', + '@farewell': { description: 'A farewell' }, + }, null, 2) + '\n'; + + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Bonjour' }, + { key: 'farewell', value: 'Goodbye', translation: 'Au revoir' }, + ]; + + const result = parser.reconstruct(content, entries); + const parsed = JSON.parse(result); + expect(parsed.greeting).toBe('Bonjour'); + expect(parsed.farewell).toBe('Au revoir'); + }); + }); + + describe('reconstruct() preserves @metadata entries', () => { + it('should keep @metadata for keys that are present in entries', () => { + const content = JSON.stringify({ + greeting: 'Hello', + '@greeting': { description: 'A greeting', type: 'text' }, + farewell: 'Goodbye', + '@farewell': { description: 'A farewell' }, + }, null, 2) + '\n'; + + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hola' }, + { key: 'farewell', value: 'Goodbye', translation: 'Adiós' }, + ]; + + const result = parser.reconstruct(content, entries); + const parsed = JSON.parse(result); + expect(parsed['@greeting']).toEqual({ description: 'A greeting', type: 'text' }); + expect(parsed['@farewell']).toEqual({ description: 'A farewell' }); + }); + + it('should remove @metadata for deleted keys but keep @metadata for retained keys', () => { + const content = JSON.stringify({ + greeting: 'Hello', + '@greeting': { description: 'A greeting' }, + deleted: 'Remove', + '@deleted': { description: 'Will be removed' }, + }, null, 2) + '\n'; + + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hola' }, + ]; + + const result = parser.reconstruct(content, entries); + const parsed = JSON.parse(result); + expect(parsed.greeting).toBe('Hola'); + expect(parsed['@greeting']).toEqual({ description: 'A greeting' }); + expect(parsed).not.toHaveProperty('deleted'); + expect(parsed).not.toHaveProperty('@deleted'); + }); + }); + + describe('reconstruct() preserves @@locale and top-level @ keys', () => { + it('should preserve @@locale when present', () => { + const content = JSON.stringify({ + '@@locale': 'en', + greeting: 'Hello', + '@greeting': { description: 'A greeting' }, + }, null, 2) + '\n'; + + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hola' }, + ]; + + const result = parser.reconstruct(content, entries); + const parsed = JSON.parse(result); + expect(parsed['@@locale']).toBe('en'); + expect(parsed.greeting).toBe('Hola'); + }); + }); + + describe('extract() with @-prefixed keys', () => { + it('should skip @@locale entry during extraction', () => { + const content = JSON.stringify({ + '@@locale': 'en', + greeting: 'Hello', + }, null, 2); + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.key).toBe('greeting'); + }); + + it('should skip @metadata keys during extraction', () => { + const content = JSON.stringify({ + greeting: 'Hello', + '@greeting': { description: 'A greeting' }, + }, null, 2); + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.key).toBe('greeting'); + }); + + it('should skip non-string values during extraction', () => { + const content = JSON.stringify({ + greeting: 'Hello', + count: 42, + flag: true, + nested: { inner: 'val' }, + }, null, 2); + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.key).toBe('greeting'); + }); + + it('should attach @key metadata and description as context', () => { + const content = JSON.stringify({ + greeting: 'Hello', + '@greeting': { description: 'Used on the home page', type: 'text' }, + }, null, 2); + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.context).toBe('Used on the home page'); + expect(entries[0]!.metadata).toEqual({ description: 'Used on the home page', type: 'text' }); + }); + + it('should set metadata without context when @key has no description field', () => { + const content = JSON.stringify({ + greeting: 'Hello', + '@greeting': { type: 'text', placeholders: {} }, + }, null, 2); + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.context).toBeUndefined(); + expect(entries[0]!.metadata).toEqual({ type: 'text', placeholders: {} }); + }); + + it('should handle entry with no @metadata at all', () => { + const content = JSON.stringify({ + greeting: 'Hello', + farewell: 'Goodbye', + }, null, 2); + const entries = parser.extract(content); + expect(entries).toHaveLength(2); + expect(entries[0]!.metadata).toBeUndefined(); + expect(entries[0]!.context).toBeUndefined(); + }); + + it('should skip @metadata that is not an object (string value)', () => { + const content = JSON.stringify({ + greeting: 'Hello', + '@greeting': 'not an object', + }, null, 2); + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.metadata).toBeUndefined(); + }); + + it('should skip @metadata that is null', () => { + const content = '{"greeting":"Hello","@greeting":null}'; + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.metadata).toBeUndefined(); + }); + }); + + describe('extractContext()', () => { + it('should return description from @key metadata', () => { + const content = JSON.stringify({ + greeting: 'Hello', + '@greeting': { description: 'A welcome message' }, + }, null, 2); + const result = parser.extractContext(content, 'greeting'); + expect(result).toBe('A welcome message'); + }); + + it('should return undefined when @key has no description', () => { + const content = JSON.stringify({ + greeting: 'Hello', + '@greeting': { type: 'text' }, + }, null, 2); + const result = parser.extractContext(content, 'greeting'); + expect(result).toBeUndefined(); + }); + + it('should return undefined when @key does not exist', () => { + const content = JSON.stringify({ + greeting: 'Hello', + }, null, 2); + const result = parser.extractContext(content, 'greeting'); + expect(result).toBeUndefined(); + }); + + it('should return undefined when @key is null', () => { + const content = '{"greeting":"Hello","@greeting":null}'; + const result = parser.extractContext(content, 'greeting'); + expect(result).toBeUndefined(); + }); + + it('should return undefined when @key is a string not an object', () => { + const content = JSON.stringify({ + greeting: 'Hello', + '@greeting': 'just a string', + }, null, 2); + const result = parser.extractContext(content, 'greeting'); + expect(result).toBeUndefined(); + }); + + it('should return undefined when description is not a string', () => { + const content = JSON.stringify({ + greeting: 'Hello', + '@greeting': { description: 42 }, + }, null, 2); + const result = parser.extractContext(content, 'greeting'); + expect(result).toBeUndefined(); + }); + }); + + describe('reconstruct() edge cases', () => { + it('should not add trailing newline when original lacks one', () => { + const content = JSON.stringify({ greeting: 'Hello' }, null, 2); + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hola' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result).not.toMatch(/\n$/); + expect(JSON.parse(result).greeting).toBe('Hola'); + }); + + it('should detect tab indentation', () => { + const content = '{\n\t"greeting": "Hello"\n}\n'; + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hola' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result).toContain('\t"greeting"'); + }); + + it('should default to 2-space indent when no indentation detected', () => { + const content = '{"greeting":"Hello"}\n'; + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hola' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result).toContain(' "greeting"'); + }); + + it('should handle reconstruct with key absent from original (no translation match)', () => { + const content = JSON.stringify({ + greeting: 'Hello', + farewell: 'Goodbye', + }, null, 2) + '\n'; + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hola' }, + ]; + const result = parser.reconstruct(content, entries); + const parsed = JSON.parse(result); + expect(parsed.greeting).toBe('Hola'); + expect(parsed).not.toHaveProperty('farewell'); + }); + + it('should handle reconstruct where deleted key has no @metadata', () => { + const content = JSON.stringify({ + greeting: 'Hello', + '@greeting': { description: 'A greeting' }, + orphan: 'No meta', + }, null, 2) + '\n'; + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hola' }, + ]; + const result = parser.reconstruct(content, entries); + const parsed = JSON.parse(result); + expect(parsed).not.toHaveProperty('orphan'); + expect(parsed.greeting).toBe('Hola'); + }); + }); +}); diff --git a/tests/unit/formats/detect-indent.test.ts b/tests/unit/formats/detect-indent.test.ts new file mode 100644 index 0000000..66b3c88 --- /dev/null +++ b/tests/unit/formats/detect-indent.test.ts @@ -0,0 +1,71 @@ +import { detectIndent } from '../../../src/formats/util/detect-indent'; +import { JsonFormatParser } from '../../../src/formats/json'; +import { ArbFormatParser } from '../../../src/formats/arb'; +import { XcstringsFormatParser } from '../../../src/formats/xcstrings'; +import { YamlFormatParser } from '../../../src/formats/yaml'; +import type { TranslatedEntry } from '../../../src/formats/format'; + +describe('detectIndent()', () => { + it('returns 2 as the default when the document has no indentation', () => { + expect(detectIndent('{"flat":"value"}')).toBe(2); + expect(detectIndent('')).toBe(2); + }); + + it('returns the space count for space-indented content', () => { + expect(detectIndent('{\n "a": 1\n}')).toBe(2); + expect(detectIndent('{\n "a": 1\n}')).toBe(4); + }); + + it('returns "\\t" for tab-indented content', () => { + expect(detectIndent('{\n\t"a": 1\n}')).toBe('\t'); + }); +}); + +describe('detectIndent shared usage', () => { + it('preserves indentation round-trip in the JSON parser', () => { + const parser = new JsonFormatParser(); + const original = '{\n "greeting": "Hello"\n}\n'; + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hallo' }, + ]; + expect(parser.reconstruct(original, entries)).toBe('{\n "greeting": "Hallo"\n}\n'); + }); + + it('preserves indentation round-trip in the ARB parser', () => { + const parser = new ArbFormatParser(); + const original = '{\n\t"greeting": "Hello"\n}\n'; + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hallo' }, + ]; + expect(parser.reconstruct(original, entries)).toBe('{\n\t"greeting": "Hallo"\n}\n'); + }); + + it('preserves indentation round-trip in the xcstrings parser', () => { + const parser = new XcstringsFormatParser(); + const original = JSON.stringify( + { + sourceLanguage: 'en', + version: '1.0', + strings: { + hi: { localizations: { en: { stringUnit: { state: 'translated', value: 'Hi' } } } }, + }, + }, + null, + 4, + ); + const entries: TranslatedEntry[] = [ + { key: 'hi', value: 'Hi', translation: 'Hallo' }, + ]; + const result = parser.reconstruct(original, entries, 'de'); + expect(result).toContain('\n "sourceLanguage"'); + }); + + it('extract → reconstruct stays round-trip for a non-JSON format (YAML)', () => { + const parser = new YamlFormatParser(); + const original = 'greeting: Hello\nfarewell: Goodbye\n'; + const entries = parser.extract(original); + const translated: TranslatedEntry[] = entries.map(e => ({ ...e, translation: e.value })); + const result = parser.reconstruct(original, translated); + expect(parser.extract(result)).toHaveLength(entries.length); + }); +}); diff --git a/tests/unit/formats/format-registry.test.ts b/tests/unit/formats/format-registry.test.ts new file mode 100644 index 0000000..08d0aed --- /dev/null +++ b/tests/unit/formats/format-registry.test.ts @@ -0,0 +1,108 @@ +import { FormatRegistry, SUPPORTED_FORMAT_KEYS, createDefaultRegistry } from '../../../src/formats/index'; +import type { FormatParser } from '../../../src/formats/format'; + +function createStubParser(overrides: Partial = {}): FormatParser { + return { + name: 'Stub', + configKey: 'stub', + extensions: ['.stub'], + extract: () => [], + reconstruct: (content: string) => content, + ...overrides, + }; +} + +describe('FormatRegistry', () => { + let registry: FormatRegistry; + + beforeEach(() => { + registry = new FormatRegistry(); + }); + + it('should register a parser and retrieve it by extension', () => { + const parser = createStubParser({ name: 'JSON', extensions: ['.json'] }); + registry.register(parser); + expect(registry.getParser('.json')).toBe(parser); + }); + + it('should return undefined for unknown extension', () => { + expect(registry.getParser('.unknown')).toBeUndefined(); + }); + + it('should handle multiple extensions per parser', () => { + const parser = createStubParser({ name: 'YAML', extensions: ['.yaml', '.yml'] }); + registry.register(parser); + expect(registry.getParser('.yaml')).toBe(parser); + expect(registry.getParser('.yml')).toBe(parser); + }); + + it('should be case-insensitive for extension lookup', () => { + const parser = createStubParser({ name: 'JSON', extensions: ['.json'] }); + registry.register(parser); + expect(registry.getParser('.JSON')).toBe(parser); + expect(registry.getParser('.Json')).toBe(parser); + }); + + it('should overwrite existing parser for same extension', () => { + const parser1 = createStubParser({ name: 'Parser1', extensions: ['.json'] }); + const parser2 = createStubParser({ name: 'Parser2', extensions: ['.json'] }); + registry.register(parser1); + registry.register(parser2); + expect(registry.getParser('.json')).toBe(parser2); + }); + + it('should list all supported extensions', () => { + registry.register(createStubParser({ extensions: ['.json'] })); + registry.register(createStubParser({ extensions: ['.yaml', '.yml'] })); + const extensions = registry.getSupportedExtensions(); + expect(extensions).toEqual(expect.arrayContaining(['.json', '.yaml', '.yml'])); + expect(extensions).toHaveLength(3); + }); + + it('should return empty list when no parsers registered', () => { + expect(registry.getSupportedExtensions()).toEqual([]); + }); +}); + +describe('createDefaultRegistry', () => { + it('should include all built-in parsers', async () => { + const registry = await createDefaultRegistry(); + expect(registry.getParser('.json')?.name).toBe('JSON i18n'); + expect(registry.getParser('.yaml')?.name).toBe('YAML'); + expect(registry.getParser('.yml')?.name).toBe('YAML'); + expect(registry.getParser('.po')?.name).toBe('PO (gettext)'); + expect(registry.getParser('.pot')?.name).toBe('PO (gettext)'); + expect(registry.getParser('.xml')?.name).toBe('Android XML'); + expect(registry.getParser('.strings')?.name).toBe('iOS Strings'); + expect(registry.getParser('.arb')?.name).toBe('ARB (Flutter)'); + expect(registry.getParser('.xlf')?.name).toBe('XLIFF'); + expect(registry.getParser('.xliff')?.name).toBe('XLIFF'); + }); + + it('should expose each parser by its canonical config key', async () => { + const registry = await createDefaultRegistry(); + expect(registry.getParserByFormatKey('json')?.name).toBe('JSON i18n'); + expect(registry.getParserByFormatKey('yaml')?.name).toBe('YAML'); + expect(registry.getParserByFormatKey('po')?.name).toBe('PO (gettext)'); + expect(registry.getParserByFormatKey('android_xml')?.name).toBe('Android XML'); + expect(registry.getParserByFormatKey('ios_strings')?.name).toBe('iOS Strings'); + expect(registry.getParserByFormatKey('arb')?.name).toBe('ARB (Flutter)'); + expect(registry.getParserByFormatKey('xliff')?.name).toBe('XLIFF'); + expect(registry.getParserByFormatKey('toml')?.name).toBe('TOML i18n'); + expect(registry.getParserByFormatKey('properties')?.name).toBe('Java Properties'); + expect(registry.getParserByFormatKey('xcstrings')?.name).toBe('Xcode String Catalog'); + }); +}); + +describe('SUPPORTED_FORMAT_KEYS', () => { + it('matches the config keys registered in the default registry', async () => { + const registry = await createDefaultRegistry(); + expect([...SUPPORTED_FORMAT_KEYS].sort()).toEqual(registry.getFormatKeys().sort()); + }); + + it('covers every parser extension in a single source of truth', () => { + expect([...SUPPORTED_FORMAT_KEYS].sort()).toEqual( + ['android_xml', 'arb', 'ios_strings', 'json', 'laravel_php', 'po', 'properties', 'toml', 'xcstrings', 'xliff', 'yaml'].sort(), + ); + }); +}); diff --git a/tests/unit/formats/ios-strings.test.ts b/tests/unit/formats/ios-strings.test.ts new file mode 100644 index 0000000..548bec8 --- /dev/null +++ b/tests/unit/formats/ios-strings.test.ts @@ -0,0 +1,483 @@ +import { createDefaultRegistry } from '../../../src/formats/index'; +import { IosStringsFormatParser } from '../../../src/formats/ios-strings'; +import type { TranslatedEntry } from '../../../src/formats/format'; + +const parser = new IosStringsFormatParser(); + +describe('ios-strings parser', () => { + it('should be registered in the default registry', async () => { + const registry = await createDefaultRegistry(); + const extensions = registry.getSupportedExtensions(); + expect(extensions.length).toBeGreaterThan(0); + }); + + describe('reconstruct removes deleted keys', () => { + it('should not include keys absent from translation entries', () => { + const content = [ + '/* Greeting */', + '"greeting" = "Hello";', + '', + '/* Farewell */', + '"farewell" = "Goodbye";', + '', + '/* Deleted */', + '"deleted_key" = "Remove me";', + ].join('\n'); + + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hola' }, + { key: 'farewell', value: 'Goodbye', translation: 'Adiós' }, + ]; + + const result = parser.reconstruct(content, entries); + expect(result).toContain('greeting'); + expect(result).toContain('farewell'); + expect(result).not.toContain('deleted_key'); + expect(result).not.toContain('Remove me'); + expect(result).not.toContain('Deleted'); + }); + + it('should preserve non-entry, non-comment lines', () => { + const content = [ + '"greeting" = "Hello";', + '"deleted" = "Gone";', + ].join('\n'); + + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hola' }, + ]; + + const result = parser.reconstruct(content, entries); + expect(result).toContain('Hola'); + expect(result).not.toContain('deleted'); + expect(result).not.toContain('Gone'); + }); + }); + + describe('reconstruct removes multi-line block comments for deleted keys', () => { + it('should remove entire multi-line block comment when key is deleted', () => { + const content = [ + '/* This is a', + ' multi-line comment', + ' for the deleted key */', + '"deleted_key" = "Remove me";', + '', + '/* Keep this */', + '"kept_key" = "Keep me";', + ].join('\n'); + + const entries: TranslatedEntry[] = [ + { key: 'kept_key', value: 'Keep me', translation: 'Behalte mich' }, + ]; + + const result = parser.reconstruct(content, entries); + expect(result).toContain('kept_key'); + expect(result).toContain('Behalte mich'); + expect(result).not.toContain('deleted_key'); + expect(result).not.toContain('Remove me'); + expect(result).not.toContain('multi-line comment'); + expect(result).not.toContain('for the deleted key'); + }); + + it('should keep multi-line block comments for retained keys', () => { + const content = [ + '/* This is a', + ' multi-line comment */', + '"greeting" = "Hello";', + ].join('\n'); + + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hola' }, + ]; + + const result = parser.reconstruct(content, entries); + expect(result).toContain('multi-line comment'); + expect(result).toContain('Hola'); + }); + }); + + describe('extract()', () => { + it('should extract basic key-value pairs', () => { + const content = '"greeting" = "Hello";\n"farewell" = "Goodbye";\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(2); + expect(entries.find(e => e.key === 'greeting')!.value).toBe('Hello'); + expect(entries.find(e => e.key === 'farewell')!.value).toBe('Goodbye'); + }); + + it('should attach block comments as metadata', () => { + const content = '/* Welcome message */\n"greeting" = "Hello";\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.metadata).toEqual({ comment: 'Welcome message' }); + }); + + it('should attach line comments as metadata', () => { + const content = '// Welcome message\n"greeting" = "Hello";\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.metadata).toEqual({ comment: 'Welcome message' }); + }); + + it('should handle multi-line block comments', () => { + const content = [ + '/* This is a', + ' multi-line comment */', + '"greeting" = "Hello";', + ].join('\n'); + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.metadata).toEqual({ comment: 'This is a\n multi-line comment' }); + }); + + it('should handle block comment spanning three or more lines', () => { + const content = [ + '/* Line one', + ' Line two', + ' Line three */', + '"greeting" = "Hello";', + ].join('\n'); + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.metadata).toEqual({ + comment: 'Line one\n Line two\n Line three', + }); + }); + + it('should handle escaped double quotes', () => { + const content = '"key" = "He said \\"hello\\"";\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.value).toBe('He said "hello"'); + }); + + it('should handle escaped backslash-n and tab', () => { + const content = '"key" = "line1\\nline2\\ttab";\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.value).toBe('line1\nline2\ttab'); + }); + + it('should handle Unicode escapes like \\U0041', () => { + const content = '"key" = "\\U0041\\U0042";\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.value).toBe('AB'); + }); + + it('should return empty array for empty file', () => { + expect(parser.extract('')).toEqual([]); + }); + + it('should return empty array for whitespace-only file', () => { + expect(parser.extract(' \n\n ')).toEqual([]); + }); + + it('should carry comment across blank lines to next entry', () => { + const content = '/* orphan comment */\n\n"greeting" = "Hello";\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.metadata).toEqual({ comment: 'orphan comment' }); + }); + + it('should extract entries in source-file order (consumers sort downstream)', () => { + const content = '"zebra" = "Z";\n"alpha" = "A";\n"middle" = "M";\n'; + const entries = parser.extract(content); + expect(entries.map(e => e.key)).toEqual(['zebra', 'alpha', 'middle']); + }); + }); + + describe('reconstruct() comments preservation', () => { + it('should remove comments for deleted keys', () => { + const content = [ + '/* Keep comment */', + '"kept" = "Keep";', + '', + '// Delete comment', + '"deleted" = "Gone";', + ].join('\n'); + + const entries: TranslatedEntry[] = [ + { key: 'kept', value: 'Keep', translation: 'Behalten' }, + ]; + + const result = parser.reconstruct(content, entries); + expect(result).toContain('Keep comment'); + expect(result).toContain('Behalten'); + expect(result).not.toContain('Delete comment'); + expect(result).not.toContain('deleted'); + }); + + it('should preserve line comments for kept keys', () => { + const content = [ + '// A line comment', + '"greeting" = "Hello";', + ].join('\n'); + + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hola' }, + ]; + + const result = parser.reconstruct(content, entries); + expect(result).toContain('// A line comment'); + expect(result).toContain('Hola'); + }); + }); + + describe('reconstruct with $-patterns in translations', () => { + it('should preserve literal dollar signs like Pay $5.99', () => { + const content = '"price_label" = "Price";'; + const entries: TranslatedEntry[] = [ + { key: 'price_label', value: 'Price', translation: 'Pay $5.99' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result).toBe('"price_label" = "Pay $5.99";'); + }); + + it('should preserve $1 and $& in translation values', () => { + const content = '"msg" = "Hello";'; + const entries: TranslatedEntry[] = [ + { key: 'msg', value: 'Hello', translation: 'Cost $1 and $& more' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result).toBe('"msg" = "Cost $1 and $& more";'); + }); + }); + + describe('unescape edge cases', () => { + it('should unescape \\r to carriage return', () => { + const content = '"key" = "line\\rreturn";\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.value).toBe('line\rreturn'); + }); + + it('should unescape \\0 to null character', () => { + const content = '"key" = "null\\0char";\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.value).toBe('null\0char'); + }); + + it('should unescape \\t to tab', () => { + const content = '"key" = "tab\\there";\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.value).toBe('tab\there'); + }); + + it('should unescape \\\\ to single backslash', () => { + const content = '"key" = "back\\\\slash";\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.value).toBe('back\\slash'); + }); + + it('should handle invalid Unicode escape (non-hex after \\u)', () => { + const content = '"key" = "hello\\uzzzz";\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.value).toBe('hellouzzzz'); + }); + + it('should handle invalid Unicode escape (non-hex after \\U)', () => { + const content = '"key" = "hello\\Uzzzz";\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.value).toBe('helloUzzzz'); + }); + + it('should handle \\u with fewer than 4 hex chars at end of string', () => { + const content = '"key" = "end\\u00";\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.value).toBe('endu00'); + }); + + it('should pass through unknown escape character', () => { + const content = '"key" = "hello\\xworld";\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.value).toBe('helloxworld'); + }); + + it('should handle lowercase \\u with valid hex', () => { + const content = '"key" = "\\u0041\\u0042";\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.value).toBe('AB'); + }); + }); + + describe('escape edge cases', () => { + it('should escape \\r in output', () => { + const content = '"key" = "Hello";\n'; + const entries: TranslatedEntry[] = [ + { key: 'key', value: 'Hello', translation: 'line\rreturn' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result).toContain('\\r'); + }); + + it('should escape \\0 in output', () => { + const content = '"key" = "Hello";\n'; + const entries: TranslatedEntry[] = [ + { key: 'key', value: 'Hello', translation: 'null\0char' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result).toContain('\\0'); + }); + + it('should escape \\t in output', () => { + const content = '"key" = "Hello";\n'; + const entries: TranslatedEntry[] = [ + { key: 'key', value: 'Hello', translation: 'tab\there' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result).toContain('\\t'); + }); + + it('should escape \\n in output', () => { + const content = '"key" = "Hello";\n'; + const entries: TranslatedEntry[] = [ + { key: 'key', value: 'Hello', translation: 'line\nnewline' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result).toContain('\\n'); + }); + + it('should escape double quotes in output', () => { + const content = '"key" = "Hello";\n'; + const entries: TranslatedEntry[] = [ + { key: 'key', value: 'Hello', translation: 'say "hi"' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result).toContain('\\"hi\\"'); + }); + + it('should escape backslashes in output', () => { + const content = '"key" = "Hello";\n'; + const entries: TranslatedEntry[] = [ + { key: 'key', value: 'Hello', translation: 'back\\slash' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result).toContain('back\\\\slash'); + }); + }); + + describe('reconstruct with block comments spanning multiple lines', () => { + it('should handle inBlockComment state during reconstruct', () => { + const content = [ + '/* Start of', + ' a block comment', + ' that spans lines */', + '"greeting" = "Hello";', + '', + '"farewell" = "Goodbye";', + ].join('\n'); + + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hola' }, + { key: 'farewell', value: 'Goodbye', translation: 'Adios' }, + ]; + + const result = parser.reconstruct(content, entries); + expect(result).toContain('Start of'); + expect(result).toContain('a block comment'); + expect(result).toContain('that spans lines'); + expect(result).toContain('Hola'); + expect(result).toContain('Adios'); + }); + + it('should drop multi-line block comment when following key is deleted', () => { + const content = [ + '/* Multi-line', + ' comment for deleted */', + '"deleted" = "Gone";', + '"kept" = "Here";', + ].join('\n'); + + const entries: TranslatedEntry[] = [ + { key: 'kept', value: 'Here', translation: 'Aqui' }, + ]; + + const result = parser.reconstruct(content, entries); + expect(result).not.toContain('Multi-line'); + expect(result).not.toContain('deleted'); + expect(result).toContain('Aqui'); + }); + }); + + describe('reconstruct with non-entry non-comment lines', () => { + it('should preserve non-matching lines in reconstruct', () => { + const content = [ + '"greeting" = "Hello";', + 'SOME_RANDOM_DIRECTIVE', + '"farewell" = "Goodbye";', + ].join('\n'); + + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hola' }, + { key: 'farewell', value: 'Goodbye', translation: 'Adios' }, + ]; + + const result = parser.reconstruct(content, entries); + expect(result).toContain('SOME_RANDOM_DIRECTIVE'); + expect(result).toContain('Hola'); + expect(result).toContain('Adios'); + }); + }); + + describe('extract edge cases', () => { + it('should reset pendingComment on non-matching line', () => { + const content = [ + '/* orphan comment */', + 'NOT_A_VALID_ENTRY', + '"greeting" = "Hello";', + ].join('\n'); + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.metadata).toBeUndefined(); + }); + + it('should handle single-line block comment in reconstruct non-entry path', () => { + const content = [ + '/* standalone comment */', + '"greeting" = "Hello";', + ].join('\n'); + + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hola' }, + ]; + + const result = parser.reconstruct(content, entries); + expect(result).toContain('standalone comment'); + expect(result).toContain('Hola'); + }); + }); + + describe('reconstruct flush trailing pending comments', () => { + it('should flush trailing comments at end of file', () => { + const content = [ + '"greeting" = "Hello";', + '// trailing comment', + ].join('\n'); + + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hola' }, + ]; + + const result = parser.reconstruct(content, entries); + expect(result).toContain('// trailing comment'); + }); + + it('should flush trailing empty lines at end of file', () => { + const content = '"greeting" = "Hello";\n\n'; + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hola' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result).toContain('Hola'); + }); + }); +}); diff --git a/tests/unit/formats/json.test.ts b/tests/unit/formats/json.test.ts new file mode 100644 index 0000000..3da07ef --- /dev/null +++ b/tests/unit/formats/json.test.ts @@ -0,0 +1,611 @@ +import { JsonFormatParser } from '../../../src/formats/json'; +import type { TranslatedEntry } from '../../../src/formats/format'; + +const parser = new JsonFormatParser(); + +describe('JsonFormatParser', () => { + describe('extract()', () => { + it('should extract flat key-value pairs', () => { + const result = parser.extract('{"greeting":"Hello","farewell":"Goodbye"}'); + expect(result).toHaveLength(2); + expect(result.find(e => e.key === 'farewell')!.value).toBe('Goodbye'); + expect(result.find(e => e.key === 'greeting')!.value).toBe('Hello'); + }); + + it('should extract nested objects with dot-path keys', () => { + const result = parser.extract('{"nav":{"home":"Home","about":"About"}}'); + expect(result).toHaveLength(2); + expect(result.map(e => e.key).sort()).toEqual(['nav.about', 'nav.home']); + }); + + it('should skip non-string values', () => { + const result = parser.extract('{"name":"Test","count":5,"flag":true}'); + expect(result).toHaveLength(1); + expect(result[0]!.key).toBe('name'); + }); + }); + + describe('reconstruct()', () => { + it('should replace values with translations', () => { + const original = '{\n "greeting": "Hello"\n}\n'; + const entries: TranslatedEntry[] = [{ key: 'greeting', value: 'Hello', translation: 'Hallo' }]; + const result = parser.reconstruct(original, entries); + expect(JSON.parse(result)).toEqual({ greeting: 'Hallo' }); + }); + + it('should preserve indentation', () => { + const original = '{\n "key": "value"\n}\n'; + const entries: TranslatedEntry[] = [{ key: 'key', value: 'value', translation: 'valor' }]; + const result = parser.reconstruct(original, entries); + expect(result).toBe('{\n "key": "valor"\n}\n'); + }); + + it('should INSERT new keys not in the template', () => { + const original = '{\n "existing": "Hello"\n}\n'; + const entries: TranslatedEntry[] = [ + { key: 'existing', value: 'Hello', translation: 'Hallo' }, + { key: 'new_key', value: 'New', translation: 'Neu' }, + ]; + const result = parser.reconstruct(original, entries); + const parsed = JSON.parse(result) as Record; + expect(parsed['existing']).toBe('Hallo'); + expect(parsed['new_key']).toBe('Neu'); + }); + + it('should INSERT nested new keys', () => { + const original = '{\n "nav": {\n "home": "Home"\n }\n}\n'; + const entries: TranslatedEntry[] = [ + { key: 'nav.home', value: 'Home', translation: 'Startseite' }, + { key: 'nav.about', value: 'About', translation: 'Uber uns' }, + ]; + const result = parser.reconstruct(original, entries); + const parsed = JSON.parse(result) as Record; + expect((parsed['nav'] as Record)['about']).toBe('Uber uns'); + }); + + it('should REMOVE deleted keys not in entries', () => { + const original = '{\n "keep": "Hello",\n "delete_me": "Goodbye"\n}\n'; + const entries: TranslatedEntry[] = [ + { key: 'keep', value: 'Hello', translation: 'Hallo' }, + ]; + const result = parser.reconstruct(original, entries); + const parsed = JSON.parse(result) as Record; + expect(parsed['keep']).toBe('Hallo'); + expect(parsed['delete_me']).toBeUndefined(); + }); + }); + + describe('extract() arrays and edge cases', () => { + it('should extract array values with string items', () => { + const json = '{"items":["apple","banana","cherry"]}'; + const result = parser.extract(json); + expect(result).toHaveLength(3); + expect(result[0]!.key).toBe('items.0'); + expect(result[0]!.value).toBe('apple'); + expect(result[1]!.key).toBe('items.1'); + expect(result[1]!.value).toBe('banana'); + expect(result[2]!.key).toBe('items.2'); + expect(result[2]!.value).toBe('cherry'); + }); + + it('should skip null values', () => { + const json = '{"name":"Test","empty":null}'; + const result = parser.extract(json); + expect(result).toHaveLength(1); + expect(result[0]!.key).toBe('name'); + }); + + it('should skip numeric and boolean values', () => { + const json = '{"label":"OK","count":42,"flag":true,"nothing":null}'; + const result = parser.extract(json); + expect(result).toHaveLength(1); + expect(result[0]!.key).toBe('label'); + }); + }); + + describe('reconstruct() nested key insertion and deletion', () => { + it('should insert a nested key that creates intermediate objects', () => { + const original = '{\n "existing": "Hello"\n}\n'; + const entries: TranslatedEntry[] = [ + { key: 'existing', value: 'Hello', translation: 'Hallo' }, + { key: 'nav.menu.item', value: 'Item', translation: 'Artikel' }, + ]; + const result = parser.reconstruct(original, entries); + const parsed = JSON.parse(result); + expect(parsed.existing).toBe('Hallo'); + expect(parsed.nav.menu.item).toBe('Artikel'); + }); + + it('should remove nested keys and clean up empty parent objects', () => { + const original = JSON.stringify({ + nav: { + home: 'Home', + about: 'About', + }, + footer: { + copyright: 'Copyright', + }, + }, null, 2) + '\n'; + + const entries: TranslatedEntry[] = [ + { key: 'nav.home', value: 'Home', translation: 'Startseite' }, + { key: 'nav.about', value: 'About', translation: 'Info' }, + ]; + + const result = parser.reconstruct(original, entries); + const parsed = JSON.parse(result); + expect(parsed.nav.home).toBe('Startseite'); + expect(parsed.nav.about).toBe('Info'); + expect(parsed).not.toHaveProperty('footer'); + }); + + it('should remove only the deleted key and keep siblings', () => { + const original = JSON.stringify({ + nav: { + home: 'Home', + about: 'About', + contact: 'Contact', + }, + }, null, 2) + '\n'; + + const entries: TranslatedEntry[] = [ + { key: 'nav.home', value: 'Home', translation: 'Inicio' }, + { key: 'nav.about', value: 'About', translation: 'Acerca' }, + ]; + + const result = parser.reconstruct(original, entries); + const parsed = JSON.parse(result); + expect(parsed.nav.home).toBe('Inicio'); + expect(parsed.nav.about).toBe('Acerca'); + expect(parsed.nav).not.toHaveProperty('contact'); + }); + }); + + describe('metadata', () => { + it('should have correct name and extensions', () => { + expect(parser.name).toBe('JSON i18n'); + expect(parser.extensions).toEqual(['.json']); + }); + }); + + describe('round-trip', () => { + it('should preserve content with identity translations', () => { + const original = '{\n "a": "Hello",\n "b": "World"\n}\n'; + const entries = parser.extract(original); + const translated: TranslatedEntry[] = entries.map(e => ({ ...e, translation: e.value })); + const result = parser.reconstruct(original, translated); + expect(result).toBe(original); + }); + }); + + describe('BOM handling', () => { + it('should extract correctly from JSON with UTF-8 BOM prefix', () => { + const bom = '\uFEFF{"key":"value"}'; + const entries = parser.extract(bom); + expect(entries).toHaveLength(1); + expect(entries[0]!.key).toBe('key'); + expect(entries[0]!.value).toBe('value'); + }); + + it('should reconstruct correctly from JSON with UTF-8 BOM prefix', () => { + const bom = '\uFEFF{\n "key": "value"\n}\n'; + const entries: TranslatedEntry[] = [ + { key: 'key', value: 'value', translation: 'Wert' }, + ]; + const result = parser.reconstruct(bom, entries); + expect(JSON.parse(result)).toEqual({ key: 'Wert' }); + }); + }); + + describe('duplicate key detection', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('should warn on duplicate top-level keys', () => { + const json = '{"greeting":"Hello","farewell":"Goodbye","greeting":"Hi"}'; + const result = parser.extract(json); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('duplicate keys: greeting'), + ); + expect(result).toHaveLength(2); + expect(result.find(e => e.key === 'greeting')!.value).toBe('Hi'); + }); + + it('should warn on duplicate keys in nested objects', () => { + const json = '{"nav":{"home":"Home","about":"About","home":"Casa"}}'; + parser.extract(json); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('duplicate keys: home'), + ); + }); + + it('should not warn when keys appear at different nesting levels', () => { + const json = '{"name":"Top","child":{"name":"Nested"}}'; + parser.extract(json); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('should not warn when there are no duplicate keys', () => { + const json = '{"greeting":"Hello","farewell":"Goodbye"}'; + parser.extract(json); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('should not warn for empty or minimal JSON', () => { + parser.extract('{}'); + parser.extract('{"single":"value"}'); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('should handle escaped quotes in keys', () => { + const json = '{"say\\"hi":"Hello","other":"World","say\\"hi":"Again"}'; + parser.extract(json); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('duplicate keys'), + ); + }); + + it('should handle string value (not key) followed by non-colon', () => { + const json = '{"arr":["hello","world"]}'; + parser.extract(json); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('should handle unterminated string in findDuplicateJsonKeys', () => { + const json = '{"key": "unterminated'; + expect(() => parser.extract(json)).toThrow(); + }); + + it('should not crash on key-colon outside braces in findDuplicateJsonKeys', () => { + const json = '{"valid": "json"}'; + parser.extract(json); + expect(warnSpy).not.toHaveBeenCalled(); + }); + }); + + describe('extract() edge cases', () => { + it('should return empty array for empty content', () => { + expect(parser.extract('')).toEqual([]); + }); + + it('should return empty array for whitespace-only content', () => { + expect(parser.extract(' \n ')).toEqual([]); + }); + + it('should extract array items with nested objects', () => { + const json = '{"list":[{"name":"Alice"},{"name":"Bob"}]}'; + const result = parser.extract(json); + expect(result).toHaveLength(2); + expect(result[0]!.key).toBe('list.0.name'); + expect(result[1]!.key).toBe('list.1.name'); + }); + + it('should skip non-string non-object array items', () => { + const json = '{"items":["hello",42,true,null]}'; + const result = parser.extract(json); + expect(result).toHaveLength(1); + expect(result[0]!.key).toBe('items.0'); + }); + + it('should handle top-level array', () => { + const json = '["hello","world"]'; + const result = parser.extract(json); + expect(result).toHaveLength(2); + expect(result[0]!.key).toBe('0'); + expect(result[1]!.key).toBe('1'); + }); + }); + + describe('reconstruct() tab indentation', () => { + it('should detect and preserve tab indentation', () => { + const original = '{\n\t"key": "value"\n}\n'; + const entries: TranslatedEntry[] = [ + { key: 'key', value: 'value', translation: 'valor' }, + ]; + const result = parser.reconstruct(original, entries); + expect(result).toContain('\t"key"'); + expect(result).toBe('{\n\t"key": "valor"\n}\n'); + }); + + it('should default to 2-space indent when content is not indented', () => { + const original = '{"key":"value"}\n'; + const entries: TranslatedEntry[] = [ + { key: 'key', value: 'value', translation: 'valor' }, + ]; + const result = parser.reconstruct(original, entries); + expect(result).toContain(' "key"'); + }); + }); + + describe('reconstruct() with arrays', () => { + it('should apply translations to array items', () => { + const original = '{\n "items": [\n "hello",\n "world"\n ]\n}\n'; + const entries: TranslatedEntry[] = [ + { key: 'items.0', value: 'hello', translation: 'hola' }, + { key: 'items.1', value: 'world', translation: 'mundo' }, + ]; + const result = parser.reconstruct(original, entries); + const parsed = JSON.parse(result); + expect(parsed.items).toEqual(['hola', 'mundo']); + }); + + it('should apply translations to nested objects inside arrays', () => { + const original = JSON.stringify({ list: [{ name: 'Alice' }, { name: 'Bob' }] }, null, 2) + '\n'; + const entries: TranslatedEntry[] = [ + { key: 'list.0.name', value: 'Alice', translation: 'Alicia' }, + { key: 'list.1.name', value: 'Bob', translation: 'Roberto' }, + ]; + const result = parser.reconstruct(original, entries); + const parsed = JSON.parse(result); + expect(parsed.list[0].name).toBe('Alicia'); + expect(parsed.list[1].name).toBe('Roberto'); + }); + + it('should not apply translation to array string item when not in translations', () => { + const original = JSON.stringify({ items: ['keep', 'also_keep'] }, null, 2) + '\n'; + const entries: TranslatedEntry[] = [ + { key: 'items.0', value: 'keep', translation: 'kept' }, + ]; + const result = parser.reconstruct(original, entries); + const parsed = JSON.parse(result); + expect(parsed.items[0]).toBe('kept'); + expect(parsed.items[1]).toBe('also_keep'); + }); + }); + + describe('reconstruct() removeDeletedKeys nested cleanup', () => { + it('should remove deeply nested empty parent objects after key removal', () => { + const original = JSON.stringify({ + a: { + b: { + c: 'deep value', + }, + }, + keep: 'yes', + }, null, 2) + '\n'; + + const entries: TranslatedEntry[] = [ + { key: 'keep', value: 'yes', translation: 'ja' }, + ]; + + const result = parser.reconstruct(original, entries); + const parsed = JSON.parse(result); + expect(parsed).not.toHaveProperty('a'); + expect(parsed.keep).toBe('ja'); + }); + + it('should not remove array values via removeDeletedKeys', () => { + const original = JSON.stringify({ + items: ['one', 'two'], + label: 'test', + }, null, 2) + '\n'; + + const entries: TranslatedEntry[] = [ + { key: 'items.0', value: 'one', translation: 'uno' }, + { key: 'items.1', value: 'two', translation: 'dos' }, + { key: 'label', value: 'test', translation: 'prueba' }, + ]; + + const result = parser.reconstruct(original, entries); + const parsed = JSON.parse(result); + expect(parsed.items).toEqual(['uno', 'dos']); + expect(parsed.label).toBe('prueba'); + }); + }); + + describe('reconstruct() insertNewKeys edge cases', () => { + it('should not insert when obj is not an object', () => { + const original = '{"key": "value"}\n'; + const entries: TranslatedEntry[] = [ + { key: 'key', value: 'value', translation: 'valor' }, + ]; + const result = parser.reconstruct(original, entries); + expect(JSON.parse(result)).toEqual({ key: 'valor' }); + }); + + it('should handle setKey where intermediate path already exists as object', () => { + const original = JSON.stringify({ + nav: { home: 'Home' }, + }, null, 2) + '\n'; + const entries: TranslatedEntry[] = [ + { key: 'nav.home', value: 'Home', translation: 'Inicio' }, + { key: 'nav.about', value: 'About', translation: 'Acerca' }, + ]; + const result = parser.reconstruct(original, entries); + const parsed = JSON.parse(result); + expect(parsed.nav.home).toBe('Inicio'); + expect(parsed.nav.about).toBe('Acerca'); + }); + }); + + describe('hasKey and setKey edge cases', () => { + it('should insert new key through non-existent intermediate path', () => { + const original = JSON.stringify({ existing: 'Hello' }, null, 2) + '\n'; + const entries: TranslatedEntry[] = [ + { key: 'existing', value: 'Hello', translation: 'Hola' }, + { key: 'deep.path.key', value: 'x', translation: 'y' }, + ]; + const result = parser.reconstruct(original, entries); + const parsed = JSON.parse(result); + expect(parsed.existing).toBe('Hola'); + expect(parsed.deep.path.key).toBe('y'); + }); + + it('should handle setKey when intermediate path is null', () => { + const original = JSON.stringify({ parent: null, other: 'val' }, null, 2) + '\n'; + const entries: TranslatedEntry[] = [ + { key: 'other', value: 'val', translation: 'valor' }, + { key: 'parent.child', value: 'x', translation: 'y' }, + ]; + const result = parser.reconstruct(original, entries); + const parsed = JSON.parse(result); + expect(parsed.parent.child).toBe('y'); + }); + + it('should handle single-segment dotPath in setKey', () => { + const original = JSON.stringify({ existing: 'Hello' }, null, 2) + '\n'; + const entries: TranslatedEntry[] = [ + { key: 'existing', value: 'Hello', translation: 'Hola' }, + { key: 'newkey', value: 'x', translation: 'y' }, + ]; + const result = parser.reconstruct(original, entries); + const parsed = JSON.parse(result); + expect(parsed.newkey).toBe('y'); + }); + }); + + describe('reconstruct() with non-object root', () => { + it('should handle JSON with a bare string value gracefully', () => { + const original = '"hello"\n'; + const entries: TranslatedEntry[] = []; + const result = parser.reconstruct(original, entries); + expect(JSON.parse(result)).toBe('hello'); + }); + }); + + describe('reconstruct() top-level array', () => { + it('should apply translations to a top-level string array', () => { + const original = '[\n "hello",\n "world"\n]\n'; + const entries: TranslatedEntry[] = [ + { key: '0', value: 'hello', translation: 'hola' }, + { key: '1', value: 'world', translation: 'mundo' }, + ]; + const result = parser.reconstruct(original, entries); + const parsed = JSON.parse(result); + expect(parsed).toEqual(['hola', 'mundo']); + }); + + it('should apply translations to top-level array with nested objects', () => { + const original = JSON.stringify([{ name: 'Alice' }, { name: 'Bob' }], null, 2) + '\n'; + const entries: TranslatedEntry[] = [ + { key: '0.name', value: 'Alice', translation: 'Alicia' }, + { key: '1.name', value: 'Bob', translation: 'Roberto' }, + ]; + const result = parser.reconstruct(original, entries); + const parsed = JSON.parse(result); + expect(parsed[0].name).toBe('Alicia'); + expect(parsed[1].name).toBe('Roberto'); + }); + }); + + describe('reconstruct() BOM handling', () => { + it('should handle BOM in reconstruct and produce valid JSON', () => { + const original = '\uFEFF{\n "key": "value"\n}'; + const entries: TranslatedEntry[] = [ + { key: 'key', value: 'value', translation: 'Wert' }, + ]; + const result = parser.reconstruct(original, entries); + expect(JSON.parse(result)).toEqual({ key: 'Wert' }); + expect(result).not.toMatch(/^\uFEFF/); + }); + }); + + describe('flat dotted keys', () => { + it('should extract flat dotted keys as-is', () => { + const json = '{"section.key": "Value", "section.other": "Other"}'; + const result = parser.extract(json); + expect(result).toHaveLength(2); + expect(result[0]!.key).toBe('section.key'); + expect(result[0]!.value).toBe('Value'); + expect(result[1]!.key).toBe('section.other'); + expect(result[1]!.value).toBe('Other'); + }); + + it('should reconstruct flat dotted keys without nesting', () => { + const original = '{\n "section.key": "Value",\n "section.other": "Other"\n}\n'; + const entries: TranslatedEntry[] = [ + { key: 'section.key', value: 'Value', translation: 'Wert' }, + { key: 'section.other', value: 'Other', translation: 'Andere' }, + ]; + const result = parser.reconstruct(original, entries); + const parsed = JSON.parse(result); + expect(parsed).toEqual({ 'section.key': 'Wert', 'section.other': 'Andere' }); + expect(parsed).not.toHaveProperty('section'); + }); + + it('should preserve nested keys as nested', () => { + const original = JSON.stringify({ section: { key: 'Value' } }, null, 2) + '\n'; + const entries: TranslatedEntry[] = [ + { key: 'section.key', value: 'Value', translation: 'Wert' }, + ]; + const result = parser.reconstruct(original, entries); + const parsed = JSON.parse(result); + expect(parsed).toEqual({ section: { key: 'Wert' } }); + expect(Object.keys(parsed)).toEqual(['section']); + }); + + it('should handle mixed flat and nested keys', () => { + const original = JSON.stringify({ + 'flat.key': 'Flat Value', + nested: { key: 'Nested Value' }, + }, null, 2) + '\n'; + const entries: TranslatedEntry[] = [ + { key: 'flat.key', value: 'Flat Value', translation: 'Flacher Wert' }, + { key: 'nested.key', value: 'Nested Value', translation: 'Verschachtelter Wert' }, + ]; + const result = parser.reconstruct(original, entries); + const parsed = JSON.parse(result); + expect(parsed['flat.key']).toBe('Flacher Wert'); + expect(parsed['nested']['key']).toBe('Verschachtelter Wert'); + expect(Object.keys(parsed)).toEqual(['flat.key', 'nested']); + }); + + it('should round-trip flat dotted keys with identity translations', () => { + const original = '{\n "app.title": "My App",\n "app.version": "1.0"\n}\n'; + const entries = parser.extract(original); + const translated: TranslatedEntry[] = entries.map(e => ({ ...e, translation: e.value })); + const result = parser.reconstruct(original, translated); + expect(result).toBe(original); + }); + + it('should insert new flat dotted keys when source has flat style', () => { + const original = '{\n "section.existing": "Hello"\n}\n'; + const entries: TranslatedEntry[] = [ + { key: 'section.existing', value: 'Hello', translation: 'Hallo' }, + { key: 'section.new', value: 'New', translation: 'Neu' }, + ]; + const result = parser.reconstruct(original, entries); + const parsed = JSON.parse(result); + expect(parsed['section.existing']).toBe('Hallo'); + expect(parsed['section.new']).toBe('Neu'); + expect(parsed).not.toHaveProperty('section'); + }); + + it('should remove deleted flat dotted keys', () => { + const original = '{\n "section.keep": "Keep",\n "section.remove": "Remove"\n}\n'; + const entries: TranslatedEntry[] = [ + { key: 'section.keep', value: 'Keep', translation: 'Behalten' }, + ]; + const result = parser.reconstruct(original, entries); + const parsed = JSON.parse(result); + expect(parsed['section.keep']).toBe('Behalten'); + expect(parsed).not.toHaveProperty('section.remove'); + }); + + it('should handle flat dotted keys inside nested objects', () => { + const original = JSON.stringify({ + outer: { 'inner.key': 'Value' }, + }, null, 2) + '\n'; + const entries: TranslatedEntry[] = [ + { key: 'outer.inner.key', value: 'Value', translation: 'Wert' }, + ]; + const result = parser.reconstruct(original, entries); + const parsed = JSON.parse(result); + expect(parsed['outer']['inner.key']).toBe('Wert'); + expect(parsed['outer']).not.toHaveProperty('inner'); + }); + }); +}); diff --git a/tests/unit/formats/pending-comment-buffer.test.ts b/tests/unit/formats/pending-comment-buffer.test.ts new file mode 100644 index 0000000..4261f3d --- /dev/null +++ b/tests/unit/formats/pending-comment-buffer.test.ts @@ -0,0 +1,142 @@ +import { PendingCommentBuffer } from '../../../src/formats/pending-comment-buffer'; + +describe('PendingCommentBuffer', () => { + describe('collect() + flushToOutput()', () => { + it('should emit buffered lines into the output array in order', () => { + const buf = new PendingCommentBuffer(); + const out: string[] = []; + buf.collect('# comment one'); + buf.collect('# comment two'); + buf.flushToOutput(out); + expect(out).toEqual(['# comment one', '# comment two']); + }); + + it('should clear the buffer after flushing', () => { + const buf = new PendingCommentBuffer(); + const out: string[] = []; + buf.collect('# c1'); + buf.flushToOutput(out); + buf.flushToOutput(out); + expect(out).toEqual(['# c1']); + }); + + it('should be a no-op when nothing is buffered', () => { + const buf = new PendingCommentBuffer(); + const out: string[] = ['existing']; + buf.flushToOutput(out); + expect(out).toEqual(['existing']); + }); + + it('should accept blank lines alongside comment lines', () => { + const buf = new PendingCommentBuffer(); + const out: string[] = []; + buf.collect('# comment'); + buf.collect(''); + buf.collect('# another'); + buf.flushToOutput(out); + expect(out).toEqual(['# comment', '', '# another']); + }); + }); + + describe('drop()', () => { + it('should discard buffered lines without emitting them', () => { + const buf = new PendingCommentBuffer(); + const out: string[] = ['prev']; + buf.collect('# about to be orphaned'); + buf.drop(); + buf.flushToOutput(out); + expect(out).toEqual(['prev']); + }); + + it('should be a no-op when the buffer is already empty', () => { + const buf = new PendingCommentBuffer(); + const out: string[] = []; + buf.drop(); + buf.flushToOutput(out); + expect(out).toEqual([]); + }); + }); + + describe('reset()', () => { + it('should clear the buffer without emitting', () => { + const buf = new PendingCommentBuffer(); + const out: string[] = []; + buf.collect('# dropped'); + buf.reset(); + buf.flushToOutput(out); + expect(out).toEqual([]); + }); + }); + + describe('flush-on-emit, drop-on-delete, flush-on-reinsert semantics', () => { + it('flushes when the next entry survives (emit)', () => { + const buf = new PendingCommentBuffer(); + const out: string[] = []; + buf.collect('# keeps-its-comment'); + buf.flushToOutput(out); + out.push('key = "value"'); + expect(out).toEqual(['# keeps-its-comment', 'key = "value"']); + }); + + it('drops when the next entry is deleted (preamble goes with it)', () => { + const buf = new PendingCommentBuffer(); + const out: string[] = []; + buf.collect('# doomed-preamble'); + buf.drop(); + expect(out).toEqual([]); + }); + + it('flushes again after reinsertion-path entries survive', () => { + const buf = new PendingCommentBuffer(); + const out: string[] = []; + buf.collect('# first-preamble'); + buf.flushToOutput(out); + out.push('kept = "x"'); + buf.collect('# second-preamble'); + buf.flushToOutput(out); + out.push('kept2 = "y"'); + expect(out).toEqual([ + '# first-preamble', + 'kept = "x"', + '# second-preamble', + 'kept2 = "y"', + ]); + }); + }); + + describe('round-trip preservation', () => { + it('should preserve the full comment+blank+entry sequence exactly', () => { + const buf = new PendingCommentBuffer(); + const out: string[] = []; + const src = [ + '# header comment', + '', + '# another', + 'key = "value"', + '', + 'other = "thing"', + ]; + for (const line of src) { + if (line.startsWith('#') || line === '') { + buf.collect(line); + continue; + } + buf.flushToOutput(out); + out.push(line); + } + buf.flushToOutput(out); + expect(out).toEqual(src); + }); + + it('supports mixed emit/drop/emit over the same buffer', () => { + const buf = new PendingCommentBuffer(); + const out: string[] = []; + buf.collect('# for-deleted'); + buf.drop(); + buf.collect('# for-kept'); + buf.flushToOutput(out); + out.push('kept = "v"'); + expect(out).toEqual(['# for-kept', 'kept = "v"']); + }); + }); +}); diff --git a/tests/unit/formats/php-arrays.test.ts b/tests/unit/formats/php-arrays.test.ts new file mode 100644 index 0000000..b799f85 --- /dev/null +++ b/tests/unit/formats/php-arrays.test.ts @@ -0,0 +1,734 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { + PhpArraysFormatParser, + PhpArraysCapExceededError, + SKIP_REASON_PIPE_PLURALIZATION, +} from '../../../src/formats/php-arrays'; +import { createDefaultRegistry } from '../../../src/formats/index'; +import { ValidationError } from '../../../src/utils/errors'; +import { Logger } from '../../../src/utils/logger'; + +const parser = new PhpArraysFormatParser(); + +describe('PhpArraysFormatParser', () => { + it('is registered in the default registry under laravel_php', async () => { + const registry = await createDefaultRegistry(); + expect(registry.getParserByFormatKey('laravel_php')?.name).toBe('Laravel PHP arrays'); + expect(registry.getSupportedExtensions()).toContain('.php'); + }); + + describe('extract()', () => { + it('returns [] for empty content', () => { + expect(parser.extract('')).toEqual([]); + expect(parser.extract(' \n ')).toEqual([]); + }); + + it('returns [] for a PHP file with no return statement', () => { + expect(parser.extract(' { + expect(parser.extract(' { + const content = ` 'Hello', + 'farewell' => 'Goodbye', +]; +`; + const entries = parser.extract(content); + expect(entries).toHaveLength(2); + expect(entries.find((e) => e.key === 'greeting')?.value).toBe('Hello'); + expect(entries.find((e) => e.key === 'farewell')?.value).toBe('Goodbye'); + }); + + it('builds dot-notation keys from nested associative arrays', () => { + const content = ` [ + 'failed' => 'These credentials do not match our records.', + 'password' => 'The password is incorrect.', + ], + 'welcome' => 'Welcome', +]; +`; + const entries = parser.extract(content); + const map = Object.fromEntries(entries.map((e) => [e.key, e.value])); + expect(map['auth.failed']).toBe('These credentials do not match our records.'); + expect(map['auth.password']).toBe('The password is incorrect.'); + expect(map['welcome']).toBe('Welcome'); + expect(entries).toHaveLength(3); + }); + + it('supports both `[...]` and `array(...)` syntax in the same file', () => { + const content = ` 'Short', + 'long' => array( + 'inner' => 'Inner', + ), +]; +`; + const entries = parser.extract(content); + expect(entries.find((e) => e.key === 'short')?.value).toBe('Short'); + expect(entries.find((e) => e.key === 'long.inner')?.value).toBe('Inner'); + }); + + it('round-trips `:placeholder` as an opaque substring', () => { + const content = ` 'Welcome :name, you have :count messages'];`; + const entries = parser.extract(content); + expect(entries[0]?.value).toBe('Welcome :name, you have :count messages'); + }); + + it('decodes single-quoted escape sequences (\\\\\' and \\\\\\\\)', () => { + const content = ` 'It\\'s a back\\\\slash'];`; + const entries = parser.extract(content); + expect(entries[0]?.value).toBe("It's a back\\slash"); + }); + + it('decodes double-quoted escape sequences (no interpolation tokens)', () => { + const content = ` "line1\\nline2 \\$100"];`; + const entries = parser.extract(content); + expect(entries[0]?.value).toBe('line1\nline2 $100'); + }); + + it('strips a UTF-8 BOM before the ` { + const content = `\uFEFF 'Hello'];`; + const entries = parser.extract(content); + expect(entries).toEqual([{ key: 'greeting', value: 'Hello' }]); + }); + + it('skips numeric, boolean, and null values without rejecting them', () => { + const content = ` 'Test', + 'max' => 255, + 'enabled' => true, + 'default' => null, +]; +`; + const entries = parser.extract(content); + expect(entries).toEqual([{ key: 'name', value: 'Test' }]); + }); + + it('contributes no entries from an empty nested array', () => { + const content = ` [], 'welcome' => 'Hi'];`; + const entries = parser.extract(content); + expect(entries).toEqual([{ key: 'welcome', value: 'Hi' }]); + }); + + it('rejects double-quoted interpolation ("Hello $name") with ValidationError', () => { + const content = ` "Hello $name"];`; + expect(() => parser.extract(content)).toThrow(ValidationError); + }); + + it('rejects heredoc values with ValidationError', () => { + const content = ` << parser.extract(content)).toThrow(ValidationError); + }); + + it('rejects nowdoc values with ValidationError', () => { + const content = ` <<<'EOT' +multi-line nowdoc +EOT +, +]; +`; + expect(() => parser.extract(content)).toThrow(ValidationError); + }); + + it("rejects string concatenation ('a' . 'b') with ValidationError", () => { + const content = ` 'Hello, ' . 'world'];`; + expect(() => parser.extract(content)).toThrow(ValidationError); + }); + + it('rejects function-call expressions as values', () => { + const content = ` trans('greeting')];`; + expect(() => parser.extract(content)).toThrow(ValidationError); + }); + + it('rejects when the top-level return is not an array', () => { + const content = ` parser.extract(content)).toThrow(ValidationError); + }); + + it('rejects numeric-indexed entries (non-string keys)', () => { + const content = ` parser.extract(content)).toThrow(ValidationError); + }); + }); + + describe('pipe-pluralization warning gate', () => { + it('tags values containing `|{n}` count markers with metadata.skipped', () => { + const content = ` '{0} No apples|{1} One apple|[2,*] Many apples'];`; + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]?.metadata).toEqual({ + skipped: { reason: SKIP_REASON_PIPE_PLURALIZATION }, + }); + }); + + it('tags values containing `|[n,m]` range markers', () => { + const content = ` '[0,0] No days|[1,6] A few|[7,*] Full week'];`; + const entries = parser.extract(content); + expect(entries[0]?.metadata?.['skipped']).toEqual({ + reason: SKIP_REASON_PIPE_PLURALIZATION, + }); + }); + + it('does NOT tag simple pipe-delimited values without explicit count markers', () => { + const content = ` 'apples|apple'];`; + const entries = parser.extract(content); + expect(entries[0]?.metadata).toBeUndefined(); + }); + + it('does NOT tag prose values that happen to contain literal pipes', () => { + const content = ` 'Choose A | B | C'];`; + const entries = parser.extract(content); + expect(entries[0]?.metadata).toBeUndefined(); + }); + + it('preserves the original string value in the tagged entry (no preprocessing)', () => { + const content = ` '{0}none|{1}one|[2,*]many'];`; + const entries = parser.extract(content); + expect(entries[0]?.value).toBe('{0}none|{1}one|[2,*]many'); + }); + + it('emits a Logger.warn naming the dot-path key when tagging', () => { + const warnSpy = jest.spyOn(Logger, 'warn').mockImplementation(() => undefined); + try { + parser.extract(` ['items' => '{0}none|{1}one|[2,*]many']];`); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('nav.items')); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('pipe-pluralization')); + } finally { + warnSpy.mockRestore(); + } + }); + + it('does not warn for plain translatable strings', () => { + const warnSpy = jest.spyOn(Logger, 'warn').mockImplementation(() => undefined); + try { + parser.extract(` 'Hello, world'];`); + expect(warnSpy).not.toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + } + }); + }); + + describe('max depth cap', () => { + it('accepts nesting up to the configured maxDepth', () => { + const shallow = new PhpArraysFormatParser({ maxDepth: 4 }); + // 3 levels of nesting: [ 'a' => [ 'b' => [ 'c' => 'x' ] ] ] + const content = ` ['b' => ['c' => 'x']]];`; + expect(() => shallow.extract(content)).not.toThrow(); + }); + + it('throws PhpArraysCapExceededError when nesting exceeds maxDepth', () => { + const shallow = new PhpArraysFormatParser({ maxDepth: 2 }); + // 3 levels of nesting — exceeds cap of 2 + const content = ` ['b' => ['c' => 'x']]];`; + expect(() => shallow.extract(content)).toThrow(PhpArraysCapExceededError); + }); + + it('defaults to depth 32 when no option is provided', () => { + // 32 levels pass, 33 would exceed — construct a deep nest programmatically. + const defaultParser = new PhpArraysFormatParser(); + // Build exactly 32 levels of nested arrays: 1 outer + 31 inner + let inner = `'x'`; + for (let i = 0; i < 31; i++) inner = `['k${i}' => ${inner}]`; + const ok = ` ${inner}];`; + expect(() => defaultParser.extract(ok)).not.toThrow(); + + // 33 levels should exceed + let deeper = `'x'`; + for (let i = 0; i < 32; i++) deeper = `['k${i}' => ${deeper}]`; + const tooDeep = ` ${deeper}];`; + expect(() => defaultParser.extract(tooDeep)).toThrow(PhpArraysCapExceededError); + }); + + it('cap-exceeded error names the dot-path where the limit was hit', () => { + const shallow = new PhpArraysFormatParser({ maxDepth: 2 }); + const content = ` ['inner' => ['leaf' => 'x']]];`; + expect(() => shallow.extract(content)).toThrow(/outer\.inner/); + }); + }); + + describe('reconstruct()', () => { + it('returns content unchanged for empty entries', () => { + const content = ` 'Hello'];`; + expect(parser.reconstruct(content, [])).toBe(content); + }); + + it('replaces a single flat string, preserving surrounding bytes verbatim', () => { + const content = ` 'Hello',\n];\n`; + const out = parser.reconstruct(content, [ + { key: 'greeting', value: 'Hello', translation: 'Hallo' }, + ]); + expect(out).toBe(` 'Hallo',\n];\n`); + }); + + it('rewrites a dot-path key inside a nested array', () => { + const content = ` [ + 'failed' => 'These credentials do not match our records.', + ], +]; +`; + const out = parser.reconstruct(content, [ + { + key: 'auth.failed', + value: 'These credentials do not match our records.', + translation: 'Ungültige Zugangsdaten.', + }, + ]); + expect(out).toContain(`'failed' => 'Ungültige Zugangsdaten.'`); + expect(out).toContain(`'auth' => [`); + }); + + it('preserves single-quoted quote style for a single-quoted source', () => { + const content = ` 'one'];`; + const out = parser.reconstruct(content, [ + { key: 'x', value: 'one', translation: 'uno' }, + ]); + expect(out).toBe(` 'uno'];`); + }); + + it('preserves double-quoted quote style for a double-quoted source', () => { + const content = ` "one"];`; + const out = parser.reconstruct(content, [ + { key: 'x', value: 'one', translation: 'uno' }, + ]); + expect(out).toBe(` "uno"];`); + }); + + it("escapes apostrophes in single-quoted translations as \\'", () => { + const content = ` 'ok'];`; + const out = parser.reconstruct(content, [ + { key: 'msg', value: 'ok', translation: "It's fine" }, + ]); + expect(out).toBe(` 'It\\'s fine'];`); + }); + + it('escapes backslashes in single-quoted translations as \\\\', () => { + const content = ` 'x'];`; + const out = parser.reconstruct(content, [ + { key: 'path', value: 'x', translation: 'C:\\Users' }, + ]); + expect(out).toBe(` 'C:\\\\Users'];`); + }); + + it('escapes $ in double-quoted translations as \\$ (prevents interpolation on re-parse)', () => { + const content = ` "n/a"];`; + const out = parser.reconstruct(content, [ + { key: 'price', value: 'n/a', translation: '$100' }, + ]); + expect(out).toBe(` "\\$100"];`); + const reExtracted = parser.extract(out); + expect(reExtracted[0]?.value).toBe('$100'); + }); + + it('escapes " in double-quoted translations as \\"', () => { + const content = ` "x"];`; + const out = parser.reconstruct(content, [ + { key: 'msg', value: 'x', translation: 'She said "hi"' }, + ]); + expect(out).toBe(` "She said \\"hi\\""];`); + }); + + it('leaves untranslated keys exactly as they appear in the source', () => { + const content = ` 'one', 'b' => 'two', 'c' => 'three'];`; + const out = parser.reconstruct(content, [ + { key: 'b', value: 'two', translation: 'zwei' }, + ]); + expect(out).toBe(` 'one', 'b' => 'zwei', 'c' => 'three'];`); + }); + + it('silently skips keys that are not present in the source (no insertion)', () => { + const content = ` 'one'];`; + const out = parser.reconstruct(content, [ + { key: 'a', value: 'one', translation: 'uno' }, + { key: 'not_in_source', value: 'new', translation: 'nuevo' }, + ]); + expect(out).toBe(` 'uno'];`); + }); + + it('preserves comments, PHPDoc, trailing commas, and irregular whitespace', () => { + const content = ` 'Hello', // trailing comment + 'bye' => 'Goodbye', +]; +`; + const out = parser.reconstruct(content, [ + { key: 'hello', value: 'Hello', translation: 'Hallo' }, + ]); + expect(out).toBe(` 'Hallo', // trailing comment + 'bye' => 'Goodbye', +]; +`); + }); + + it('rewrites values regardless of `[...]` vs `array(...)` syntax', () => { + const content = ` 'one', 'b' => array('c' => 'two'));`; + const out = parser.reconstruct(content, [ + { key: 'a', value: 'one', translation: 'uno' }, + { key: 'b.c', value: 'two', translation: 'dos' }, + ]); + expect(out).toBe(` 'uno', 'b' => array('c' => 'dos'));`); + }); + + it('handles multiple replacements on the same line via descending-offset rewrite', () => { + const content = ` 'x', 'b' => 'y'];`; + const out = parser.reconstruct(content, [ + { key: 'a', value: 'x', translation: 'apple' }, + { key: 'b', value: 'y', translation: 'banana' }, + ]); + expect(out).toBe(` 'apple', 'b' => 'banana'];`); + }); + + it('is byte-equal when reconstruct keeps values identical to extract', () => { + const content = ` 'Hello', + 'nav' => [ + 'home' => 'Home', + ], +]; +`; + const entries = parser.extract(content).map((e) => ({ ...e, translation: e.value })); + expect(parser.reconstruct(content, entries)).toBe(content); + }); + + it('preserves a UTF-8 BOM before the ` { + const content = `\uFEFF 'one'];`; + const out = parser.reconstruct(content, [ + { key: 'a', value: 'one', translation: 'uno' }, + ]); + expect(out).toBe(`\uFEFF 'uno'];`); + }); + + it('handles non-ASCII source positions correctly (UTF-16 offsets)', () => { + const content = ` '日本語', 'bye' => 'さようなら'];`; + const out = parser.reconstruct(content, [ + { key: 'bye', value: 'さようなら', translation: 'Auf Wiedersehen' }, + ]); + expect(out).toBe(` '日本語', 'bye' => 'Auf Wiedersehen'];`); + }); + + it('throws ValidationError when the file has no return array', () => { + expect(() => + parser.reconstruct(` { + const FIXTURES_DIR = path.resolve( + __dirname, + '../../fixtures/sync/formats/laravel-php', + ); + const load = (name: string): string => + fs.readFileSync(path.join(FIXTURES_DIR, name), 'utf-8'); + + // Fixtures the parser must accept and extract cleanly. + const ACCEPT_FIXTURES = [ + '01-single-quote-escape.php', + '02-double-quote-escapes.php', + '06-mixed-syntax.php', + '07-colon-placeholder.php', + '08-trailing-commas.php', + '09-ast-idempotence.php', + '10-empty-nested-array.php', + '11-dot-key-vs-nested.php', + '12-escaped-dollar.php', + '13-utf8-bom.php', + '14-literal-pipe-in-prose.php', + '15-irregular-whitespace-and-comments.php', + ] as const; + + // Fixtures the allowlist must reject. + const REJECT_FIXTURES = [ + '03-interpolation-rejected.php', + '04-heredoc-rejected.php', + '05-concat-rejected.php', + ] as const; + + describe('accept fixtures — targeted assertions', () => { + it("01: `\\'` and `\\\\` decode inside single-quoted values", () => { + const entries = parser.extract(load('01-single-quote-escape.php')); + const map = Object.fromEntries(entries.map((e) => [e.key, e.value])); + expect(map['possessive']).toBe("It's a test"); + expect(map['backslash']).toBe('path\\to\\file'); + }); + + it('02: `\\n`, `\\t`, `\\"` decode inside double-quoted (no interpolation)', () => { + const entries = parser.extract(load('02-double-quote-escapes.php')); + const map = Object.fromEntries(entries.map((e) => [e.key, e.value])); + expect(map['newline']).toBe('line1\nline2'); + expect(map['quote']).toBe('She said "hi"'); + expect(map['tab']).toBe('col1\tcol2'); + }); + + it('06: `[...]` and `array(...)` coexist and both walk', () => { + const entries = parser.extract(load('06-mixed-syntax.php')); + const keys = entries.map((e) => e.key).sort(); + expect(keys).toEqual(['long.inner', 'nested_short.a', 'short']); + }); + + it('07: `:placeholder` markers round-trip as opaque substrings', () => { + const entries = parser.extract(load('07-colon-placeholder.php')); + const map = Object.fromEntries(entries.map((e) => [e.key, e.value])); + expect(map['greeting']).toBe('Welcome, :name!'); + expect(map['summary']).toBe('You have :count messages from :sender'); + expect(map['required']).toBe('The :attribute field is required.'); + }); + + it('08: trailing commas parse without issue', () => { + const entries = parser.extract(load('08-trailing-commas.php')); + expect(entries).toHaveLength(4); + }); + + it('10: empty nested arrays contribute no entries and do not throw', () => { + const entries = parser.extract(load('10-empty-nested-array.php')); + const keys = entries.map((e) => e.key).sort(); + expect(keys).toEqual(['errors.required', 'welcome']); + }); + + it('11: literal-dot key coexists with a nested `user.name` path', () => { + const entries = parser.extract(load('11-dot-key-vs-nested.php')); + const map = Object.fromEntries(entries.map((e) => [e.key, e.value])); + // Both keys exist and collide at the dot-path level — this is a + // known Laravel ambiguity. The extract faithfully surfaces both; + // downstream diff/translate logic is responsible for choosing a + // resolution strategy (currently last-write-wins via Map semantics). + expect(map['user.name']).toBeDefined(); + expect(entries.some((e) => e.key === 'user.name' && e.value === 'Literal dot key')).toBe(true); + expect(entries.some((e) => e.key === 'user.name' && e.value === 'Nested under user.name')).toBe(true); + }); + + it('12: `"\\$100"` and `"\\${currency}"` decode to literal `$`', () => { + const entries = parser.extract(load('12-escaped-dollar.php')); + const map = Object.fromEntries(entries.map((e) => [e.key, e.value])); + expect(map['price']).toBe('Only $100!'); + expect(map['note']).toBe('Use ${currency} in your local flavor'); + }); + + it('13: UTF-8 BOM before ` { + const content = load('13-utf8-bom.php'); + expect(content.charCodeAt(0)).toBe(0xfeff); // sanity: fixture actually has BOM + const entries = parser.extract(content); + expect(entries).toEqual([{ key: 'greeting', value: 'Hello with BOM' }]); + }); + + it('14: literal pipes in prose do NOT trigger the pluralization gate', () => { + const entries = parser.extract(load('14-literal-pipe-in-prose.php')); + expect(entries.every((e) => e.metadata === undefined)).toBe(true); + }); + + it('15: PHPDoc, block comments, and irregular whitespace produce normal extract', () => { + const entries = parser.extract(load('15-irregular-whitespace-and-comments.php')); + const map = Object.fromEntries(entries.map((e) => [e.key, e.value])); + expect(map['hello']).toBe('Hello'); + expect(map['bye']).toBe('Goodbye'); + expect(map['nav.home']).toBe('Home'); + expect(map['nav.about']).toBe('About'); + }); + }); + + describe('reject fixtures — allowlist throws ValidationError', () => { + for (const name of REJECT_FIXTURES) { + it(`rejects ${name}`, () => { + expect(() => parser.extract(load(name))).toThrow(ValidationError); + }); + } + }); + + describe('CI gate: AST/byte-equal round-trip when translations equal source', () => { + // Fixture 11 is the explicit "collision ambiguity" case: extract emits + // two entries with the same dot-path (`user.name`) from different + // source locations. Identity round-trip is unreachable by design + // because TranslatedEntry[] is keyed on the dot-path, so one location's + // translation overwrites the other's. Covered separately below. + const BYTE_EQUAL_FIXTURES = ACCEPT_FIXTURES.filter( + (n) => n !== '11-dot-key-vs-nested.php', + ); + for (const name of BYTE_EQUAL_FIXTURES) { + it(`reconstructs ${name} byte-identically`, () => { + const content = load(name); + const extracted = parser.extract(content); + // Skipped entries (pipe-pluralization) would never flow into + // TranslatedEntry[], so exclude them here too — reconstruct then + // leaves those bytes untouched by construction. + const entries = extracted + .filter((e) => !e.metadata?.['skipped']) + .map((e) => ({ ...e, translation: e.value })); + expect(parser.reconstruct(content, entries)).toBe(content); + }); + } + + it('11: collision case — reconstruct with no translations is byte-equal (baseline)', () => { + const content = load('11-dot-key-vs-nested.php'); + expect(parser.reconstruct(content, [])).toBe(content); + }); + }); + + describe('CI gate: byte-equal on untouched (span-surgical preservation)', () => { + it('fixture 15: translating ONE key preserves comments, PHPDoc, and irregular whitespace verbatim', () => { + const content = load('15-irregular-whitespace-and-comments.php'); + const out = parser.reconstruct(content, [ + { key: 'hello', value: 'Hello', translation: 'Hallo' }, + ]); + + // PHPDoc header preserved byte-for-byte + expect(out).toContain('/**\n * Language Lines — do not delete.'); + // Inline comment preserved + expect(out).toContain('// Primary greeting (do not rename).'); + // Block comment preserved + expect(out).toContain('/* multiline\n block comment */'); + // Irregular inner whitespace preserved around the rewritten key + expect(out).toContain(`'hello' => 'Hallo', // trailing`); + // Everything besides the rewritten value stays identical + expect(out.length).toBe(content.length + ('Hallo'.length - 'Hello'.length)); + }); + }); + }); + + describe('supply-chain + runtime safety', () => { + const phpParserDir = path.dirname( + require.resolve('php-parser/package.json'), + ); + const distBundle = fs.readFileSync( + path.join(phpParserDir, 'dist', 'php-parser.js'), + 'utf-8', + ); + const distMinBundle = fs.readFileSync( + path.join(phpParserDir, 'dist', 'php-parser.min.js'), + 'utf-8', + ); + const packageJson = JSON.parse( + fs.readFileSync(path.join(phpParserDir, 'package.json'), 'utf-8'), + ) as { + dependencies?: Record; + scripts?: Record; + }; + + // Token names assembled at runtime so the scanner does not mistake this + // test's *asserting* strings for actual calls to the dangerous APIs. + const CHILD_PROCESS_TOKEN = ['child', 'process'].join('_'); + const VM_TOKEN = 'vm'; + const WORKER_TOKEN = ['worker', 'threads'].join('_'); + const EVAL_TOKEN = ['e', 'val'].join(''); + const NEW_FUNCTION_TOKEN = ['Fun', 'ction'].join(''); + const SPAWN_SYNC_TOKEN = ['spawn', 'Sync'].join(''); + const EXEC_SYNC_TOKEN = ['e', 'xec', 'Sync'].join(''); + + // Precise regexes — match dangerous APIs without colliding with the + // innocent `class Function` AST-node declaration the audit flagged. + const FORBIDDEN: ReadonlyArray<[string, RegExp]> = [ + [ + `require("${CHILD_PROCESS_TOKEN}")`, + new RegExp(`require\\s*\\(\\s*["']${CHILD_PROCESS_TOKEN}["']\\s*\\)`), + ], + [ + `require("${VM_TOKEN}")`, + new RegExp(`require\\s*\\(\\s*["']${VM_TOKEN}["']\\s*\\)`), + ], + [ + `require("${WORKER_TOKEN}")`, + new RegExp(`require\\s*\\(\\s*["']${WORKER_TOKEN}["']\\s*\\)`), + ], + [ + `from "${CHILD_PROCESS_TOKEN}"`, + new RegExp(`from\\s+["']${CHILD_PROCESS_TOKEN}["']`), + ], + [ + `from "${VM_TOKEN}"`, + new RegExp(`from\\s+["']${VM_TOKEN}["']`), + ], + [EVAL_TOKEN, new RegExp(`\\b${EVAL_TOKEN}\\s*\\(`)], + [ + `new ${NEW_FUNCTION_TOKEN}(`, + new RegExp(`\\bnew\\s+${NEW_FUNCTION_TOKEN}\\s*\\(`), + ], + [SPAWN_SYNC_TOKEN, new RegExp(`\\b${SPAWN_SYNC_TOKEN}\\s*\\(`)], + [EXEC_SYNC_TOKEN, new RegExp(`\\b${EXEC_SYNC_TOKEN}\\s*\\(`)], + ]; + + it('php-parser dist bundle contains no dangerous imports or code-exec primitives', () => { + for (const [name, pattern] of FORBIDDEN) { + expect({ name, match: pattern.test(distBundle) }).toEqual({ + name, + match: false, + }); + } + }); + + it('php-parser minified bundle contains no dangerous imports or code-exec primitives', () => { + for (const [name, pattern] of FORBIDDEN) { + expect({ name, match: pattern.test(distMinBundle) }).toEqual({ + name, + match: false, + }); + } + }); + + it('php-parser declares zero runtime dependencies', () => { + expect(packageJson.dependencies ?? {}).toEqual({}); + }); + + it('php-parser declares no preinstall or postinstall hooks', () => { + expect(packageJson.scripts?.['preinstall']).toBeUndefined(); + expect(packageJson.scripts?.['postinstall']).toBeUndefined(); + }); + + it(`exercising extract and reconstruct does not load ${CHILD_PROCESS_TOKEN} or ${VM_TOKEN} into require.cache`, () => { + const fresh = new PhpArraysFormatParser(); + fresh.extract( + ` 'Hello', 'nested' => ['b' => 'World']];`, + ); + fresh.reconstruct(` 'Hello'];`, [ + { key: 'a', value: 'Hello', translation: 'Hola' }, + ]); + + const cacheKeys = Object.keys(require.cache); + const cpPattern = new RegExp( + `(?:^|[\\/\\\\])${CHILD_PROCESS_TOKEN}(?:[\\/\\\\.]|$)`, + ); + const vmPattern = new RegExp(`(?:^|[\\/\\\\])${VM_TOKEN}(?:[\\/\\\\.]|$)`); + expect(cacheKeys.find((k) => cpPattern.test(k))).toBeUndefined(); + expect(cacheKeys.find((k) => vmPattern.test(k))).toBeUndefined(); + }); + }); +}); diff --git a/tests/unit/formats/po.test.ts b/tests/unit/formats/po.test.ts new file mode 100644 index 0000000..95eab31 --- /dev/null +++ b/tests/unit/formats/po.test.ts @@ -0,0 +1,449 @@ +import { createDefaultRegistry } from '../../../src/formats/index'; +import { PoFormatParser } from '../../../src/formats/po'; +import type { TranslatedEntry } from '../../../src/formats/format'; + +describe('po parser', () => { + it('should be registered in the default registry', async () => { + const registry = await createDefaultRegistry(); + const extensions = registry.getSupportedExtensions(); + expect(extensions.length).toBeGreaterThan(0); + }); +}); + +describe('PoFormatParser extract (unquote)', () => { + const parser = new PoFormatParser(); + + it('should decode literal backslash-n (\\\\n in PO) as backslash + n, not newline', () => { + const po = [ + 'msgid "path\\\\nname"', + 'msgstr ""', + ].join('\n'); + + const entries = parser.extract(po); + expect(entries).toHaveLength(1); + expect(entries[0]!.value).toBe('path\\nname'); + expect(entries[0]!.value).not.toContain('\n'); + }); +}); + +describe('PoFormatParser extract (msgid with #)', () => { + const parser = new PoFormatParser(); + + it('should not confuse # in msgid with msgctxt separator', () => { + const po = [ + 'msgid "error#404"', + 'msgstr "Not Found"', + ].join('\n'); + + const entries = parser.extract(po); + expect(entries).toHaveLength(1); + expect(entries[0]!.key).toBe('error#404'); + expect(entries[0]!.value).toBe('error#404'); + + const result = parser.reconstruct(po, [ + { key: 'error#404', value: 'error#404', translation: 'Nicht gefunden' }, + ]); + + expect(result).toContain('msgid "error#404"'); + expect(result).toContain('msgstr "Nicht gefunden"'); + expect(result).not.toContain('msgctxt'); + }); +}); + +describe('PoFormatParser reconstruct', () => { + const parser = new PoFormatParser(); + + it('should replace msgstr with entry.translation, not source text', () => { + const template = [ + 'msgid "greeting"', + 'msgstr "Old Translation"', + '', + 'msgid "farewell"', + 'msgstr "Old Farewell"', + ].join('\n'); + + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hallo' }, + { key: 'farewell', value: 'Goodbye', translation: 'Auf Wiedersehen' }, + ]; + + const result = parser.reconstruct(template, entries); + + expect(result).toContain('msgstr "Hallo"'); + expect(result).toContain('msgstr "Auf Wiedersehen"'); + expect(result).not.toContain('msgstr "Old Translation"'); + expect(result).not.toContain('msgstr "Old Farewell"'); + expect(result).not.toContain('msgstr "Hello"'); + expect(result).not.toContain('msgstr "Goodbye"'); + }); + + it('should append new entries not present in template', () => { + const template = [ + 'msgid "greeting"', + 'msgstr "Hallo"', + ].join('\n'); + + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hallo' }, + { key: 'new_key', value: 'New text', translation: 'Neuer Text' }, + ]; + + const result = parser.reconstruct(template, entries); + + expect(result).toContain('msgid "greeting"'); + expect(result).toContain('msgid "new_key"'); + expect(result).toContain('msgstr "Neuer Text"'); + const lines = result.split('\n'); + const newKeyIdx = lines.findIndex(l => l.includes('"new_key"')); + const greetingIdx = lines.findIndex(l => l.includes('"greeting"')); + expect(newKeyIdx).toBeGreaterThan(greetingIdx); + }); + + it('should remove entries from template that are not in entries (deleted keys)', () => { + const template = [ + 'msgid "greeting"', + 'msgstr "Hallo"', + '', + 'msgid "deleted_key"', + 'msgstr "Geloescht"', + '', + 'msgid "farewell"', + 'msgstr "Auf Wiedersehen"', + ].join('\n'); + + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hallo' }, + { key: 'farewell', value: 'Goodbye', translation: 'Auf Wiedersehen' }, + ]; + + const result = parser.reconstruct(template, entries); + + expect(result).toContain('msgid "greeting"'); + expect(result).toContain('msgid "farewell"'); + expect(result).not.toContain('deleted_key'); + expect(result).not.toContain('Geloescht'); + }); + + it('should preserve header entries regardless of entries list', () => { + const template = [ + 'msgid ""', + 'msgstr "Content-Type: text/plain; charset=UTF-8\\n"', + '', + 'msgid "greeting"', + 'msgstr "Hallo"', + ].join('\n'); + + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Updated Hallo' }, + ]; + + const result = parser.reconstruct(template, entries); + + expect(result).toContain('msgstr "Content-Type: text/plain; charset=UTF-8\\n"'); + expect(result).toContain('msgstr "Updated Hallo"'); + }); + + it('should preserve multi-line header with empty msgstr and continuation lines', () => { + const template = [ + 'msgid ""', + 'msgstr ""', + '"Content-Type: text/plain; charset=UTF-8\\n"', + '"Plural-Forms: nplurals=2; plural=(n != 1);\\n"', + '', + 'msgid "greeting"', + 'msgstr "Hallo"', + ].join('\n'); + + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Bonjour' }, + ]; + + const result = parser.reconstruct(template, entries); + + expect(result).toContain('msgstr ""'); + expect(result).toContain('"Content-Type: text/plain; charset=UTF-8\\n"'); + expect(result).toContain('"Plural-Forms: nplurals=2; plural=(n != 1);\\n"'); + expect(result).toContain('msgstr "Bonjour"'); + }); + + it('should remove fuzzy flag when providing a fresh translation', () => { + const template = [ + '#, fuzzy', + 'msgid "greeting"', + 'msgstr ""', + ].join('\n'); + + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hallo' }, + ]; + + const result = parser.reconstruct(template, entries); + + expect(result).toContain('msgstr "Hallo"'); + expect(result).not.toContain('fuzzy'); + }); + + it('should keep other flags when removing fuzzy from multi-flag line', () => { + const template = [ + '#, fuzzy, python-format', + 'msgid "greeting"', + 'msgstr ""', + ].join('\n'); + + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hallo' }, + ]; + + const result = parser.reconstruct(template, entries); + + expect(result).toContain('#, python-format'); + expect(result).not.toContain('fuzzy'); + expect(result).toContain('msgstr "Hallo"'); + }); + + it('should use continuation line format for long msgstr with newlines', () => { + const template = [ + 'msgid "long_message"', + 'msgstr ""', + ].join('\n'); + + const longTranslation = 'First line of the translated message\nSecond line of the translated message\nThird line'; + + const entries: TranslatedEntry[] = [ + { key: 'long_message', value: 'source', translation: longTranslation }, + ]; + + const result = parser.reconstruct(template, entries); + + expect(result).toContain('msgstr ""'); + expect(result).toContain('"First line of the translated message\\n"'); + expect(result).toContain('"Second line of the translated message\\n"'); + expect(result).toContain('"Third line"'); + expect(result).not.toMatch(/^msgstr "First/m); + }); + + it('should use single-line format for short msgstr without newlines', () => { + const template = [ + 'msgid "greeting"', + 'msgstr ""', + ].join('\n'); + + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hallo' }, + ]; + + const result = parser.reconstruct(template, entries); + + expect(result).toContain('msgstr "Hallo"'); + expect(result).not.toContain('msgstr ""'); + }); +}); + +describe('PoFormatParser — parsing coverage', () => { + const parser = new PoFormatParser(); + + it('should parse entries separated by blank lines', () => { + const po = [ + 'msgid "hello"', + 'msgstr "Hello"', + '', + 'msgid "bye"', + 'msgstr "Bye"', + ].join('\n'); + const entries = parser.extract(po); + expect(entries).toHaveLength(2); + }); + + it('should parse developer comments (#.)', () => { + const po = '#. Developer note\nmsgid "key"\nmsgstr "value"\n'; + const entries = parser.extract(po); + expect(entries[0]!.context).toContain('Developer note'); + }); + + it('should parse reference comments (#:)', () => { + const po = '#: src/app.ts:42\nmsgid "key"\nmsgstr "value"\n'; + const entries = parser.extract(po); + expect(entries).toHaveLength(1); + }); + + it('should parse flag comments (#,)', () => { + const po = '#, fuzzy, python-format\nmsgid "key"\nmsgstr "value"\n'; + const entries = parser.extract(po); + expect(entries).toHaveLength(1); + }); + + it('should parse translator comments (#)', () => { + const po = '# Translator note\nmsgid "key"\nmsgstr "value"\n'; + const entries = parser.extract(po); + expect(entries).toHaveLength(1); + }); + + it('should skip obsolete entries (#~)', () => { + const po = '#~ msgid "old"\n#~ msgstr "ancient"\nmsgid "key"\nmsgstr "value"\n'; + const entries = parser.extract(po); + expect(entries).toHaveLength(1); + expect(entries[0]!.key).toBe('key'); + }); + + it('should parse msgctxt', () => { + const po = 'msgctxt "menu"\nmsgid "file"\nmsgstr "File"\n'; + const entries = parser.extract(po); + expect(entries).toHaveLength(1); + expect(entries[0]!.key).toContain('file'); + }); + + it('should parse msgid_plural and msgstr[N]', () => { + const po = [ + 'msgid "item"', + 'msgid_plural "items"', + 'msgstr[0] "Artikel"', + 'msgstr[1] "Artikel"', + ].join('\n') + '\n'; + const entries = parser.extract(po); + expect(entries).toHaveLength(1); + expect(entries[0]!.metadata).toBeDefined(); + expect(entries[0]!.metadata!['msgid_plural']).toBe('items'); + }); + + it('should handle multi-line continuation strings', () => { + const po = [ + 'msgid ""', + '"Hello "', + '"World"', + 'msgstr "Hallo Welt"', + ].join('\n') + '\n'; + const entries = parser.extract(po); + expect(entries).toHaveLength(1); + expect(entries[0]!.value).toBe('Hello World'); + }); + + it('should handle continuation for msgctxt', () => { + const po = [ + 'msgctxt ""', + '"menu"', + 'msgid "file"', + 'msgstr "Datei"', + ].join('\n') + '\n'; + const entries = parser.extract(po); + expect(entries).toHaveLength(1); + }); + + it('should handle continuation for msgid_plural', () => { + const po = [ + 'msgid "item"', + 'msgid_plural ""', + '"items"', + 'msgstr[0] "Artikel"', + 'msgstr[1] "Artikel"', + ].join('\n') + '\n'; + const entries = parser.extract(po); + expect(entries[0]!.metadata!['msgid_plural']).toBe('items'); + }); + + it('should handle continuation for msgstr', () => { + const po = [ + 'msgid "greeting"', + 'msgstr ""', + '"Hallo "', + '"Welt"', + ].join('\n') + '\n'; + const entries = parser.extract(po); + expect(entries[0]!.value).toBe('greeting'); + }); + + it('should handle continuation for msgstr[N]', () => { + const po = [ + 'msgid "item"', + 'msgid_plural "items"', + 'msgstr[0] ""', + '"Artikel"', + 'msgstr[1] ""', + '"Artikel"', + ].join('\n') + '\n'; + const entries = parser.extract(po); + expect(entries).toHaveLength(1); + }); +}); + +describe('PoFormatParser — reconstruct coverage', () => { + const parser = new PoFormatParser(); + + it('should remove deleted entries from output', () => { + const po = [ + 'msgid "keep"', + 'msgstr "Keep"', + '', + 'msgid "delete"', + 'msgstr "Delete"', + ].join('\n') + '\n'; + const entries: TranslatedEntry[] = [ + { key: 'keep', value: 'keep', translation: 'Behalten' }, + ]; + const result = parser.reconstruct(po, entries); + expect(result).toContain('Behalten'); + expect(result).not.toContain('delete'); + }); + + it('should reconstruct entries with msgctxt', () => { + const po = [ + 'msgctxt "menu"', + 'msgid "file"', + 'msgstr "File"', + ].join('\n') + '\n'; + const entries: TranslatedEntry[] = [ + { key: 'menu\x04file', value: 'file', translation: 'Datei' }, + ]; + const result = parser.reconstruct(po, entries); + expect(result).toContain('Datei'); + }); + + it('should reconstruct plural entries', () => { + const po = [ + 'msgid "item"', + 'msgid_plural "items"', + 'msgstr[0] "item"', + 'msgstr[1] "items"', + ].join('\n') + '\n'; + const entries: TranslatedEntry[] = [ + { + key: 'item', + value: 'item', + translation: 'Artikel', + metadata: { + msgid_plural: 'items', + plural_forms: { 'msgstr[0]': 'Artikel', 'msgstr[1]': 'Artikel' }, + }, + }, + ]; + const result = parser.reconstruct(po, entries); + expect(result).toContain('msgstr[0] "Artikel"'); + expect(result).toContain('msgstr[1] "Artikel"'); + }); + + it('should remove fuzzy flag when translation is provided', () => { + const po = [ + '#, fuzzy', + 'msgid "greeting"', + 'msgstr "old"', + ].join('\n') + '\n'; + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'greeting', translation: 'Hallo' }, + ]; + const result = parser.reconstruct(po, entries); + expect(result).toContain('Hallo'); + expect(result).not.toContain('fuzzy'); + }); + + it('should preserve non-fuzzy flags', () => { + const po = [ + '#, python-format', + 'msgid "count: %d"', + 'msgstr "Anzahl: %d"', + ].join('\n') + '\n'; + const entries: TranslatedEntry[] = [ + { key: 'count: %d', value: 'count: %d', translation: 'Anzahl: %d' }, + ]; + const result = parser.reconstruct(po, entries); + expect(result).toContain('python-format'); + }); +}); diff --git a/tests/unit/formats/properties.test.ts b/tests/unit/formats/properties.test.ts new file mode 100644 index 0000000..db71a02 --- /dev/null +++ b/tests/unit/formats/properties.test.ts @@ -0,0 +1,330 @@ +import { PropertiesFormatParser } from '../../../src/formats/properties'; +import { createDefaultRegistry } from '../../../src/formats/index'; +import type { TranslatedEntry } from '../../../src/formats/format'; + +const parser = new PropertiesFormatParser(); + +describe('PropertiesFormatParser', () => { + it('should be registered in the default registry', async () => { + const registry = await createDefaultRegistry(); + expect(registry.getSupportedExtensions()).toContain('.properties'); + }); + + describe('extract()', () => { + it('should extract key=value pairs', () => { + const content = 'greeting=Hello\nfarewell=Goodbye\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(2); + expect(entries.find(e => e.key === 'farewell')!.value).toBe('Goodbye'); + expect(entries.find(e => e.key === 'greeting')!.value).toBe('Hello'); + }); + + it('should handle key: value separator', () => { + const content = 'greeting: Hello\nfarewell: Goodbye\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(2); + expect(entries.find(e => e.key === 'farewell')!.value).toBe('Goodbye'); + }); + + it('should handle spaces around separator', () => { + const content = 'greeting = Hello World\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.value).toBe('Hello World'); + }); + + it('should skip comment lines', () => { + const content = '# This is a comment\ngreeting=Hello\n! Another comment\nfarewell=Goodbye\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(2); + }); + + it('should attach preceding comment as metadata', () => { + const content = '# Welcome message\ngreeting=Hello\n'; + const entries = parser.extract(content); + expect(entries[0]!.metadata).toEqual({ comment: 'Welcome message' }); + }); + + it('should handle Unicode escapes', () => { + const content = 'greeting=Hallo \\u0057elt\n'; + const entries = parser.extract(content); + expect(entries[0]!.value).toBe('Hallo Welt'); + }); + + it('should handle escaped special characters', () => { + const content = 'path=C\\:\\\\Users\\\\test\n'; + const entries = parser.extract(content); + expect(entries[0]!.value).toBe('C:\\Users\\test'); + }); + + it('should handle line continuations', () => { + const content = 'greeting=Hello \\\n World\n'; + const entries = parser.extract(content); + expect(entries[0]!.value).toBe('Hello World'); + }); + + it('should return empty array for empty content', () => { + expect(parser.extract('')).toEqual([]); + }); + + it('should skip blank lines', () => { + const content = 'greeting=Hello\n\n\nfarewell=Goodbye\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(2); + }); + }); + + describe('reconstruct()', () => { + it('should replace values with translations', () => { + const content = 'greeting=Hello\nfarewell=Goodbye\n'; + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hallo' }, + { key: 'farewell', value: 'Goodbye', translation: 'Tsch\\u00fcss' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result).toContain('greeting=Hallo'); + }); + + it('should remove deleted keys', () => { + const content = 'greeting=Hello\nfarewell=Goodbye\n'; + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hallo' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result).toContain('Hallo'); + expect(result).not.toContain('farewell'); + }); + + it('should preserve comments for kept keys', () => { + const content = '# Welcome\ngreeting=Hello\n'; + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hallo' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result).toContain('# Welcome'); + expect(result).toContain('Hallo'); + }); + + it('should escape non-ASCII characters in output', () => { + const content = 'greeting=Hello\n'; + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Héllo' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result).toContain('\\u00e9'); + }); + + it('should preserve original separator style', () => { + const content = 'greeting = Hello\n'; + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hallo' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result).toContain('greeting = Hallo'); + }); + + it('should collapse line continuations in original to single line', () => { + const content = 'greeting=Hello \\\n World\nfarewell=Bye\n'; + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello World', translation: 'Hallo Welt' }, + { key: 'farewell', value: 'Bye', translation: 'Bye' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result).toContain('greeting=Hallo Welt'); + const lines = result.split('\n'); + const greetingLine = lines.find(l => l.startsWith('greeting=')); + expect(greetingLine).toBe('greeting=Hallo Welt'); + }); + + it('should preserve colon separator in output', () => { + const content = 'greeting: Hello\nfarewell: Goodbye\n'; + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hallo' }, + { key: 'farewell', value: 'Goodbye', translation: 'Auf Wiedersehen' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result).toContain('greeting: Hallo'); + expect(result).toContain('farewell: Auf Wiedersehen'); + }); + + it('should handle empty value in reconstruct', () => { + const content = 'greeting=Hello\n'; + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: '' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result).toContain('greeting='); + expect(result.trim()).toBe('greeting='); + }); + + it('should preserve pending blank lines between kept entries', () => { + const content = 'greeting=Hello\n\nfarewell=Goodbye\n'; + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hola' }, + { key: 'farewell', value: 'Goodbye', translation: 'Adios' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result).toContain('Hola'); + expect(result).toContain('Adios'); + expect(result).toContain('\n\n'); + }); + + it('should remove comments for deleted keys and keep non-entry lines', () => { + const content = [ + '# comment for deleted', + 'deleted=Gone', + 'some-random-line-that-does-not-match', + 'keep=Hello', + ].join('\n'); + const entries: TranslatedEntry[] = [ + { key: 'keep', value: 'Hello', translation: 'Hola' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result).not.toContain('deleted'); + expect(result).toContain('some-random-line-that-does-not-match'); + expect(result).toContain('Hola'); + }); + + it('should flush trailing pending comments at end of file', () => { + const content = 'greeting=Hello\n# trailing comment\n'; + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hola' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result).toContain('# trailing comment'); + }); + + it('should handle line continuations in reconstruct', () => { + const content = 'long=This is \\\n a continued \\\n line\n'; + const entries: TranslatedEntry[] = [ + { key: 'long', value: 'This is a continued line', translation: 'Translated' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result).toContain('long=Translated'); + }); + }); + + describe('extract() escape edge cases', () => { + it('should unescape backslash-equals in value', () => { + const content = 'key=1\\=2\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.value).toBe('1=2'); + }); + + it('should unescape backslash-colon in value', () => { + const content = 'key=value\\:with\\:colons\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.value).toBe('value:with:colons'); + }); + + it('should unescape backslash-space in value', () => { + const content = 'key=hello\\ world\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.value).toBe('hello world'); + }); + + it('should handle \\r escape in value', () => { + const content = 'key=line\\rreturn\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.value).toBe('line\rreturn'); + }); + + it('should handle \\n escape in value', () => { + const content = 'key=line\\nnewline\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.value).toBe('line\nnewline'); + }); + + it('should handle \\t escape in value', () => { + const content = 'key=tab\\there\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.value).toBe('tab\there'); + }); + + it('should handle invalid Unicode escape (non-hex after \\u)', () => { + const content = 'key=hello\\uzzzz\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.value).toBe('hellouzzzz'); + }); + + it('should handle \\u with fewer than 4 hex chars at end of string', () => { + const content = 'key=end\\u00\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.value).toBe('endu00'); + }); + + it('should pass through unknown escape character', () => { + const content = 'key=hello\\xworld\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.value).toBe('helloxworld'); + }); + + it('should unescape backslash-backslash', () => { + const content = 'key=path\\\\to\\\\file\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.value).toBe('path\\to\\file'); + }); + + it('should clear pendingComment after blank line', () => { + const content = '# orphan comment\n\ngreeting=Hello\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.metadata).toBeUndefined(); + }); + + it('should attach comment with ! prefix as metadata', () => { + const content = '! Important note\ngreeting=Hello\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.metadata).toEqual({ comment: 'Important note' }); + }); + }); + + describe('escapeValue()', () => { + it('should escape \\r in output', () => { + const content = 'key=Hello\n'; + const entries: TranslatedEntry[] = [ + { key: 'key', value: 'Hello', translation: 'line\rreturn' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result).toContain('\\r'); + }); + + it('should escape \\t in output', () => { + const content = 'key=Hello\n'; + const entries: TranslatedEntry[] = [ + { key: 'key', value: 'Hello', translation: 'tab\there' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result).toContain('\\t'); + }); + + it('should escape \\n in output', () => { + const content = 'key=Hello\n'; + const entries: TranslatedEntry[] = [ + { key: 'key', value: 'Hello', translation: 'line\nnewline' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result).toContain('\\n'); + }); + + it('should escape backslashes in output', () => { + const content = 'key=Hello\n'; + const entries: TranslatedEntry[] = [ + { key: 'key', value: 'Hello', translation: 'back\\slash' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result).toContain('back\\\\slash'); + }); + }); +}); diff --git a/tests/unit/formats/toml.test.ts b/tests/unit/formats/toml.test.ts new file mode 100644 index 0000000..900dd1b --- /dev/null +++ b/tests/unit/formats/toml.test.ts @@ -0,0 +1,315 @@ +import { TomlFormatParser } from '../../../src/formats/toml'; +import { createDefaultRegistry } from '../../../src/formats/index'; +import type { TranslatedEntry } from '../../../src/formats/format'; + +const parser = new TomlFormatParser(); + +describe('TomlFormatParser', () => { + it('should be registered in the default registry', async () => { + const registry = await createDefaultRegistry(); + expect(registry.getSupportedExtensions()).toContain('.toml'); + }); + + describe('extract()', () => { + it('should extract flat key-value pairs', () => { + const content = 'greeting = "Hello"\nfarewell = "Goodbye"\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(2); + expect(entries.find(e => e.key === 'greeting')!.value).toBe('Hello'); + expect(entries.find(e => e.key === 'farewell')!.value).toBe('Goodbye'); + }); + + it('should extract nested table keys with dot paths', () => { + const content = '[nav]\nhome = "Home"\nabout = "About"\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(2); + expect(entries.map(e => e.key).sort()).toEqual(['nav.about', 'nav.home']); + }); + + it('should skip non-string values', () => { + const content = 'name = "Test"\ncount = 5\nflag = true\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(1); + expect(entries[0]!.key).toBe('name'); + }); + + it('should return empty array for empty content', () => { + expect(parser.extract('')).toEqual([]); + expect(parser.extract(' \n ')).toEqual([]); + }); + + it('should handle go-i18n style deeply nested tables', () => { + const content = [ + '[messages]', + '[messages.greeting]', + 'other = "Hello, {{.Name}}!"', + '[messages.farewell]', + 'other = "Goodbye"', + ].join('\n') + '\n'; + const entries = parser.extract(content); + expect(entries).toHaveLength(2); + expect(entries.find(e => e.key === 'messages.greeting.other')!.value).toBe('Hello, {{.Name}}!'); + expect(entries.find(e => e.key === 'messages.farewell.other')!.value).toBe('Goodbye'); + }); + }); + + describe('reconstruct()', () => { + it('should replace values with translations', () => { + const content = 'greeting = "Hello"\nfarewell = "Goodbye"\n'; + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hallo' }, + { key: 'farewell', value: 'Goodbye', translation: 'Tschüss' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result).toContain('Hallo'); + expect(result).toContain('Tschüss'); + }); + + it('should remove deleted keys', () => { + const content = 'greeting = "Hello"\nfarewell = "Goodbye"\n'; + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hallo' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result).toContain('Hallo'); + expect(result).not.toContain('farewell'); + }); + + it('should preserve trailing newline', () => { + const content = 'greeting = "Hello"\n'; + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hallo' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result.endsWith('\n')).toBe(true); + }); + + it('should handle nested tables', () => { + const content = '[nav]\nhome = "Home"\n'; + const entries: TranslatedEntry[] = [ + { key: 'nav.home', value: 'Home', translation: 'Startseite' }, + ]; + const result = parser.reconstruct(content, entries); + expect(result).toContain('Startseite'); + }); + + it('should return empty string for empty content', () => { + expect(parser.reconstruct('', [])).toBe(''); + }); + }); + + describe('span-surgical reconstruct', () => { + it('preserves # comments when translating values', () => { + const content = [ + '# translator: keep this short', + 'greeting = "Hello"', + '', + '# another note', + 'farewell = "Goodbye"', + '', + ].join('\n'); + const result = parser.reconstruct(content, [ + { key: 'greeting', value: 'Hello', translation: 'Hallo' }, + { key: 'farewell', value: 'Goodbye', translation: 'Tschüss' }, + ]); + expect(result).toContain('# translator: keep this short'); + expect(result).toContain('# another note'); + expect(result).toContain('greeting = "Hallo"'); + expect(result).toContain('farewell = "Tschüss"'); + }); + + it('preserves blank lines between sections', () => { + const content = [ + '[home]', + 'title = "Home"', + '', + '', + '[about]', + 'title = "About"', + '', + ].join('\n'); + const result = parser.reconstruct(content, [ + { key: 'home.title', value: 'Home', translation: 'Startseite' }, + { key: 'about.title', value: 'About', translation: 'Über uns' }, + ]); + expect(result).toBe([ + '[home]', + 'title = "Startseite"', + '', + '', + '[about]', + 'title = "Über uns"', + '', + ].join('\n')); + }); + + it('preserves double-quoted vs literal (single-quoted) values per-entry', () => { + const content = [ + 'dq = "double"', + "lit = 'literal'", + '', + ].join('\n'); + const result = parser.reconstruct(content, [ + { key: 'dq', value: 'double', translation: 'doppel' }, + { key: 'lit', value: 'literal', translation: 'wörtlich' }, + ]); + expect(result).toContain('dq = "doppel"'); + expect(result).toContain("lit = 'wörtlich'"); + }); + + it('preserves key order within a section', () => { + const content = [ + '[nav]', + 'zebra = "Z"', + 'alpha = "A"', + 'mid = "M"', + '', + ].join('\n'); + const result = parser.reconstruct(content, [ + { key: 'nav.zebra', value: 'Z', translation: 'Zebra' }, + { key: 'nav.alpha', value: 'A', translation: 'Alpha' }, + { key: 'nav.mid', value: 'M', translation: 'Mitte' }, + ]); + const zIdx = result.indexOf('Zebra'); + const aIdx = result.indexOf('Alpha'); + const mIdx = result.indexOf('Mitte'); + expect(zIdx).toBeGreaterThanOrEqual(0); + expect(zIdx).toBeLessThan(aIdx); + expect(aIdx).toBeLessThan(mIdx); + }); + + it('preserves irregular whitespace around `=` and trailing comments', () => { + const content = 'key = "value" # trailing comment\n'; + const result = parser.reconstruct(content, [ + { key: 'key', value: 'value', translation: 'wert' }, + ]); + expect(result).toBe('key = "wert" # trailing comment\n'); + }); + + it('escapes double-quote translations correctly', () => { + const content = 'msg = "hi"\n'; + const result = parser.reconstruct(content, [ + { key: 'msg', value: 'hi', translation: 'She said "hello"' }, + ]); + expect(result).toBe('msg = "She said \\"hello\\""\n'); + }); + + it('falls back from literal to double-quoted when translation contains `\\\'`', () => { + const content = "msg = 'hi'\n"; + const result = parser.reconstruct(content, [ + { key: 'msg', value: 'hi', translation: "It's fine" }, + ]); + // Literal strings cannot contain apostrophes; falls back to double-quoted. + expect(result).toBe('msg = "It\'s fine"\n'); + }); + + it('passes non-string values through unchanged (numbers, bools, dates)', () => { + const content = [ + 'name = "Test"', + 'count = 5', + 'enabled = true', + 'shipped = 2024-01-15', + '', + ].join('\n'); + const result = parser.reconstruct(content, [ + { key: 'name', value: 'Test', translation: 'Testen' }, + ]); + expect(result).toContain('name = "Testen"'); + expect(result).toContain('count = 5'); + expect(result).toContain('enabled = true'); + expect(result).toContain('shipped = 2024-01-15'); + }); + + it('deletes entries whose keys are missing from the translation map (with preceding comments)', () => { + const content = [ + 'keep = "X"', + '# comment for remove', + 'remove = "Y"', + 'also_keep = "Z"', + '', + ].join('\n'); + const result = parser.reconstruct(content, [ + { key: 'keep', value: 'X', translation: 'x' }, + { key: 'also_keep', value: 'Z', translation: 'z' }, + ]); + expect(result).not.toContain('remove'); + expect(result).not.toContain('# comment for remove'); + expect(result).toContain('keep = "x"'); + expect(result).toContain('also_keep = "z"'); + }); + + it('appends new keys at end when translations contain keys not in source', () => { + const content = 'existing = "A"\n'; + const result = parser.reconstruct(content, [ + { key: 'existing', value: 'A', translation: 'a' }, + { key: 'brand_new', value: 'new', translation: 'neu' }, + ]); + expect(result).toContain('existing = "a"'); + expect(result).toContain('brand_new = "neu"'); + // New key appears AFTER the existing entry, not before. + expect(result.indexOf('existing')).toBeLessThan(result.indexOf('brand_new')); + }); + + it('is byte-equal when reconstruct keeps values identical to extract', () => { + const content = [ + '# Root comment', + 'greeting = "Hello"', + '', + '[nav]', + '# Inside-section comment', + 'home = "Home" # trailing', + 'back = "Back"', + '', + '[messages.greeting]', + 'other = "Welcome, {{.Name}}!"', + '', + ].join('\n'); + const extracted = parser.extract(content); + const identity = extracted.map((e) => ({ ...e, translation: e.value })); + expect(parser.reconstruct(content, identity)).toBe(content); + }); + + it('preserves surrounding bytes verbatim when only one value is translated (span-surgical)', () => { + const content = [ + '# keep-me', + 'one = "1"', + '', + '# also-keep', + 'two = "2"', + '', + ].join('\n'); + const result = parser.reconstruct(content, [ + { key: 'one', value: '1', translation: 'uno' }, + { key: 'two', value: '2', translation: '2' }, // unchanged + ]); + // Byte-delta equals the one replacement's length change: '"1"' → '"uno"' is +2 chars. + expect(result.length).toBe(content.length + ('uno'.length - '1'.length)); + expect(result).toContain('# keep-me'); + expect(result).toContain('# also-keep'); + expect(result).toContain('one = "uno"'); + expect(result).toContain('two = "2"'); + }); + + it('passes through multi-line triple-quoted strings without attempting translation', () => { + const content = [ + 'one = "first"', + 'multi = """', + 'line 1', + 'line 2', + '"""', + 'two = "last"', + '', + ].join('\n'); + const result = parser.reconstruct(content, [ + { key: 'one', value: 'first', translation: 'erste' }, + { key: 'two', value: 'last', translation: 'letzte' }, + ]); + expect(result).toContain('one = "erste"'); + expect(result).toContain('two = "letzte"'); + // Multi-line block preserved verbatim. + expect(result).toContain('multi = """'); + expect(result).toContain('line 1'); + expect(result).toContain('line 2'); + }); + }); +}); diff --git a/tests/unit/formats/xcstrings.test.ts b/tests/unit/formats/xcstrings.test.ts new file mode 100644 index 0000000..b4f24eb --- /dev/null +++ b/tests/unit/formats/xcstrings.test.ts @@ -0,0 +1,221 @@ +import { XcstringsFormatParser } from '../../../src/formats/xcstrings'; +import type { TranslatedEntry } from '../../../src/formats/format'; + +const parser = new XcstringsFormatParser(); + +const SAMPLE = JSON.stringify({ + sourceLanguage: 'en', + version: '1.0', + strings: { + greeting: { + comment: 'Welcome screen title', + localizations: { + en: { stringUnit: { state: 'translated', value: 'Hello' } }, + de: { stringUnit: { state: 'translated', value: 'Hallo' } }, + }, + }, + farewell: { + localizations: { + en: { stringUnit: { state: 'translated', value: 'Goodbye' } }, + }, + }, + }, +}, null, 2) + '\n'; + +describe('XcstringsFormatParser', () => { + it('should have multiLocale set to true', () => { + expect(parser.multiLocale).toBe(true); + }); + + describe('extract()', () => { + it('should extract entries for a specific locale', () => { + const result = parser.extract(SAMPLE, 'en'); + expect(result).toHaveLength(2); + expect(result.find(e => e.key === 'farewell')!.value).toBe('Goodbye'); + expect(result.find(e => e.key === 'greeting')!.value).toBe('Hello'); + }); + + it('should return empty array when locale is not provided', () => { + const result = parser.extract(SAMPLE); + expect(result).toEqual([]); + }); + + it('should return empty array when locale has no localizations', () => { + const result = parser.extract(SAMPLE, 'fr'); + expect(result).toEqual([]); + }); + + it('should extract entries for a target locale', () => { + const result = parser.extract(SAMPLE, 'de'); + expect(result).toHaveLength(1); + expect(result[0]!.key).toBe('greeting'); + expect(result[0]!.value).toBe('Hallo'); + }); + + it('should preserve comment as context and metadata', () => { + const result = parser.extract(SAMPLE, 'en'); + const greeting = result.find(e => e.key === 'greeting'); + expect(greeting!.context).toBe('Welcome screen title'); + expect(greeting!.metadata).toEqual({ comment: 'Welcome screen title' }); + }); + + it('should not set context when no comment exists', () => { + const result = parser.extract(SAMPLE, 'en'); + const farewell = result.find(e => e.key === 'farewell'); + expect(farewell!.context).toBeUndefined(); + expect(farewell!.metadata).toBeUndefined(); + }); + + it('should return entries in insertion order (consumers sort downstream)', () => { + const content = JSON.stringify({ + sourceLanguage: 'en', + version: '1.0', + strings: { + zebra: { localizations: { en: { stringUnit: { state: 'translated', value: 'Z' } } } }, + apple: { localizations: { en: { stringUnit: { state: 'translated', value: 'A' } } } }, + mango: { localizations: { en: { stringUnit: { state: 'translated', value: 'M' } } } }, + }, + }); + const result = parser.extract(content, 'en'); + expect(result.map(e => e.key).sort()).toEqual(['apple', 'mango', 'zebra']); + }); + + it('should handle empty strings object', () => { + const content = JSON.stringify({ sourceLanguage: 'en', version: '1.0', strings: {} }); + const result = parser.extract(content, 'en'); + expect(result).toEqual([]); + }); + + it('should skip keys with missing stringUnit', () => { + const content = JSON.stringify({ + sourceLanguage: 'en', + version: '1.0', + strings: { + has_unit: { localizations: { en: { stringUnit: { state: 'translated', value: 'Yes' } } } }, + no_unit: { localizations: { en: {} } }, + }, + }); + const result = parser.extract(content, 'en'); + expect(result).toHaveLength(1); + expect(result[0]!.key).toBe('has_unit'); + }); + }); + + describe('reconstruct()', () => { + it('should add translations for a locale', () => { + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Bonjour' }, + { key: 'farewell', value: 'Goodbye', translation: 'Au revoir' }, + ]; + const result = parser.reconstruct(SAMPLE, entries, 'fr'); + const data = JSON.parse(result); + expect(data.strings.greeting.localizations.fr.stringUnit.value).toBe('Bonjour'); + expect(data.strings.farewell.localizations.fr.stringUnit.value).toBe('Au revoir'); + }); + + it('should set state to translated', () => { + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hola' }, + ]; + const result = parser.reconstruct(SAMPLE, entries, 'es'); + const data = JSON.parse(result); + expect(data.strings.greeting.localizations.es.stringUnit.state).toBe('translated'); + }); + + it('should preserve existing localizations for other locales', () => { + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Bonjour' }, + ]; + const result = parser.reconstruct(SAMPLE, entries, 'fr'); + const data = JSON.parse(result); + expect(data.strings.greeting.localizations.en.stringUnit.value).toBe('Hello'); + expect(data.strings.greeting.localizations.de.stringUnit.value).toBe('Hallo'); + expect(data.strings.greeting.localizations.fr.stringUnit.value).toBe('Bonjour'); + }); + + it('should create localizations structure when absent', () => { + const content = JSON.stringify({ + sourceLanguage: 'en', + version: '1.0', + strings: { new_key: {} }, + }); + const entries: TranslatedEntry[] = [ + { key: 'new_key', value: 'New', translation: 'Neu' }, + ]; + const result = parser.reconstruct(content, entries, 'de'); + const data = JSON.parse(result); + expect(data.strings.new_key.localizations.de.stringUnit.value).toBe('Neu'); + }); + + it('should preserve comments on string definitions', () => { + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hola' }, + ]; + const result = parser.reconstruct(SAMPLE, entries, 'es'); + const data = JSON.parse(result); + expect(data.strings.greeting.comment).toBe('Welcome screen title'); + }); + + it('should preserve sourceLanguage and version', () => { + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hola' }, + ]; + const result = parser.reconstruct(SAMPLE, entries, 'es'); + const data = JSON.parse(result); + expect(data.sourceLanguage).toBe('en'); + expect(data.version).toBe('1.0'); + }); + + it('should preserve indentation style', () => { + const tabIndented = JSON.stringify({ sourceLanguage: 'en', version: '1.0', strings: {} }, null, '\t') + '\n'; + const entries: TranslatedEntry[] = []; + const result = parser.reconstruct(tabIndented, entries, 'de'); + expect(result).toContain('\t'); + }); + + it('should preserve trailing newline', () => { + const withNewline = JSON.stringify({ sourceLanguage: 'en', version: '1.0', strings: {} }, null, 2) + '\n'; + const result = parser.reconstruct(withNewline, [], 'de'); + expect(result.endsWith('\n')).toBe(true); + }); + + it('should return content unchanged when locale is not provided', () => { + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hola' }, + ]; + const result = parser.reconstruct(SAMPLE, entries); + expect(result).toBe(SAMPLE); + }); + }); + + describe('round-trip', () => { + it('should extract translations after reconstruct', () => { + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Bonjour' }, + { key: 'farewell', value: 'Goodbye', translation: 'Au revoir' }, + ]; + const reconstructed = parser.reconstruct(SAMPLE, entries, 'fr'); + const extracted = parser.extract(reconstructed, 'fr'); + + expect(extracted).toHaveLength(2); + expect(extracted.find(e => e.key === 'greeting')!.value).toBe('Bonjour'); + expect(extracted.find(e => e.key === 'farewell')!.value).toBe('Au revoir'); + }); + + it('should chain reconstructs for multiple locales', () => { + let content = SAMPLE; + content = parser.reconstruct(content, [ + { key: 'greeting', value: 'Hello', translation: 'Hola' }, + ], 'es'); + content = parser.reconstruct(content, [ + { key: 'greeting', value: 'Hello', translation: 'Bonjour' }, + ], 'fr'); + + const data = JSON.parse(content); + expect(data.strings.greeting.localizations.en.stringUnit.value).toBe('Hello'); + expect(data.strings.greeting.localizations.de.stringUnit.value).toBe('Hallo'); + expect(data.strings.greeting.localizations.es.stringUnit.value).toBe('Hola'); + expect(data.strings.greeting.localizations.fr.stringUnit.value).toBe('Bonjour'); + }); + }); +}); diff --git a/tests/unit/formats/xliff.test.ts b/tests/unit/formats/xliff.test.ts new file mode 100644 index 0000000..2853265 --- /dev/null +++ b/tests/unit/formats/xliff.test.ts @@ -0,0 +1,316 @@ +import { createDefaultRegistry } from '../../../src/formats/index'; +import { XliffFormatParser } from '../../../src/formats/xliff'; +import type { TranslatedEntry } from '../../../src/formats/format'; + +const parser = new XliffFormatParser(); + +describe('xliff parser', () => { + it('should be registered in the default registry', async () => { + const registry = await createDefaultRegistry(); + const extensions = registry.getSupportedExtensions(); + expect(extensions.length).toBeGreaterThan(0); + }); + + describe('reconstruct removes deleted keys', () => { + it('should remove trans-units not in entries for v1.2', () => { + const xliff = ` + + + + + Hello + Hallo + + + Goodbye + Tschüss + + + Remove me + Entferne mich + + + +`; + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hallo' }, + { key: 'farewell', value: 'Goodbye', translation: 'Tschüss' }, + ]; + const result = parser.reconstruct(xliff, entries); + expect(result).toContain('trans-unit id="greeting"'); + expect(result).toContain('trans-unit id="farewell"'); + expect(result).not.toContain('trans-unit id="deleted"'); + expect(result).not.toContain('Remove me'); + }); + + it('should remove units not in entries for v2.0', () => { + const xliff = ` + + + + + Hello + Hallo + + + + + Remove me + Entferne mich + + + +`; + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hallo' }, + ]; + const result = parser.reconstruct(xliff, entries); + expect(result).toContain('unit id="greeting"'); + expect(result).not.toContain('unit id="deleted"'); + expect(result).not.toContain('Remove me'); + }); + }); + + describe('namespace-prefixed elements', () => { + it('should extract from XLIFF v2.0 with namespace-prefixed elements', () => { + const xliff = ` + + + + + Hello + Hallo + + + +`; + const entries = parser.extract(xliff); + expect(entries).toHaveLength(1); + expect(entries[0]!.key).toBe('greeting'); + expect(entries[0]!.value).toBe('Hello'); + }); + + it('should reconstruct XLIFF v2.0 with namespace-prefixed elements', () => { + const xliff = ` + + + + + Hello + Hallo + + + +`; + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Bonjour' }, + ]; + const result = parser.reconstruct(xliff, entries); + expect(result).toContain('Bonjour'); + expect(result).toContain('xliff:unit id="greeting"'); + }); + + it('should extract from XLIFF v1.2 with namespace-prefixed elements', () => { + const xliff = ` + + + + + Welcome + Willkommen + + + +`; + const entries = parser.extract(xliff); + expect(entries).toHaveLength(1); + expect(entries[0]!.key).toBe('msg1'); + expect(entries[0]!.value).toBe('Welcome'); + }); + + it('should reconstruct XLIFF v1.2 with namespace-prefixed elements', () => { + const xliff = ` + + + + + Welcome + Willkommen + + + +`; + const entries: TranslatedEntry[] = [ + { key: 'msg1', value: 'Welcome', translation: 'Bienvenue' }, + ]; + const result = parser.reconstruct(xliff, entries); + expect(result).toContain('Bienvenue'); + expect(result).toContain('x:trans-unit id="msg1"'); + }); + }); + + describe('reconstruct v2.0 full coverage', () => { + it('should apply translations to v2.0 with existing target', () => { + const xliff = ` + + + + + Hello + Bonjour + + + +`; + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Salut' }, + ]; + const result = parser.reconstruct(xliff, entries); + expect(result).toContain('Salut'); + expect(result).not.toContain('Bonjour'); + }); + + it('should insert missing elements in v2.0', () => { + const xliff = ` + + + + + Hello + + + +`; + const entries: TranslatedEntry[] = [ + { key: 'msg1', value: 'Hello', translation: 'Hallo' }, + ]; + const result = parser.reconstruct(xliff, entries); + expect(result).toContain('Hallo'); + expect(result).toContain('Hello'); + }); + + it('should remove deleted units in v2.0', () => { + const xliff = ` + + + + + Keep + Behalten + + + + + Remove + Entfernen + + + +`; + const entries: TranslatedEntry[] = [ + { key: 'kept', value: 'Keep', translation: 'Behalten' }, + ]; + const result = parser.reconstruct(xliff, entries); + expect(result).toContain('unit id="kept"'); + expect(result).not.toContain('unit id="removed"'); + expect(result).not.toContain('Entfernen'); + }); + }); + + describe('reconstruct v1.2 missing target insertion', () => { + it('should insert when missing in v1.2', () => { + const xliff = ` + + + + + Welcome + + + +`; + const entries: TranslatedEntry[] = [ + { key: 'msg1', value: 'Welcome', translation: 'Willkommen' }, + ]; + const result = parser.reconstruct(xliff, entries); + expect(result).toContain('Willkommen'); + expect(result).toContain('Welcome'); + }); + }); + + describe('reconstruct with $-patterns in translations', () => { + it('should preserve literal $1 and $& in v1.2 target replacement', () => { + const xliff = ` + + + + + Price + Preis + + + +`; + const entries: TranslatedEntry[] = [ + { key: 'price', value: 'Price', translation: 'Costs $1 and $& more' }, + ]; + const result = parser.reconstruct(xliff, entries); + expect(result).toContain('Costs $1 and $& more'); + }); + + it('should preserve literal $1 and $& in v1.2 when inserting new target', () => { + const xliff = ` + + + + + Price + + + +`; + const entries: TranslatedEntry[] = [ + { key: 'price', value: 'Price', translation: 'Pay $1 now $& later' }, + ]; + const result = parser.reconstruct(xliff, entries); + expect(result).toContain('Pay $1 now $& later'); + }); + + it('should preserve literal $1 and $& in v2.0 target replacement', () => { + const xliff = ` + + + + + Price + Preis + + + +`; + const entries: TranslatedEntry[] = [ + { key: 'price', value: 'Price', translation: 'Costs $1 and $& more' }, + ]; + const result = parser.reconstruct(xliff, entries); + expect(result).toContain('Costs $1 and $& more'); + }); + + it('should preserve literal $1 and $& in v2.0 when inserting new target', () => { + const xliff = ` + + + + + Price + + + +`; + const entries: TranslatedEntry[] = [ + { key: 'price', value: 'Price', translation: 'Pay $1 now $& later' }, + ]; + const result = parser.reconstruct(xliff, entries); + expect(result).toContain('Pay $1 now $& later'); + }); + }); +}); diff --git a/tests/unit/formats/yaml.test.ts b/tests/unit/formats/yaml.test.ts new file mode 100644 index 0000000..f52693d --- /dev/null +++ b/tests/unit/formats/yaml.test.ts @@ -0,0 +1,369 @@ +import { createDefaultRegistry } from '../../../src/formats/index'; +import { YamlFormatParser } from '../../../src/formats/yaml'; +import type { TranslatedEntry } from '../../../src/formats/format'; + +const parser = new YamlFormatParser(); + +describe('yaml parser', () => { + it('should be registered in the default registry', async () => { + const registry = await createDefaultRegistry(); + const extensions = registry.getSupportedExtensions(); + expect(extensions.length).toBeGreaterThan(0); + }); + + describe('reconstruct with empty content', () => { + it('should return empty string without throwing', () => { + const result = parser.reconstruct('', []); + expect(result).toBe(''); + }); + + it('should return empty string for whitespace-only content', () => { + const result = parser.reconstruct(' \n ', []); + expect(result).toBe(''); + }); + }); + + describe('null-byte key separator', () => { + it('should extract a key with a literal dot as a flat key', () => { + const yaml = '"my.key": value\n'; + const entries = parser.extract(yaml); + expect(entries).toHaveLength(1); + expect(entries[0]!.key).toBe('my.key'); + expect(entries[0]!.value).toBe('value'); + }); + + it('should round-trip a key with a literal dot without nesting', () => { + const yaml = '"my.key": value\n'; + const entries = parser.extract(yaml); + const translated: TranslatedEntry[] = [ + { key: entries[0]!.key, value: 'value', translation: 'translated' }, + ]; + const result = parser.reconstruct(yaml, translated); + expect(result).toContain('"my.key"'); + expect(result).toContain('translated'); + expect(result).not.toMatch(/^my:\n/m); + }); + + it('should extract nested keys with null-byte separator', () => { + const yaml = 'nav:\n home: value\n'; + const entries = parser.extract(yaml); + expect(entries).toHaveLength(1); + expect(entries[0]!.key).toBe('nav\0home'); + expect(entries[0]!.value).toBe('value'); + }); + + it('should reconstruct nested keys from null-byte separated paths', () => { + const yaml = 'nav:\n home: value\n'; + const entries: TranslatedEntry[] = [ + { key: 'nav\0home', value: 'value', translation: 'Inicio' }, + ]; + const result = parser.reconstruct(yaml, entries); + expect(result).toContain('nav'); + expect(result).toContain('home'); + expect(result).toContain('Inicio'); + }); + }); + + describe('array values', () => { + it('should extract string array items with index-based keys', () => { + const yaml = 'items:\n - hello\n - world\n'; + const entries = parser.extract(yaml); + expect(entries).toHaveLength(2); + expect(entries[0]!.key).toBe('items\x000'); + expect(entries[0]!.value).toBe('hello'); + expect(entries[1]!.key).toBe('items\x001'); + expect(entries[1]!.value).toBe('world'); + }); + + it('should round-trip array values preserving structure', () => { + const yaml = 'items:\n - hello\n - world\n'; + const entries: TranslatedEntry[] = [ + { key: 'items\x000', value: 'hello', translation: 'hola' }, + { key: 'items\x001', value: 'world', translation: 'mundo' }, + ]; + const result = parser.reconstruct(yaml, entries); + expect(result).toContain('- hola'); + expect(result).toContain('- mundo'); + expect(result).toContain('items:'); + }); + + it('should skip non-string array items', () => { + const yaml = 'mixed:\n - hello\n - 42\n - true\n'; + const entries = parser.extract(yaml); + expect(entries).toHaveLength(1); + expect(entries[0]!.key).toBe('mixed\x000'); + expect(entries[0]!.value).toBe('hello'); + }); + + it('should walk into object items within arrays', () => { + const yaml = 'list:\n - name: Alice\n role: admin\n'; + const entries = parser.extract(yaml); + expect(entries).toHaveLength(2); + const keys = entries.map(e => e.key); + expect(keys).toContain('list\x000\x00name'); + expect(keys).toContain('list\x000\x00role'); + }); + }); + + describe('anchor/alias handling', () => { + it('should extract both anchor and alias entries with correct values', () => { + const yaml = 'base: &anchor "hello"\nalias: *anchor\n'; + const entries = parser.extract(yaml); + expect(entries).toHaveLength(2); + const baseEntry = entries.find(e => e.key === 'base'); + const aliasEntry = entries.find(e => e.key === 'alias'); + expect(baseEntry).toBeDefined(); + expect(baseEntry!.value).toBe('hello'); + expect(aliasEntry).toBeDefined(); + expect(aliasEntry!.value).toBe('hello'); + }); + + it('should reconstruct with translations preserving YAML structure', () => { + const yaml = 'base: &anchor "hello"\nalias: *anchor\n'; + const entries: TranslatedEntry[] = [ + { key: 'base', value: 'hello', translation: 'hola' }, + { key: 'alias', value: 'hello', translation: 'hola' }, + ]; + const result = parser.reconstruct(yaml, entries); + expect(result).toContain('base'); + expect(result).toContain('alias'); + expect(result).toContain('hola'); + }); + }); + + describe('reconstruct removes deleted keys', () => { + it('should not include keys absent from translation entries', () => { + const template = [ + 'greeting: Hello', + 'farewell: Goodbye', + 'deleted_key: Remove me', + ].join('\n'); + + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hola' }, + { key: 'farewell', value: 'Goodbye', translation: 'Adiós' }, + ]; + + const result = parser.reconstruct(template, entries); + expect(result).toContain('greeting'); + expect(result).toContain('farewell'); + expect(result).not.toContain('deleted_key'); + expect(result).not.toContain('Remove me'); + }); + + it('should remove nested deleted keys', () => { + const template = [ + 'nav:', + ' home: Home', + ' about: About', + ' removed: Gone', + ].join('\n'); + + const entries: TranslatedEntry[] = [ + { key: 'nav\0home', value: 'Home', translation: 'Inicio' }, + { key: 'nav\0about', value: 'About', translation: 'Acerca' }, + ]; + + const result = parser.reconstruct(template, entries); + expect(result).toContain('home'); + expect(result).toContain('about'); + expect(result).not.toContain('removed'); + expect(result).not.toContain('Gone'); + }); + }); + + describe('extract edge cases', () => { + it('should return empty array for empty content', () => { + expect(parser.extract('')).toEqual([]); + }); + + it('should return empty array for whitespace-only content', () => { + expect(parser.extract(' \n ')).toEqual([]); + }); + + it('should return empty array when document has no contents (comment-only YAML)', () => { + const yaml = '# just a comment\n'; + const entries = parser.extract(yaml); + expect(entries).toEqual([]); + }); + + it('should throw on YAML parse errors', () => { + const yaml = 'key: [invalid: yaml: :::'; + expect(() => parser.extract(yaml)).toThrow('YAML parse error'); + }); + + it('should skip non-string scalar values', () => { + const yaml = 'name: Hello\ncount: 42\nflag: true\nnull_val: null\n'; + const entries = parser.extract(yaml); + expect(entries).toHaveLength(1); + expect(entries[0]!.key).toBe('name'); + }); + }); + + describe('walkNode with sequences', () => { + it('should walk into nested sequences within sequences', () => { + const yaml = 'matrix:\n - - a\n - b\n - - c\n'; + const entries = parser.extract(yaml); + const keys = entries.map(e => e.key); + expect(keys).toContain('matrix\x000\x000'); + expect(keys).toContain('matrix\x000\x001'); + expect(keys).toContain('matrix\x001\x000'); + }); + + it('should walk into map nodes inside sequences', () => { + const yaml = 'items:\n - title: First\n - title: Second\n'; + const entries = parser.extract(yaml); + expect(entries).toHaveLength(2); + const keys = entries.map(e => e.key); + expect(keys).toContain('items\x000\x00title'); + expect(keys).toContain('items\x001\x00title'); + }); + }); + + describe('walkNode with aliases resolving to maps/seqs', () => { + it('should extract entries from alias that resolves to a map', () => { + const yaml = 'defaults: &defaults\n color: red\n size: large\ntheme: *defaults\n'; + const entries = parser.extract(yaml); + const keys = entries.map(e => e.key); + expect(keys).toContain('defaults\0color'); + expect(keys).toContain('defaults\0size'); + expect(keys).toContain('theme\0color'); + expect(keys).toContain('theme\0size'); + }); + + it('should extract entries from alias in a sequence that resolves to a map', () => { + const yaml = 'base: &base\n x: hello\nitems:\n - *base\n'; + const entries = parser.extract(yaml); + const keys = entries.map(e => e.key); + expect(keys).toContain('base\0x'); + expect(keys).toContain('items\x000\x00x'); + }); + }); + + describe('reconstruct walkDoc with sequences', () => { + it('should delete sequence string items absent from translations', () => { + const yaml = 'items:\n - keep\n - remove\n'; + const entries: TranslatedEntry[] = [ + { key: 'items\x000', value: 'keep', translation: 'kept' }, + ]; + const result = parser.reconstruct(yaml, entries); + expect(result).toContain('kept'); + expect(result).not.toContain('remove'); + }); + + it('should walk into map nodes inside sequences during reconstruct', () => { + const yaml = 'list:\n - name: Alice\n - name: Bob\n'; + const entries: TranslatedEntry[] = [ + { key: 'list\x000\x00name', value: 'Alice', translation: 'Alicia' }, + ]; + const result = parser.reconstruct(yaml, entries); + expect(result).toContain('Alicia'); + expect(result).not.toContain('Bob'); + }); + + it('should walk into nested sequences during reconstruct', () => { + const yaml = 'matrix:\n - - a\n - b\n'; + const entries: TranslatedEntry[] = [ + { key: 'matrix\x000\x000', value: 'a', translation: 'x' }, + { key: 'matrix\x000\x001', value: 'b', translation: 'y' }, + ]; + const result = parser.reconstruct(yaml, entries); + expect(result).toContain('x'); + expect(result).toContain('y'); + }); + }); + + describe('reconstruct walkDoc with aliases', () => { + it('should handle alias resolving to a scalar in walkDoc for deletion', () => { + const yaml = 'base: &anchor hello\nalias: *anchor\nremoved: *anchor\n'; + const entries: TranslatedEntry[] = [ + { key: 'base', value: 'hello', translation: 'hola' }, + { key: 'alias', value: 'hello', translation: 'hola' }, + ]; + const result = parser.reconstruct(yaml, entries); + expect(result).toContain('hola'); + expect(result).not.toContain('removed'); + }); + + it('should walk alias resolving to scalar in map value during walkDoc', () => { + const yaml = 'base: &anchor hello\nkept: world\nalias_ref: *anchor\n'; + const entries: TranslatedEntry[] = [ + { key: 'kept', value: 'world', translation: 'mundo' }, + ]; + const result = parser.reconstruct(yaml, entries); + expect(result).toContain('mundo'); + }); + + it('should walk alias resolving to scalar in map value and delete it', () => { + const yaml = 'base: &anchor hello\nkept: world\nalias1: *anchor\nalias2: *anchor\n'; + const entries: TranslatedEntry[] = [ + { key: 'kept', value: 'world', translation: 'mundo' }, + ]; + const result = parser.reconstruct(yaml, entries); + expect(result).toContain('mundo'); + expect(result).not.toContain('alias1'); + expect(result).not.toContain('alias2'); + }); + + it('should handle alias in sequence resolving to scalar in walkDoc', () => { + const yaml = 'base: &val hello\nitems:\n - *val\n - world\n'; + const entries: TranslatedEntry[] = [ + { key: 'base', value: 'hello', translation: 'hola' }, + { key: 'items\x000', value: 'hello', translation: 'hola' }, + ]; + const result = parser.reconstruct(yaml, entries); + expect(result).toContain('hola'); + expect(result).not.toContain('world'); + }); + + it('should handle alias in sequence resolving to scalar in extract', () => { + const yaml = 'base: &val hello\nitems:\n - *val\n - extra\n'; + const entries = parser.extract(yaml); + const keys = entries.map(e => e.key); + expect(keys).toContain('base'); + expect(keys).toContain('items\x000'); + expect(keys).toContain('items\x001'); + expect(entries.find(e => e.key === 'items\x000')!.value).toBe('hello'); + }); + + it('should walk alias resolving to scalar in seq during walkDoc and delete it', () => { + const yaml = 'base: &val hello\nkept: world\nitems:\n - *val\n'; + const entries: TranslatedEntry[] = [ + { key: 'kept', value: 'world', translation: 'mundo' }, + ]; + const result = parser.reconstruct(yaml, entries); + expect(result).toContain('mundo'); + expect(result).not.toContain('*val'); + }); + }); + + describe('reconstruct trailing newline handling', () => { + it('should strip trailing newline if original does not end with one', () => { + const yaml = 'greeting: Hello'; + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hola' }, + ]; + const result = parser.reconstruct(yaml, entries); + expect(result).not.toMatch(/\n$/); + expect(result).toContain('Hola'); + }); + + it('should add trailing newline if original ends with one', () => { + const yaml = 'greeting: Hello\n'; + const entries: TranslatedEntry[] = [ + { key: 'greeting', value: 'Hello', translation: 'Hola' }, + ]; + const result = parser.reconstruct(yaml, entries); + expect(result).toMatch(/\n$/); + }); + }); + + describe('reconstruct with non-scalar map keys', () => { + it('should stringify non-scalar map keys', () => { + const yaml = '123: numeric key value\n'; + const entries = parser.extract(yaml); + expect(entries).toHaveLength(1); + expect(entries[0]!.value).toBe('numeric key value'); + }); + }); +}); diff --git a/tests/unit/glossary-client.test.ts b/tests/unit/glossary-client.test.ts index c209b24..8ca756c 100644 --- a/tests/unit/glossary-client.test.ts +++ b/tests/unit/glossary-client.test.ts @@ -192,6 +192,24 @@ describe('GlossaryClient', () => { expect(result).toEqual([]); }); + + it('suffixes thrown errors with the [listGlossaries] method context', async () => { + const axiosError = Object.assign(new Error('Request failed'), { + isAxiosError: true, + response: { status: 403, data: { message: 'Invalid API key' }, headers: {} }, + config: {}, + }); + mockAxiosInstance.request.mockRejectedValue(axiosError); + + let thrown: unknown; + try { + await client.listGlossaries(); + } catch (err) { + thrown = err; + } + expect(thrown).toBeInstanceOf(Error); + expect((thrown as Error).message).toMatch(/\[listGlossaries\]$/); + }); }); describe('getGlossary()', () => { diff --git a/tests/unit/glossary-service.test.ts b/tests/unit/glossary-service.test.ts index 5a3a3a1..2ffabd7 100644 --- a/tests/unit/glossary-service.test.ts +++ b/tests/unit/glossary-service.test.ts @@ -5,6 +5,7 @@ import { GlossaryService } from '../../src/services/glossary'; import { DeepLClient } from '../../src/api/deepl-client'; +import { ConfigError } from '../../src/utils/errors'; import { createMockDeepLClient } from '../helpers/mock-factories'; // Mock DeepLClient @@ -316,6 +317,243 @@ describe('GlossaryService', () => { expect(result).toBe(uppercaseUuid); expect(mockDeepLClient.listGlossaries).not.toHaveBeenCalled(); }); + + it('should emit a verbose log with resolved glossary name -> UUID after name lookup', async () => { + mockDeepLClient.listGlossaries.mockResolvedValue([ + { + glossary_id: 'resolved-glossary-id', + name: 'tech-terms', + source_lang: 'en', + target_langs: ['es'], + dictionaries: [{ source_lang: 'en', target_lang: 'es', entry_count: 1 }], + creation_time: '2024-01-01T00:00:00Z', + }, + ]); + const { Logger } = jest.requireActual('../../src/utils/logger'); + const spy = jest.spyOn(Logger, 'verbose').mockImplementation(() => {}); + + await glossaryService.resolveGlossaryId('tech-terms'); + + expect(spy).toHaveBeenCalledWith( + expect.stringContaining('Resolved glossary "tech-terms" -> resolved-glossary-id') + ); + spy.mockRestore(); + }); + + describe('session-scoped cache', () => { + const TECH_TERMS = { + glossary_id: 'resolved-glossary-id', + name: 'tech-terms', + source_lang: 'en' as const, + target_langs: ['es' as const], + dictionaries: [{ source_lang: 'en' as const, target_lang: 'es' as const, entry_count: 1 }], + creation_time: '2024-01-01T00:00:00Z', + }; + + it('calls listGlossaries exactly once across repeat resolutions of the same name', async () => { + mockDeepLClient.listGlossaries.mockResolvedValue([TECH_TERMS]); + + const first = await glossaryService.resolveGlossaryId('tech-terms'); + const second = await glossaryService.resolveGlossaryId('tech-terms'); + const third = await glossaryService.resolveGlossaryId('tech-terms'); + + expect(first).toBe('resolved-glossary-id'); + expect(second).toBe('resolved-glossary-id'); + expect(third).toBe('resolved-glossary-id'); + expect(mockDeepLClient.listGlossaries).toHaveBeenCalledTimes(1); + }); + + it('emits a cache-hit verbose log on repeat resolutions', async () => { + mockDeepLClient.listGlossaries.mockResolvedValue([TECH_TERMS]); + const { Logger } = jest.requireActual('../../src/utils/logger'); + const spy = jest.spyOn(Logger, 'verbose').mockImplementation(() => {}); + + await glossaryService.resolveGlossaryId('tech-terms'); + spy.mockClear(); + await glossaryService.resolveGlossaryId('tech-terms'); + + expect(spy).toHaveBeenCalledWith( + expect.stringContaining('Glossary cache hit: "tech-terms" -> resolved-glossary-id'), + ); + spy.mockRestore(); + }); + + it('invalidates the cache on createGlossary so a stale name→id does not linger', async () => { + mockDeepLClient.listGlossaries.mockResolvedValue([TECH_TERMS]); + mockDeepLClient.createGlossary.mockResolvedValue({ + ...TECH_TERMS, + glossary_id: 'new-glossary-id', + }); + + await glossaryService.resolveGlossaryId('tech-terms'); + await glossaryService.createGlossary('other', 'en', ['es'], { a: 'b' }); + await glossaryService.resolveGlossaryId('tech-terms'); + + expect(mockDeepLClient.listGlossaries).toHaveBeenCalledTimes(2); + }); + + it('invalidates the cache on deleteGlossary', async () => { + mockDeepLClient.listGlossaries.mockResolvedValue([TECH_TERMS]); + mockDeepLClient.deleteGlossary.mockResolvedValue(undefined); + + await glossaryService.resolveGlossaryId('tech-terms'); + await glossaryService.deleteGlossary('resolved-glossary-id'); + await glossaryService.resolveGlossaryId('tech-terms'); + + expect(mockDeepLClient.listGlossaries).toHaveBeenCalledTimes(2); + }); + + it('UUID fast-path does not populate or consult the cache', async () => { + const uuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + + await glossaryService.resolveGlossaryId(uuid); + await glossaryService.resolveGlossaryId(uuid); + + expect(mockDeepLClient.listGlossaries).not.toHaveBeenCalled(); + }); + }); + + describe('shared list cache', () => { + const TECH_TERMS = { + glossary_id: 'resolved-glossary-id', + name: 'tech-terms', + source_lang: 'en' as const, + target_langs: ['es' as const], + dictionaries: [{ source_lang: 'en' as const, target_lang: 'es' as const, entry_count: 1 }], + creation_time: '2024-01-01T00:00:00Z', + }; + + it('getGlossaryByName consults the session list cache so N successive lookups issue 1 list call', async () => { + mockDeepLClient.listGlossaries.mockResolvedValue([TECH_TERMS]); + + const first = await glossaryService.getGlossaryByName('tech-terms'); + const second = await glossaryService.getGlossaryByName('tech-terms'); + const third = await glossaryService.getGlossaryByName('tech-terms'); + + expect(first?.glossary_id).toBe('resolved-glossary-id'); + expect(second?.glossary_id).toBe('resolved-glossary-id'); + expect(third?.glossary_id).toBe('resolved-glossary-id'); + expect(mockDeepLClient.listGlossaries).toHaveBeenCalledTimes(1); + }); + + it('resolveGlossaryId and getGlossaryByName share the list cache (one call total)', async () => { + mockDeepLClient.listGlossaries.mockResolvedValue([TECH_TERMS]); + + await glossaryService.resolveGlossaryId('tech-terms'); + const resolved = await glossaryService.getGlossaryByName('tech-terms'); + + expect(resolved?.glossary_id).toBe('resolved-glossary-id'); + expect(mockDeepLClient.listGlossaries).toHaveBeenCalledTimes(1); + }); + + it('list cache expires after the TTL window, forcing a fresh list call', async () => { + jest.useFakeTimers(); + try { + mockDeepLClient.listGlossaries.mockResolvedValue([TECH_TERMS]); + + await glossaryService.getGlossaryByName('tech-terms'); + jest.advanceTimersByTime(61_000); + await glossaryService.getGlossaryByName('tech-terms'); + + expect(mockDeepLClient.listGlossaries).toHaveBeenCalledTimes(2); + } finally { + jest.useRealTimers(); + } + }); + + it('createGlossary invalidates the list cache so getGlossaryByName re-fetches', async () => { + mockDeepLClient.listGlossaries.mockResolvedValue([TECH_TERMS]); + mockDeepLClient.createGlossary.mockResolvedValue({ + ...TECH_TERMS, + glossary_id: 'fresh-id', + name: 'brand-new', + }); + + await glossaryService.getGlossaryByName('tech-terms'); + await glossaryService.createGlossary('brand-new', 'en', ['es'], { a: 'b' }); + await glossaryService.getGlossaryByName('tech-terms'); + + expect(mockDeepLClient.listGlossaries).toHaveBeenCalledTimes(2); + }); + }); + + describe('API-returned name trust boundary', () => { + const cleanGlossary = { + glossary_id: 'clean-id', + name: 'prod-gloss', + source_lang: 'en' as const, + target_langs: ['es' as const], + dictionaries: [{ source_lang: 'en' as const, target_lang: 'es' as const, entry_count: 1 }], + creation_time: '2024-01-01T00:00:00Z', + }; + + it('treats a glossary whose name contains a zero-width space as non-existent (silent skip)', async () => { + mockDeepLClient.listGlossaries.mockResolvedValue([ + { ...cleanGlossary, glossary_id: 'poisoned-id', name: 'prod-gloss\u200B' }, + ]); + + await expect( + glossaryService.resolveGlossaryId('prod-gloss\u200B'), + ).rejects.toThrow(/not found/); + }); + + it('treats a glossary whose name contains an ASCII control char as non-existent (silent skip)', async () => { + mockDeepLClient.listGlossaries.mockResolvedValue([ + { ...cleanGlossary, glossary_id: 'poisoned-id', name: 'prod-gloss\x00' }, + ]); + + await expect( + glossaryService.resolveGlossaryId('prod-gloss\x00'), + ).rejects.toThrow(/not found/); + }); + + it('throws ConfigError with a UUID-disambiguation hint when two glossaries share the same name', async () => { + mockDeepLClient.listGlossaries.mockResolvedValue([ + { ...cleanGlossary, glossary_id: 'first-id', name: 'shared' }, + { ...cleanGlossary, glossary_id: 'second-id', name: 'shared' }, + ]); + + let thrown: unknown; + try { + await glossaryService.resolveGlossaryId('shared'); + } catch (err) { + thrown = err; + } + + expect(thrown).toBeInstanceOf(ConfigError); + const err = thrown as ConfigError; + expect(err.message).toMatch(/Multiple glossaries share the name/); + expect(err.suggestion).toMatch(/UUID/); + }); + + it('does not echo raw control chars from the caller input into the error message', async () => { + mockDeepLClient.listGlossaries.mockResolvedValue([]); + const dirty = 'bad\x07name'; + + let thrown: unknown; + try { + await glossaryService.resolveGlossaryId(dirty); + } catch (err) { + thrown = err; + } + + expect(thrown).toBeInstanceOf(ConfigError); + const err = thrown as ConfigError; + expect(err.message).not.toContain('\x07'); + expect(err.message).toContain('badname'); + }); + + it('legit unambiguous name still resolves even when a filtered sibling exists', async () => { + mockDeepLClient.listGlossaries.mockResolvedValue([ + { ...cleanGlossary, glossary_id: 'legit-id', name: 'prod-gloss' }, + { ...cleanGlossary, glossary_id: 'poisoned-id', name: 'prod-gloss\x00' }, + ]); + + const resolved = await glossaryService.resolveGlossaryId('prod-gloss'); + + expect(resolved).toBe('legit-id'); + }); + }); }); describe('deleteGlossary()', () => { diff --git a/tests/unit/logger.test.ts b/tests/unit/logger.test.ts index 64a36de..77bc045 100644 --- a/tests/unit/logger.test.ts +++ b/tests/unit/logger.test.ts @@ -384,4 +384,127 @@ describe('Logger', () => { expect(Logger.shouldShowSpinner()).toBe(false); }); }); + + describe('TMS credential sanitization', () => { + let originalTmsApiKey: string | undefined; + let originalTmsToken: string | undefined; + + beforeEach(() => { + originalTmsApiKey = process.env['TMS_API_KEY']; + originalTmsToken = process.env['TMS_TOKEN']; + delete process.env['TMS_API_KEY']; + delete process.env['TMS_TOKEN']; + }); + + afterEach(() => { + if (originalTmsApiKey === undefined) { + delete process.env['TMS_API_KEY']; + } else { + process.env['TMS_API_KEY'] = originalTmsApiKey; + } + if (originalTmsToken === undefined) { + delete process.env['TMS_TOKEN']; + } else { + process.env['TMS_TOKEN'] = originalTmsToken; + } + }); + + it('should redact TMS_API_KEY env value when set', () => { + process.env['TMS_API_KEY'] = 'test-tms-api-key-12345'; + Logger.info('TMS call used key test-tms-api-key-12345 successfully'); + const logged = consoleErrorSpy.mock.calls[0]?.[0] as string; + expect(logged).not.toContain('test-tms-api-key-12345'); + expect(logged).toMatch(/\[REDACTED\]/); + expect(logged).toBe('TMS call used key [REDACTED] successfully'); + }); + + it('should redact TMS_TOKEN env value when set', () => { + process.env['TMS_TOKEN'] = 'test-tms-token-67890'; + Logger.info('TMS token test-tms-token-67890 refreshed'); + const logged = consoleErrorSpy.mock.calls[0]?.[0] as string; + expect(logged).not.toContain('test-tms-token-67890'); + expect(logged).toMatch(/\[REDACTED\]/); + expect(logged).toBe('TMS token [REDACTED] refreshed'); + }); + + it('should not redact when TMS_API_KEY is not set', () => { + Logger.info('Generic message without any TMS values'); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Generic message without any TMS values', + ); + }); + + it('should not redact when TMS_TOKEN is not set', () => { + Logger.info('Another generic message'); + expect(consoleErrorSpy).toHaveBeenCalledWith('Another generic message'); + }); + + it('should redact Authorization: ApiKey header values', () => { + Logger.info('req headers: Authorization: ApiKey secret-value-xyz'); + const logged = consoleErrorSpy.mock.calls[0]?.[0] as string; + expect(logged).not.toContain('secret-value-xyz'); + expect(logged).toMatch(/\[REDACTED\]/); + expect(logged).toBe('req headers: Authorization: ApiKey [REDACTED]'); + }); + + it('should redact Authorization: Bearer header values', () => { + Logger.info('req headers: Authorization: Bearer jwt.xyz.abc'); + const logged = consoleErrorSpy.mock.calls[0]?.[0] as string; + expect(logged).not.toContain('jwt.xyz.abc'); + expect(logged).toMatch(/\[REDACTED\]/); + expect(logged).toBe('req headers: Authorization: Bearer [REDACTED]'); + }); + + it('should redact Authorization: ApiKey case-insensitively', () => { + Logger.info('authorization: apikey leaked-secret'); + const logged = consoleErrorSpy.mock.calls[0]?.[0] as string; + expect(logged).not.toContain('leaked-secret'); + expect(logged).toMatch(/\[REDACTED\]/); + }); + + it('should redact Authorization header even when TMS env vars are not set', () => { + Logger.info('Authorization: Bearer rotated-jwt-token'); + const logged = consoleErrorSpy.mock.calls[0]?.[0] as string; + expect(logged).not.toContain('rotated-jwt-token'); + expect(logged).toMatch(/\[REDACTED\]/); + }); + + it('should pass non-string values through unchanged', () => { + process.env['TMS_API_KEY'] = 'test-tms-api-key-12345'; + process.env['TMS_TOKEN'] = 'test-tms-token-67890'; + const obj = { token: 'test-tms-token-67890' }; + Logger.info(obj, 42, null, undefined); + expect(consoleErrorSpy).toHaveBeenCalledWith(obj, 42, null, undefined); + }); + + it('should redact multiple distinct credentials in the same log line', () => { + const originalDeeplKey = process.env['DEEPL_API_KEY']; + process.env['DEEPL_API_KEY'] = 'deepl-key-abc:fx'; + process.env['TMS_API_KEY'] = 'test-tms-api-key-12345'; + process.env['TMS_TOKEN'] = 'test-tms-token-67890'; + try { + Logger.info( + 'deepl=deepl-key-abc:fx tmsKey=test-tms-api-key-12345 tmsTok=test-tms-token-67890 url?token=qp-secret DeepL-Auth-Key deepl-key-abc:fx Authorization: Bearer jwt.abc', + ); + const logged = consoleErrorSpy.mock.calls[0]?.[0] as string; + expect(logged).not.toContain('deepl-key-abc:fx'); + expect(logged).not.toContain('test-tms-api-key-12345'); + expect(logged).not.toContain('test-tms-token-67890'); + expect(logged).not.toContain('qp-secret'); + expect(logged).not.toContain('jwt.abc'); + expect(logged).toMatch(/deepl=\[REDACTED\]/); + expect(logged).toMatch(/tmsKey=\[REDACTED\]/); + expect(logged).toMatch(/tmsTok=\[REDACTED\]/); + expect(logged).toMatch(/\?token=\[REDACTED\]/); + expect(logged).toMatch(/DeepL-Auth-Key \[REDACTED\]/); + expect(logged).toMatch(/Authorization: Bearer \[REDACTED\]/); + } finally { + if (originalDeeplKey === undefined) { + delete process.env['DEEPL_API_KEY']; + } else { + process.env['DEEPL_API_KEY'] = originalDeeplKey; + } + } + }); + }); }); diff --git a/tests/unit/register-translate.test.ts b/tests/unit/register-translate.test.ts index 0ebf0e1..f302831 100644 --- a/tests/unit/register-translate.test.ts +++ b/tests/unit/register-translate.test.ts @@ -22,20 +22,39 @@ jest.mock('../../src/utils/logger', () => ({ }, })); -import { Command } from 'commander'; +import { Command, Option } from 'commander'; import { registerTranslate } from '../../src/cli/commands/register-translate'; +import { ValidationError } from '../../src/utils/errors'; + +type Deps = { + handleError: jest.Mock; + getConfigService: jest.Mock; + getApiKeyOrThrow: jest.Mock; +}; + +function setupProgram(): { program: Command; deps: Deps } { + const program = new Command(); + const deps: Deps = { + handleError: jest.fn(), + getConfigService: jest.fn().mockReturnValue({ + getValue: jest.fn().mockReturnValue(undefined), + }), + getApiKeyOrThrow: jest.fn(), + }; + registerTranslate(program, deps as unknown as Parameters[1]); + return { program, deps }; +} + +function getOption(program: Command, long: string): Option { + const translateCmd = program.commands.find(c => c.name() === 'translate')!; + return translateCmd.options.find(o => o.long === long)!; +} describe('registerTranslate', () => { let program: Command; beforeEach(() => { - program = new Command(); - const mockDeps = { - handleError: jest.fn(), - getConfigService: jest.fn(), - getApiKeyOrThrow: jest.fn(), - }; - registerTranslate(program, mockDeps as any); + ({ program } = setupProgram()); }); describe('--output-format option', () => { @@ -54,4 +73,182 @@ describe('registerTranslate', () => { expect(helpOutput).not.toContain('report.docx --to de --output-format pdf'); }); }); + + describe('translation-memory flag registration', () => { + it('registers --translation-memory with the expected synopsis', () => { + const opt = getOption(program, '--translation-memory'); + expect(opt).toBeDefined(); + expect(opt.description).toContain('forces quality_optimized model'); + }); + + it('registers --tm-threshold with the expected synopsis', () => { + const opt = getOption(program, '--tm-threshold'); + expect(opt).toBeDefined(); + expect(opt.description).toContain('requires --translation-memory'); + expect(opt.description).toContain('default 75'); + }); + }); + + describe('--tm-threshold strict coercer', () => { + const coercerOf = (p: Command): (raw: string) => number => { + const opt = getOption(p, '--tm-threshold'); + const parseArg = (opt as unknown as { + parseArg: (raw: string, prev: number) => number; + }).parseArg; + return (raw: string) => parseArg(raw, 0); + }; + + it.each([ + ['0', 0], + ['100', 100], + ['-1', -1], + ['101', 101], + ['80', 80], + ])('accepts integer string %s and parses to %s', (raw, expected) => { + expect(coercerOf(program)(raw)).toBe(expected); + }); + + it.each([ + ['80abc'], + ['0x40'], + ['1e2'], + ['50.5'], + [''], + ['abc'], + ])('rejects non-integer string %p with ValidationError', (raw) => { + expect(() => coercerOf(program)(raw)).toThrow(ValidationError); + }); + }); + + describe('action-handler validation (before dry-run)', () => { + const baseArgs = (extra: string[]): string[] => + ['node', 'cli', 'translate', 'hello', '--to', 'de', '--dry-run', ...extra]; + + it('orphan --tm-threshold throws ValidationError (exit 6)', async () => { + const { program: p, deps } = setupProgram(); + await p.parseAsync(baseArgs(['--tm-threshold', '80'])); + expect(deps.handleError).toHaveBeenCalledTimes(1); + const err = deps.handleError.mock.calls[0]![0] as ValidationError; + expect(err).toBeInstanceOf(ValidationError); + expect(err.message).toContain('--tm-threshold requires --translation-memory'); + expect(err.exitCode).toBe(6); + }); + + it('rejects threshold 101 via range guard (exit 6 with suggestion)', async () => { + const { program: p, deps } = setupProgram(); + await p.parseAsync(baseArgs(['--translation-memory', 'my-tm', '--tm-threshold', '101'])); + expect(deps.handleError).toHaveBeenCalledTimes(1); + const err = deps.handleError.mock.calls[0]![0] as ValidationError; + expect(err).toBeInstanceOf(ValidationError); + expect(err.message).toContain('got: 101'); + expect(err.suggestion).toBe( + 'Use a value between 0 and 100, or omit --tm-threshold to use the default (75).', + ); + }); + + it('rejects threshold -1 via range guard', async () => { + const { program: p, deps } = setupProgram(); + await p.parseAsync(baseArgs(['--translation-memory', 'my-tm', '--tm-threshold', '-1'])); + expect(deps.handleError).toHaveBeenCalledTimes(1); + const err = deps.handleError.mock.calls[0]![0] as ValidationError; + expect(err.message).toContain('got: -1'); + }); + + it('accepts threshold 0 (boundary)', async () => { + const { program: p, deps } = setupProgram(); + await p.parseAsync(baseArgs(['--translation-memory', 'my-tm', '--tm-threshold', '0'])); + expect(deps.handleError).not.toHaveBeenCalled(); + }); + + it('accepts threshold 100 (boundary)', async () => { + const { program: p, deps } = setupProgram(); + await p.parseAsync(baseArgs(['--translation-memory', 'my-tm', '--tm-threshold', '100'])); + expect(deps.handleError).not.toHaveBeenCalled(); + }); + + it('rejects --translation-memory with --model-type latency_optimized', async () => { + const { program: p, deps } = setupProgram(); + await p.parseAsync(baseArgs([ + '--translation-memory', 'my-tm', + '--model-type', 'latency_optimized', + ])); + expect(deps.handleError).toHaveBeenCalledTimes(1); + const err = deps.handleError.mock.calls[0]![0] as ValidationError; + expect(err).toBeInstanceOf(ValidationError); + expect(err.message).toContain('requires quality_optimized model type'); + expect(err.suggestion).toContain('Remove --model-type'); + }); + + it('rejects --translation-memory with --model-type prefer_quality_optimized', async () => { + const { program: p, deps } = setupProgram(); + await p.parseAsync(baseArgs([ + '--translation-memory', 'my-tm', + '--model-type', 'prefer_quality_optimized', + ])); + expect(deps.handleError).toHaveBeenCalledTimes(1); + const err = deps.handleError.mock.calls[0]![0] as ValidationError; + expect(err.message).toContain('requires quality_optimized model type'); + }); + + it('accepts --translation-memory with --model-type quality_optimized', async () => { + const { program: p, deps } = setupProgram(); + await p.parseAsync(baseArgs([ + '--translation-memory', 'my-tm', + '--model-type', 'quality_optimized', + ])); + expect(deps.handleError).not.toHaveBeenCalled(); + }); + + it('accepts --translation-memory alone (no --model-type)', async () => { + const { program: p, deps } = setupProgram(); + await p.parseAsync(baseArgs(['--translation-memory', 'my-tm'])); + expect(deps.handleError).not.toHaveBeenCalled(); + }); + }); + + describe('dry-run block', () => { + const Logger = jest.requireMock('../../src/utils/logger').Logger as { + output: jest.Mock; + }; + + beforeEach(() => { + Logger.output.mockClear(); + }); + + it('emits translation-memory, threshold, and forced-model lines when TM set', async () => { + const { program: p } = setupProgram(); + await p.parseAsync([ + 'node', 'cli', 'translate', 'hello', + '--to', 'de', '--dry-run', + '--translation-memory', 'my-tm', + '--tm-threshold', '80', + ]); + const out = Logger.output.mock.calls.map(c => c[0]).join('\n'); + expect(out).toContain('[dry-run] Translation memory: my-tm'); + expect(out).toContain('[dry-run] Match threshold: 80'); + expect(out).toContain('[dry-run] Model: quality_optimized (forced by --translation-memory)'); + }); + + it('uses default threshold 75 when --tm-threshold not provided', async () => { + const { program: p } = setupProgram(); + await p.parseAsync([ + 'node', 'cli', 'translate', 'hello', + '--to', 'de', '--dry-run', + '--translation-memory', 'my-tm', + ]); + const out = Logger.output.mock.calls.map(c => c[0]).join('\n'); + expect(out).toContain('[dry-run] Match threshold: 75'); + }); + + it('does not emit translation-memory lines when flag absent', async () => { + const { program: p } = setupProgram(); + await p.parseAsync([ + 'node', 'cli', 'translate', 'hello', + '--to', 'de', '--dry-run', + ]); + const out = Logger.output.mock.calls.map(c => c[0]).join('\n'); + expect(out).not.toContain('[dry-run] Translation memory'); + expect(out).not.toContain('[dry-run] Match threshold'); + }); + }); }); diff --git a/tests/unit/sync/sync-bak-cleanup.test.ts b/tests/unit/sync/sync-bak-cleanup.test.ts new file mode 100644 index 0000000..4f15d3f --- /dev/null +++ b/tests/unit/sync/sync-bak-cleanup.test.ts @@ -0,0 +1,212 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { sweepStaleBackups, bucketSweepRoots, DEFAULT_BAK_SWEEP_MAX_AGE_SECONDS } from '../../../src/sync/sync-bak-cleanup'; + +// ────────────────────────────────────────────────────────────────────────────── +// bucketSweepRoots() +// ────────────────────────────────────────────────────────────────────────────── + +describe('bucketSweepRoots()', () => { + const root = '/project'; + + it('returns the literal directory prefix for a simple glob', () => { + const result = bucketSweepRoots(root, { json: { include: ['locales/**/*.json'] } }); + expect(result).toEqual([path.resolve(root, 'locales')]); + }); + + it('handles a glob with no directory prefix (project root)', () => { + const result = bucketSweepRoots(root, { json: { include: ['*.json'] } }); + expect(result).toEqual([root]); + }); + + it('handles a trailing-slash prefix (directory glob)', () => { + const result = bucketSweepRoots(root, { json: { include: ['src/locales/'] } }); + expect(result).toContain(path.resolve(root, 'src/locales')); + }); + + it('deduplicates roots across multiple buckets', () => { + const result = bucketSweepRoots(root, { + a: { include: ['locales/**/*.json'] }, + b: { include: ['locales/**/*.yaml'] }, + }); + expect(result).toEqual([path.resolve(root, 'locales')]); + }); + + it('returns distinct roots for different literal prefixes', () => { + const result = bucketSweepRoots(root, { + a: { include: ['src/locales/**/*.json'] }, + b: { include: ['resources/**/*.json'] }, + }); + expect(result).toHaveLength(2); + expect(result).toContain(path.resolve(root, 'src/locales')); + expect(result).toContain(path.resolve(root, 'resources')); + }); + + it('handles a glob with a {brace} wildcard', () => { + const result = bucketSweepRoots(root, { json: { include: ['src/{en,de}/**/*.json'] } }); + expect(result).toEqual([path.resolve(root, 'src')]); + }); + + it('handles a glob with a ? wildcard', () => { + const result = bucketSweepRoots(root, { json: { include: ['loc?les/**/*.json'] } }); + expect(result).toEqual([root]); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// sweepStaleBackups() — scoped to bucket dirs +// ────────────────────────────────────────────────────────────────────────────── + +describe('sweepStaleBackups() with bucket config', () => { + let tmpDir: string; + let readdirSpy: jest.SpyInstance; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-bak-sweep-')); + readdirSpy = jest.spyOn(fs.promises, 'readdir'); + }); + + afterEach(() => { + readdirSpy.mockRestore(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('only calls readdir inside bucket-include directories, not outside', async () => { + const localesDir = path.join(tmpDir, 'locales'); + const outsideDir = path.join(tmpDir, 'node_src'); + fs.mkdirSync(localesDir, { recursive: true }); + fs.mkdirSync(outsideDir, { recursive: true }); + + // Place a stale .bak in locales (should be swept) + const staleBak = path.join(localesDir, 'de.json.bak'); + fs.writeFileSync(staleBak, 'stale', 'utf-8'); + const tenMinAgo = new Date(Date.now() - 10 * 60_000); + fs.utimesSync(staleBak, tenMinAgo, tenMinAgo); + + // Place a .bak outside the bucket dir (should NOT be touched by a scoped sweep) + const outsideBak = path.join(outsideDir, 'other.json.bak'); + fs.writeFileSync(outsideBak, 'outside', 'utf-8'); + fs.utimesSync(outsideBak, tenMinAgo, tenMinAgo); + + const buckets = { json: { include: ['locales/**/*.json'] } }; + await sweepStaleBackups(tmpDir, 5 * 60_000, buckets); + + // The stale .bak inside the bucket dir must be removed + expect(fs.existsSync(staleBak)).toBe(false); + + // No readdir call should have touched outsideDir + const readdirCalls: string[] = readdirSpy.mock.calls.map((c: unknown[]) => c[0] as string); + expect(readdirCalls.some((p) => p.startsWith(outsideDir))).toBe(false); + }); + + it('removes a stale .bak file within bucket dirs', async () => { + const localesDir = path.join(tmpDir, 'locales'); + fs.mkdirSync(localesDir, { recursive: true }); + + const staleBak = path.join(localesDir, 'fr.json.bak'); + fs.writeFileSync(staleBak, 'stale', 'utf-8'); + const tenMinAgo = new Date(Date.now() - 10 * 60_000); + fs.utimesSync(staleBak, tenMinAgo, tenMinAgo); + + await sweepStaleBackups(tmpDir, 5 * 60_000, { json: { include: ['locales/**/*.json'] } }); + expect(fs.existsSync(staleBak)).toBe(false); + }); + + it('leaves a fresh .bak file alone', async () => { + const localesDir = path.join(tmpDir, 'locales'); + fs.mkdirSync(localesDir, { recursive: true }); + + const freshBak = path.join(localesDir, 'en.json.bak'); + fs.writeFileSync(freshBak, 'fresh', 'utf-8'); + + await sweepStaleBackups(tmpDir, 5 * 60_000, { json: { include: ['locales/**/*.json'] } }); + expect(fs.existsSync(freshBak)).toBe(true); + }); + + it('restores sibling from .bak when sibling is absent before unlinking', async () => { + const localesDir = path.join(tmpDir, 'locales'); + fs.mkdirSync(localesDir, { recursive: true }); + + const sibling = path.join(localesDir, 'de.json'); + const bakFile = `${sibling}.bak`; + fs.writeFileSync(bakFile, '{"key":"value"}', 'utf-8'); + const tenMinAgo = new Date(Date.now() - 10 * 60_000); + fs.utimesSync(bakFile, tenMinAgo, tenMinAgo); + // sibling does not exist + + await sweepStaleBackups(tmpDir, 5 * 60_000, { json: { include: ['locales/**/*.json'] } }); + + expect(fs.existsSync(sibling)).toBe(true); + expect(fs.readFileSync(sibling, 'utf-8')).toBe('{"key":"value"}'); + expect(fs.existsSync(bakFile)).toBe(false); + }); + + it('readdir call count is O(bucket dirs), not O(project)', async () => { + // Create 5 dirs outside the bucket scope + for (let i = 0; i < 5; i++) { + fs.mkdirSync(path.join(tmpDir, `other${i}`), { recursive: true }); + } + const localesDir = path.join(tmpDir, 'locales'); + fs.mkdirSync(localesDir, { recursive: true }); + + const buckets = { json: { include: ['locales/**/*.json'] } }; + await sweepStaleBackups(tmpDir, 5 * 60_000, buckets); + + const readdirCalls: string[] = readdirSpy.mock.calls.map((c: unknown[]) => c[0] as string); + // Only locales (and any of its subdirs) should be visited — not the 5 other* dirs + for (let i = 0; i < 5; i++) { + expect(readdirCalls.some((p) => p.includes(`other${i}`))).toBe(false); + } + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// sweepStaleBackups() — no bucket config (fallback) +// ────────────────────────────────────────────────────────────────────────────── + +describe('sweepStaleBackups() without bucket config (fallback)', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-bak-sweep-fb-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('still removes stale .bak files anywhere under projectRoot', async () => { + const nested = path.join(tmpDir, 'a', 'b'); + fs.mkdirSync(nested, { recursive: true }); + + const staleBak = path.join(nested, 'x.json.bak'); + fs.writeFileSync(staleBak, 'stale', 'utf-8'); + const tenMinAgo = new Date(Date.now() - 10 * 60_000); + fs.utimesSync(staleBak, tenMinAgo, tenMinAgo); + + await sweepStaleBackups(tmpDir, 5 * 60_000); + expect(fs.existsSync(staleBak)).toBe(false); + }); + + it('leaves fresh .bak files alone when no bucket config given', async () => { + const nested = path.join(tmpDir, 'a'); + fs.mkdirSync(nested, { recursive: true }); + + const freshBak = path.join(nested, 'y.json.bak'); + fs.writeFileSync(freshBak, 'fresh', 'utf-8'); + + await sweepStaleBackups(tmpDir, 5 * 60_000); + expect(fs.existsSync(freshBak)).toBe(true); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// resolveBakSweepAgeMs / DEFAULT_BAK_SWEEP_MAX_AGE_SECONDS +// ────────────────────────────────────────────────────────────────────────────── + +describe('DEFAULT_BAK_SWEEP_MAX_AGE_SECONDS', () => { + it('equals 300', () => { + expect(DEFAULT_BAK_SWEEP_MAX_AGE_SECONDS).toBe(300); + }); +}); diff --git a/tests/unit/sync/sync-bucket-walker.test.ts b/tests/unit/sync/sync-bucket-walker.test.ts new file mode 100644 index 0000000..c247e1a --- /dev/null +++ b/tests/unit/sync/sync-bucket-walker.test.ts @@ -0,0 +1,249 @@ +import { extractTranslatable, walkBuckets } from '../../../src/sync/sync-bucket-walker'; +import type { ExtractedEntry, FormatParser } from '../../../src/formats/index'; +import { FormatRegistry } from '../../../src/formats/index'; +import { JsonFormatParser } from '../../../src/formats/json'; +import type { ResolvedSyncConfig } from '../../../src/sync/sync-config'; +import { ValidationError } from '../../../src/utils/errors'; + +jest.mock('fast-glob', () => ({ __esModule: true, default: jest.fn() })); +jest.mock('fs', () => { + const actual = jest.requireActual('fs'); + return { + ...actual, + promises: { + ...actual.promises, + readFile: jest.fn(), + stat: jest.fn(), + }, + }; +}); + +import fg from 'fast-glob'; +import * as fs from 'fs'; + +const mockFg = fg as jest.MockedFunction; +const mockReadFile = fs.promises.readFile as jest.Mock; +const mockStat = fs.promises.stat as jest.Mock; + +function makeConfig(overrides: Partial = {}): ResolvedSyncConfig { + return { + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { json: { include: ['locales/en.json'] } }, + configPath: '/test/.deepl-sync.yaml', + projectRoot: '/test', + overrides: {}, + ...overrides, + }; +} + +function makeRegistry(): FormatRegistry { + const registry = new FormatRegistry(); + registry.register(new JsonFormatParser()); + return registry; +} + +async function collect(iter: AsyncIterable): Promise { + const out: T[] = []; + for await (const x of iter) out.push(x); + return out; +} + +describe('walkBuckets', () => { + beforeEach(() => { + mockFg.mockReset(); + mockReadFile.mockReset(); + mockStat.mockReset(); + mockStat.mockResolvedValue({ size: 1024 } as never); + }); + + it('yields one entry per source file with parsed entries', async () => { + mockFg.mockResolvedValue(['/test/locales/en.json'] as never); + mockReadFile.mockResolvedValue('{"greeting":"Hello"}'); + + const result = await collect(walkBuckets(makeConfig(), makeRegistry())); + + expect(result).toHaveLength(1); + expect(result[0]!.bucket).toBe('json'); + expect(result[0]!.relPath).toBe('locales/en.json'); + expect(result[0]!.entries).toEqual([{ key: 'greeting', value: 'Hello' }]); + expect(result[0]!.skippedEntries).toEqual([]); + expect(result[0]!.isMultiLocale).toBe(false); + }); + + it('partitions entries tagged with metadata.skipped into skippedEntries', async () => { + mockFg.mockResolvedValue(['/test/locales/en.json'] as never); + mockReadFile.mockResolvedValue('{}'); + + const stubRegistry = new FormatRegistry(); + stubRegistry.register({ + name: 'Stub', + configKey: 'json', + extensions: ['.json'], + extract: () => [ + { key: 'a', value: 'Hello' }, + { key: 'b', value: '{0}none|{1}one|[2,*]many', metadata: { skipped: { reason: 'pipe_pluralization' } } }, + { key: 'c', value: 'Goodbye' }, + ], + reconstruct: (content: string) => content, + }); + + const result = await collect(walkBuckets(makeConfig(), stubRegistry)); + + expect(result[0]!.entries.map((e) => e.key)).toEqual(['a', 'c']); + expect(result[0]!.skippedEntries.map((e) => e.key)).toEqual(['b']); + }); + + it('calls fast-glob with followSymbolicLinks:false (symlink-exfil defense)', async () => { + mockFg.mockResolvedValue([] as never); + await collect(walkBuckets(makeConfig(), makeRegistry())); + + expect(mockFg).toHaveBeenCalledWith( + ['locales/en.json'], + expect.objectContaining({ followSymbolicLinks: false }), + ); + }); + + it('skips buckets with no registered parser when strictParser is unset', async () => { + mockFg.mockResolvedValue([] as never); + const config = makeConfig({ buckets: { xliff: { include: ['x.xlf'] } } }); + const emptyRegistry = new FormatRegistry(); + + const result = await collect(walkBuckets(config, emptyRegistry)); + + expect(result).toHaveLength(0); + }); + + it('throws ValidationError on missing parser when strictParser is set', async () => { + const config = makeConfig({ buckets: { xliff: { include: ['x.xlf'] } } }); + const emptyRegistry = new FormatRegistry(); + + await expect( + collect(walkBuckets(config, emptyRegistry, { strictParser: true })), + ).rejects.toBeInstanceOf(ValidationError); + }); + + describe('sync.limits enforcement', () => { + it('skips files whose size exceeds sync.limits.max_file_bytes', async () => { + mockFg.mockResolvedValue(['/test/locales/en.json'] as never); + mockStat.mockResolvedValue({ size: 5_000_000 } as never); + + const config = makeConfig({ + sync: { + concurrency: 5, + batch_size: 50, + limits: { max_file_bytes: 4_000_000 }, + }, + }); + const result = await collect(walkBuckets(config, makeRegistry())); + + expect(result).toHaveLength(0); + expect(mockReadFile).not.toHaveBeenCalled(); + }); + + it('skips files whose entry count exceeds sync.limits.max_entries_per_file', async () => { + mockFg.mockResolvedValue(['/test/locales/en.json'] as never); + const bigObj: Record = {}; + for (let i = 0; i < 6; i++) bigObj[`k${i}`] = `v${i}`; + mockReadFile.mockResolvedValue(JSON.stringify(bigObj)); + + const config = makeConfig({ + sync: { + concurrency: 5, + batch_size: 50, + limits: { max_entries_per_file: 5 }, + }, + }); + const result = await collect(walkBuckets(config, makeRegistry())); + + expect(result).toHaveLength(0); + }); + + it('passes through files within the configured caps', async () => { + mockFg.mockResolvedValue(['/test/locales/en.json'] as never); + mockStat.mockResolvedValue({ size: 100 } as never); + mockReadFile.mockResolvedValue('{"a":"Hello"}'); + + const config = makeConfig({ + sync: { + concurrency: 5, + batch_size: 50, + limits: { max_file_bytes: 1024, max_entries_per_file: 10 }, + }, + }); + const result = await collect(walkBuckets(config, makeRegistry())); + + expect(result).toHaveLength(1); + expect(result[0]!.entries).toEqual([{ key: 'a', value: 'Hello' }]); + }); + + it('skips files where a laravel_php parser depth cap is exceeded', async () => { + mockFg.mockResolvedValue(['/test/lang/en.php'] as never); + mockStat.mockResolvedValue({ size: 200 } as never); + mockReadFile.mockResolvedValue( + ` ['b' => ['c' => ['d' => 'too deep']]]];`, + ); + + const { PhpArraysFormatParser } = await import('../../../src/formats/php-arrays'); + const registry = new FormatRegistry(); + registry.register(new PhpArraysFormatParser()); + + const config = makeConfig({ + buckets: { laravel_php: { include: ['lang/en.php'] } }, + sync: { + concurrency: 5, + batch_size: 50, + limits: { max_depth: 2 }, + }, + }); + const result = await collect(walkBuckets(config, registry)); + + expect(result).toHaveLength(0); + }); + }); +}); + +describe('extractTranslatable', () => { + function makeParser( + entries: ExtractedEntry[], + opts: { multiLocale?: boolean } = {}, + ): FormatParser { + return { + multiLocale: opts.multiLocale, + extract: jest.fn((_content: string, _locale?: string) => entries), + reconstruct: jest.fn(), + format: 'json', + } as unknown as FormatParser; + } + + it('drops entries tagged with metadata.skipped from the returned list', () => { + const parser = makeParser([ + { key: 'greeting', value: 'Hello' }, + { key: 'plural', value: '|{n} item', metadata: { skipped: 'pipe_pluralization' } }, + { key: 'farewell', value: 'Goodbye' }, + ]); + const result = extractTranslatable(parser, ''); + expect(result.map((e) => e.key)).toEqual(['greeting', 'farewell']); + }); + + it('forwards locale to the parser only when multiLocale is true', () => { + const parser = makeParser([], { multiLocale: true }); + extractTranslatable(parser, '', 'de'); + expect(parser.extract).toHaveBeenCalledWith('', 'de'); + }); + + it('omits locale argument for non-multiLocale parsers', () => { + const parser = makeParser([], { multiLocale: false }); + extractTranslatable(parser, '', 'de'); + expect(parser.extract).toHaveBeenCalledWith(''); + }); + + it('returns an empty array when every entry is skipped', () => { + const parser = makeParser([ + { key: 'a', value: '|x', metadata: { skipped: 'pipe_pluralization' } }, + { key: 'b', value: '|y', metadata: { skipped: 'pipe_pluralization' } }, + ]); + expect(extractTranslatable(parser, '')).toEqual([]); + }); +}); diff --git a/tests/unit/sync/sync-command.test.ts b/tests/unit/sync/sync-command.test.ts new file mode 100644 index 0000000..578d601 --- /dev/null +++ b/tests/unit/sync/sync-command.test.ts @@ -0,0 +1,1164 @@ +import { SyncCommand, type CliSyncOptions } from '../../../src/cli/commands/sync-command'; +import type { SyncService, SyncResult, SyncFileResult } from '../../../src/sync/sync-service'; +import { Logger } from '../../../src/utils/logger'; + +const mockExecFile = jest.fn( + ( + _cmd: string, + _args: string[], + _optsOrCb?: unknown, + cbArg?: (err: Error | null, result: { stdout: string; stderr: string }) => void, + ) => { + const cb = typeof _optsOrCb === 'function' + ? (_optsOrCb as (err: Error | null, result: { stdout: string; stderr: string }) => void) + : cbArg; + if (cb) cb(null, { stdout: '', stderr: '' }); + }, +); + +jest.mock('child_process', () => ({ + execFile: mockExecFile, +})); + +const mockExistsSync = jest.fn((_p: string | URL) => false); +jest.mock('fs', () => { + const actual = jest.requireActual('fs'); + return new Proxy(actual as object, { + get(target, prop: string | symbol, receiver) { + if (prop === 'existsSync') return mockExistsSync; + return Reflect.get(target, prop, receiver); + }, + }); +}); + +const mockWatcherOn = jest.fn().mockReturnThis(); +const mockWatcherClose = jest.fn().mockResolvedValue(undefined); +const mockWatch = jest.fn().mockReturnValue({ + on: mockWatcherOn, + close: mockWatcherClose, +}); + +jest.mock('chokidar', () => ({ + watch: mockWatch, +})); + +jest.mock('../../../src/sync/sync-config', () => ({ + loadSyncConfig: jest.fn(), +})); + +const { loadSyncConfig: mockLoadSyncConfig } = require('../../../src/sync/sync-config') as { loadSyncConfig: jest.Mock }; + +const defaultSyncConfig = { + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { json: { include: ['locales/en.json'] } }, + configPath: '/test/.deepl-sync.yaml', + projectRoot: '/test', + overrides: {}, +}; + +function makeResult(overrides: Partial = {}): SyncResult { + return { + success: true, + totalKeys: 10, + newKeys: 2, + staleKeys: 1, + deletedKeys: 0, + currentKeys: 7, + totalCharactersBilled: 100, + fileResults: [], + validationWarnings: 0, + validationErrors: 0, + estimatedCharacters: 0, + targetLocaleCount: 1, + dryRun: false, + frozen: false, + driftDetected: false, + lockUpdated: false, + ...overrides, + }; +} + +function createMockSyncService(result: SyncResult): jest.Mocked { + return { + sync: jest.fn().mockResolvedValue(result), + } as unknown as jest.Mocked; +} + +describe('SyncCommand', () => { + let logInfoSpy: jest.SpyInstance; + let logWarnSpy: jest.SpyInstance; + let stdoutWriteSpy: jest.SpyInstance; + + beforeEach(() => { + mockLoadSyncConfig.mockResolvedValue({ ...defaultSyncConfig }); + logInfoSpy = jest.spyOn(Logger, 'info').mockImplementation(() => {}); + logWarnSpy = jest.spyOn(Logger, 'warn').mockImplementation(() => {}); + stdoutWriteSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + logInfoSpy.mockRestore(); + logWarnSpy.mockRestore(); + stdoutWriteSpy.mockRestore(); + }); + + describe('displayResult() via run()', () => { + it('should mention dry run when dryRun is true', async () => { + const result = makeResult({ dryRun: true }); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({ dryRun: true } as CliSyncOptions); + + const infoOutput = logInfoSpy.mock.calls.map(c => String(c[0])).join('\n'); + expect(infoOutput.toLowerCase()).toContain('dry-run'); + }); + + it('should mention drift when driftDetected is true', async () => { + const result = makeResult({ driftDetected: true, success: false }); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({} as CliSyncOptions); + + const infoOutput = logInfoSpy.mock.calls.map(c => String(c[0])).join('\n'); + expect(infoOutput.toLowerCase()).toContain('drift'); + }); + + it('should output valid JSON to stdout when format is json', async () => { + const result = makeResult(); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({ format: 'json' } as CliSyncOptions); + + const jsonCall = stdoutWriteSpy.mock.calls.find((c: unknown[]) => { + try { + JSON.parse(String(c[0]).trim()); + return true; + } catch { + return false; + } + }); + expect(jsonCall).toBeDefined(); + const parsed = JSON.parse(String(jsonCall![0]).trim()) as Record; + expect(parsed).toHaveProperty('ok'); + expect(parsed).toHaveProperty('totalKeys'); + expect(parsed).not.toHaveProperty('success'); + }); + + it('should include cost estimate when characters are billed', async () => { + const result = makeResult({ totalCharactersBilled: 500_000 }); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({} as CliSyncOptions); + + const infoOutput = logInfoSpy.mock.calls.map(c => String(c[0])).join('\n'); + expect(infoOutput).toContain('500,000 chars'); + expect(infoOutput).toMatch(/~?\$\d+\.\d+/); + }); + + it('should append Pro tier disclaimer to billed-chars cost estimate in text mode', async () => { + const result = makeResult({ totalCharactersBilled: 500_000 }); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({} as CliSyncOptions); + + const infoOutput = logInfoSpy.mock.calls.map(c => String(c[0])).join('\n'); + expect(infoOutput).toContain('Pro tier estimate'); + }); + + it('should append Pro tier disclaimer to dry-run estimated cost in text mode', async () => { + const result = makeResult({ dryRun: true, estimatedCharacters: 300_000, totalCharactersBilled: 0 }); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({ dryRun: true } as CliSyncOptions); + + const infoOutput = logInfoSpy.mock.calls.map(c => String(c[0])).join('\n'); + expect(infoOutput).toContain('Pro tier estimate'); + }); + + it('should include estimatedCost in JSON output', async () => { + const result = makeResult({ totalCharactersBilled: 1_000_000 }); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({ format: 'json' } as CliSyncOptions); + + const jsonCall = stdoutWriteSpy.mock.calls.find((c: unknown[]) => { + try { JSON.parse(String(c[0]).trim()); return true; } catch { return false; } + }); + const parsed = JSON.parse(String(jsonCall![0]).trim()) as Record; + expect(parsed['estimatedCost']).toBe('~$25.00'); + }); + + it('should include rateAssumption: "pro" in JSON output', async () => { + const result = makeResult({ totalCharactersBilled: 1_000_000 }); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({ format: 'json' } as CliSyncOptions); + + const jsonCall = stdoutWriteSpy.mock.calls.find((c: unknown[]) => { + try { JSON.parse(String(c[0]).trim()); return true; } catch { return false; } + }); + const parsed = JSON.parse(String(jsonCall![0]).trim()) as Record; + expect(parsed['rateAssumption']).toBe('pro'); + }); + + it('should not embed Pro tier disclaimer in JSON estimatedCost field', async () => { + const result = makeResult({ totalCharactersBilled: 1_000_000 }); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({ format: 'json' } as CliSyncOptions); + + const jsonCall = stdoutWriteSpy.mock.calls.find((c: unknown[]) => { + try { JSON.parse(String(c[0]).trim()); return true; } catch { return false; } + }); + const parsed = JSON.parse(String(jsonCall![0]).trim()) as Record; + expect(String(parsed['estimatedCost'])).not.toContain('Pro tier estimate'); + }); + + it('should display validation warnings when present', async () => { + const result = makeResult({ validationWarnings: 3 }); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({} as CliSyncOptions); + + const warnOutput = logWarnSpy.mock.calls.map(c => String(c[0])).join('\n'); + expect(warnOutput).toContain('3'); + expect(warnOutput.toLowerCase()).toContain('warning'); + }); + + it('should display validation errors when present', async () => { + const result = makeResult({ validationErrors: 5 }); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({} as CliSyncOptions); + + const warnOutput = logWarnSpy.mock.calls.map(c => String(c[0])).join('\n'); + expect(warnOutput).toContain('5'); + expect(warnOutput.toLowerCase()).toContain('error'); + }); + }); + + describe('--ci alias for --frozen', () => { + it('should pass frozen=true to sync service when ci option is set', async () => { + const result = makeResult({ frozen: true }); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({ ci: true } as CliSyncOptions); + + const syncCall = mockService.sync.mock.calls[0]!; + const syncOptions = syncCall[1] as Record; + expect(syncOptions['frozen']).toBe(true); + }); + + it('should behave identically to --frozen', async () => { + const resultFrozen = makeResult({ frozen: true, driftDetected: true, success: false }); + const resultCi = makeResult({ frozen: true, driftDetected: true, success: false }); + + const mockServiceFrozen = createMockSyncService(resultFrozen); + const mockServiceCi = createMockSyncService(resultCi); + + const frozenCommand = new SyncCommand(mockServiceFrozen); + const ciCommand = new SyncCommand(mockServiceCi); + + const frozenResult = await frozenCommand.run({ frozen: true } as CliSyncOptions); + const ciResult = await ciCommand.run({ ci: true } as CliSyncOptions); + + expect(frozenResult.driftDetected).toBe(ciResult.driftDetected); + expect(frozenResult.frozen).toBe(ciResult.frozen); + expect(frozenResult.success).toBe(ciResult.success); + }); + }); + + describe('display formatting', () => { + it('should not show cost when zero characters billed', async () => { + const result = makeResult({ totalCharactersBilled: 0 }); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({} as CliSyncOptions); + + const infoOutput = logInfoSpy.mock.calls.map(c => String(c[0])).join('\n'); + expect(infoOutput).not.toContain('$'); + expect(infoOutput).not.toContain('chars'); + }); + + it('should show all key categories in summary', async () => { + const result = makeResult({ newKeys: 3, staleKeys: 2, currentKeys: 10, deletedKeys: 1 }); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({} as CliSyncOptions); + + const infoOutput = logInfoSpy.mock.calls.map(c => String(c[0])).join('\n'); + expect(infoOutput).toContain('3 new'); + expect(infoOutput).toContain('2 updated'); + expect(infoOutput).toContain('10 current'); + expect(infoOutput).toContain('1 deleted'); + }); + + it('should pass flagForReview through to sync options', async () => { + const result = makeResult(); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({ flagForReview: true } as CliSyncOptions); + + const syncCall = mockService.sync.mock.calls[0]!; + const syncOptions = syncCall[1] as Record; + expect(syncOptions['flagForReview']).toBe(true); + }); + }); + + describe('dry-run estimation display', () => { + it('should display estimated characters and cost in dry-run', async () => { + const result = makeResult({ dryRun: true, totalCharactersBilled: 0, estimatedCharacters: 4500, targetLocaleCount: 5 }); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({ dryRun: true } as CliSyncOptions); + + const infoOutput = logInfoSpy.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); + expect(infoOutput).toContain('~4,500 chars'); + expect(infoOutput).toMatch(/~?\$\d+\.\d+/); + }); + + it('should not display estimation line when estimatedCharacters is 0', async () => { + const result = makeResult({ dryRun: true, totalCharactersBilled: 0, estimatedCharacters: 0 }); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({ dryRun: true } as CliSyncOptions); + + const infoOutput = logInfoSpy.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); + expect(infoOutput).not.toContain('This sync'); + }); + + it('should show locale count in dry-run summary', async () => { + const result = makeResult({ dryRun: true, totalCharactersBilled: 0, estimatedCharacters: 1000, targetLocaleCount: 3 }); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({ dryRun: true } as CliSyncOptions); + + const infoOutput = logInfoSpy.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); + expect(infoOutput).toContain('across 3 languages'); + }); + + it('should use singular "language" when targetLocaleCount is 1', async () => { + const result = makeResult({ dryRun: true, totalCharactersBilled: 0, estimatedCharacters: 100, targetLocaleCount: 1 }); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({ dryRun: true } as CliSyncOptions); + + const infoOutput = logInfoSpy.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); + expect(infoOutput).toContain('across 1 language'); + expect(infoOutput).not.toContain('languages'); + }); + + it('should include estimatedCharacters in JSON dry-run output', async () => { + const result = makeResult({ dryRun: true, totalCharactersBilled: 0, estimatedCharacters: 4500, targetLocaleCount: 5 }); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({ dryRun: true, format: 'json' } as CliSyncOptions); + + const jsonCall = stdoutWriteSpy.mock.calls.find((c: unknown[]) => { + try { JSON.parse(String(c[0]).trim()); return true; } catch { return false; } + }); + const parsed = JSON.parse(String(jsonCall![0]).trim()) as Record; + expect(parsed['estimatedCharacters']).toBe(4500); + expect(parsed['estimatedCost']).toBe('~$0.11'); + }); + }); + + describe('per-locale progress display', () => { + // Per-locale ticks are emitted only by the live renderProgress listener + // on `locale-complete` events — the post-sync aggregated summary was + // removed to eliminate duplicate output. These tests exercise onProgress + // directly to verify the live tick format. + function mockServiceWithProgress( + events: Array<{ locale: string; file: string; translated: number; failed: number }>, + result: SyncResult, + ): jest.Mocked { + return { + sync: jest.fn().mockImplementation(async (_config, options) => { + for (const e of events) { + options?.onProgress?.({ type: 'locale-complete', ...e }); + } + return result; + }), + } as unknown as jest.Mocked; + } + + it('emits per-locale tick for each completed file', async () => { + const fileResults: SyncFileResult[] = [ + { file: 'locales/de.json', locale: 'de', translated: 10, skipped: 0, failed: 0, written: true }, + { file: 'locales/fr.json', locale: 'fr', translated: 10, skipped: 0, failed: 0, written: true }, + ]; + const mockService = mockServiceWithProgress( + [ + { locale: 'de', file: 'locales/de.json', translated: 10, failed: 0 }, + { locale: 'fr', file: 'locales/fr.json', translated: 10, failed: 0 }, + ], + makeResult({ fileResults }), + ); + const command = new SyncCommand(mockService); + + await command.run({} as CliSyncOptions); + + const infoOutput = logInfoSpy.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); + expect(infoOutput).toContain('\u2713 de: 10/10'); + expect(infoOutput).toContain('\u2713 fr: 10/10'); + }); + + it('emits a tick per file for multiple files in the same locale', async () => { + const fileResults: SyncFileResult[] = [ + { file: 'locales/de.json', locale: 'de', translated: 5, skipped: 0, failed: 0, written: true }, + { file: 'messages/de.json', locale: 'de', translated: 3, skipped: 0, failed: 0, written: true }, + ]; + const mockService = mockServiceWithProgress( + [ + { locale: 'de', file: 'locales/de.json', translated: 5, failed: 0 }, + { locale: 'de', file: 'messages/de.json', translated: 3, failed: 0 }, + ], + makeResult({ fileResults }), + ); + const command = new SyncCommand(mockService); + + await command.run({} as CliSyncOptions); + + const infoOutput = logInfoSpy.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); + expect(infoOutput).toContain('\u2713 de: 5/5 keys (locales/de.json)'); + expect(infoOutput).toContain('\u2713 de: 3/3 keys (messages/de.json)'); + }); + + it('shows failure indicator when a locale has failures', async () => { + const fileResults: SyncFileResult[] = [ + { file: 'locales/de.json', locale: 'de', translated: 8, skipped: 0, failed: 2, written: true }, + ]; + const mockService = mockServiceWithProgress( + [{ locale: 'de', file: 'locales/de.json', translated: 8, failed: 2 }], + makeResult({ fileResults }), + ); + const command = new SyncCommand(mockService); + + await command.run({} as CliSyncOptions); + + const infoOutput = logInfoSpy.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); + expect(infoOutput).toContain('\u2717 de: 8/10'); + }); + + it('should not display per-locale in dry-run mode', async () => { + const result = makeResult({ dryRun: true, totalCharactersBilled: 0 }); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({ dryRun: true } as CliSyncOptions); + + const infoOutput = logInfoSpy.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); + expect(infoOutput).not.toMatch(/[✓✗]/); + expect(infoOutput).not.toMatch(/\d+\/\d+/); + }); + + it('should not display per-locale when fileResults is empty', async () => { + const result = makeResult({ fileResults: [] }); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({} as CliSyncOptions); + + const infoOutput = logInfoSpy.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); + expect(infoOutput).not.toMatch(/\d+\/\d+/); + }); + + // The per-locale tick should fire exactly once per file+locale event, not + // both live via renderProgress AND again in the aggregated post-sync + // summary. Default runs were emitting both, which duplicated every + // completed locale in the console output. + it('emits each per-file-locale tick exactly once (no duplicate summary)', async () => { + const fileResult: SyncFileResult = { + file: 'locales/de.json', + locale: 'de', + translated: 10, + skipped: 0, + failed: 0, + written: true, + }; + const result = makeResult({ fileResults: [fileResult] }); + const mockService = { + sync: jest.fn().mockImplementation(async (_config, options) => { + options?.onProgress?.({ + type: 'locale-complete', + locale: 'de', + file: 'locales/de.json', + translated: 10, + failed: 0, + }); + return result; + }), + } as unknown as jest.Mocked; + const command = new SyncCommand(mockService); + + await command.run({} as CliSyncOptions); + + const infoOutput = logInfoSpy.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); + const tickMatches = infoOutput.match(/\u2713 de:/g) ?? []; + expect(tickMatches).toHaveLength(1); + }); + }); + + describe('--watch mode', () => { + it('should accept watch and debounce options without error', async () => { + const result = makeResult(); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + // run() with watch=false should complete normally (watch=true would block) + const syncResult = await command.run({ watch: false, debounce: 300 } as CliSyncOptions); + expect(syncResult.success).toBe(true); + expect(mockService.sync).toHaveBeenCalledTimes(1); + }); + }); + + describe('autoCommit', () => { + const writtenFile: SyncFileResult = { + file: 'locales/de.json', + locale: 'de', + translated: 5, + skipped: 0, + failed: 0, + written: true, + }; + + const skippedFile: SyncFileResult = { + file: 'locales/fr.json', + locale: 'fr', + translated: 0, + skipped: 5, + failed: 0, + written: false, + }; + + beforeEach(() => { + mockExecFile.mockReset(); + mockExecFile.mockImplementation( + ( + _cmd: string, + args: string[], + optsOrCb?: unknown, + cbArg?: (err: Error | null, result: { stdout: string; stderr: string }) => void, + ) => { + const cb = typeof optsOrCb === 'function' + ? (optsOrCb as (err: Error | null, result: { stdout: string; stderr: string }) => void) + : cbArg; + let stdout = ''; + if (Array.isArray(args) && args[0] === 'rev-parse' && args[1] === '--git-dir') { + stdout = '.git'; + } + if (cb) cb(null, { stdout, stderr: '' }); + }, + ); + mockExistsSync.mockReset(); + mockExistsSync.mockImplementation((p: string | URL) => { + // Lockfile exists by default so autoCommit stages it when lockUpdated=true. + return typeof p === 'string' && p.endsWith('.deepl-sync.lock'); + }); + }); + + it('should call git add and git commit when autoCommit=true and files were written', async () => { + const result = makeResult({ + fileResults: [writtenFile], + lockUpdated: true, + }); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({ autoCommit: true } as CliSyncOptions); + + const calls = mockExecFile.mock.calls; + const gitCalls = calls.map((c: unknown[]) => [c[0], c[1]]); + + expect(gitCalls).toContainEqual(['git', ['rev-parse', '--git-dir']]); + expect(gitCalls).toContainEqual(['git', ['add', 'locales/de.json']]); + expect(gitCalls).toContainEqual(['git', ['add', '.deepl-sync.lock']]); + expect(gitCalls).toContainEqual( + expect.arrayContaining([ + 'git', + expect.arrayContaining(['commit', '-m', expect.stringContaining('de')]), + ]), + ); + }); + + it('should not stage .deepl-sync.lock when lockUpdated is false', async () => { + const result = makeResult({ + fileResults: [writtenFile], + lockUpdated: false, + }); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({ autoCommit: true } as CliSyncOptions); + + const calls = mockExecFile.mock.calls; + const gitCalls = calls.map((c: unknown[]) => [c[0], c[1]]); + + expect(gitCalls).toContainEqual(['git', ['add', 'locales/de.json']]); + expect(gitCalls).not.toContainEqual(['git', ['add', '.deepl-sync.lock']]); + }); + + it('should pass cwd=config.projectRoot to every git invocation', async () => { + const result = makeResult({ fileResults: [writtenFile], lockUpdated: true }); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({ autoCommit: true } as CliSyncOptions); + + const gitInvocations = mockExecFile.mock.calls.filter( + (c: unknown[]) => c[0] === 'git', + ); + expect(gitInvocations.length).toBeGreaterThan(0); + for (const call of gitInvocations) { + const opts = call[2] as { cwd?: string }; + expect(opts).toBeDefined(); + expect(opts.cwd).toBe('/test'); + } + }); + + it('should stage multiple written files and include all locales in commit message', async () => { + const writtenFileFr: SyncFileResult = { + file: 'locales/fr.json', + locale: 'fr', + translated: 3, + skipped: 0, + failed: 0, + written: true, + }; + const result = makeResult({ + fileResults: [writtenFile, writtenFileFr], + lockUpdated: true, + }); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({ autoCommit: true } as CliSyncOptions); + + const calls = mockExecFile.mock.calls; + const gitCalls = calls.map((c: unknown[]) => [c[0], c[1]]); + + expect(gitCalls).toContainEqual(['git', ['add', 'locales/de.json']]); + expect(gitCalls).toContainEqual(['git', ['add', 'locales/fr.json']]); + expect(gitCalls).toContainEqual(['git', ['add', '.deepl-sync.lock']]); + + const commitCall = calls.find( + (c: unknown[]) => Array.isArray(c[1]) && (c[1] as string[]).includes('commit'), + ); + expect(commitCall).toBeDefined(); + const commitMsg = (commitCall![1])[2]; + expect(commitMsg).toContain('de'); + expect(commitMsg).toContain('fr'); + }); + + it('should log warning and skip when not a git repository', async () => { + mockExecFile.mockImplementation( + ( + _cmd: string, + args: string[], + optsOrCb?: unknown, + cbArg?: (err: Error | null, result: { stdout: string; stderr: string }) => void, + ) => { + const cb = typeof optsOrCb === 'function' + ? (optsOrCb as (err: Error | null, result: { stdout: string; stderr: string }) => void) + : cbArg; + if (Array.isArray(args) && args.includes('rev-parse')) { + if (cb) cb(new Error('not a git repository'), { stdout: '', stderr: '' }); + } else { + if (cb) cb(null, { stdout: '', stderr: '' }); + } + }, + ); + + const result = makeResult({ fileResults: [writtenFile] }); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({ autoCommit: true } as CliSyncOptions); + + const warnOutput = logWarnSpy.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); + expect(warnOutput).toContain('Not a git repository'); + + const addCalls = mockExecFile.mock.calls.filter( + (c: unknown[]) => Array.isArray(c[1]) && (c[1] as string[]).includes('add'), + ); + expect(addCalls).toHaveLength(0); + }); + + it('should not call git when no files were written', async () => { + const result = makeResult({ fileResults: [skippedFile] }); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({ autoCommit: true } as CliSyncOptions); + + const calls = mockExecFile.mock.calls; + const addCalls = calls.filter( + (c: unknown[]) => Array.isArray(c[1]) && (c[1] as string[]).includes('add'), + ); + const commitCalls = calls.filter( + (c: unknown[]) => Array.isArray(c[1]) && (c[1] as string[]).includes('commit'), + ); + expect(addCalls).toHaveLength(0); + expect(commitCalls).toHaveLength(0); + }); + + it('should not call git when fileResults is empty', async () => { + const result = makeResult({ fileResults: [] }); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({ autoCommit: true } as CliSyncOptions); + + expect(mockExecFile).not.toHaveBeenCalled(); + }); + + it('should not call git when dryRun is true', async () => { + const result = makeResult({ dryRun: true, fileResults: [writtenFile] }); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({ autoCommit: true, dryRun: true } as CliSyncOptions); + + const addCalls = mockExecFile.mock.calls.filter( + (c: unknown[]) => Array.isArray(c[1]) && (c[1] as string[]).includes('add'), + ); + expect(addCalls).toHaveLength(0); + }); + + it('should not call git when driftDetected is true', async () => { + const result = makeResult({ driftDetected: true, fileResults: [writtenFile] }); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({ autoCommit: true } as CliSyncOptions); + + const addCalls = mockExecFile.mock.calls.filter( + (c: unknown[]) => Array.isArray(c[1]) && (c[1] as string[]).includes('add'), + ); + expect(addCalls).toHaveLength(0); + }); + + it('should log success message after committing', async () => { + const result = makeResult({ fileResults: [writtenFile], lockUpdated: true }); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({ autoCommit: true } as CliSyncOptions); + + const infoOutput = logInfoSpy.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); + expect(infoOutput).toContain('Auto-committed'); + expect(infoOutput).toContain('2 file(s)'); + }); + }); + + describe('watchAndSync', () => { + const mockWatcher = { on: mockWatcherOn, close: mockWatcherClose }; + + type ProcessOn = typeof process.on; + + beforeEach(() => { + mockWatcherOn.mockReturnValue(mockWatcher); + mockWatcherClose.mockResolvedValue(undefined); + mockWatch.mockReturnValue(mockWatcher); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + // watchAndSync awaits chokidar/path dynamic imports and a filesystem sweep + // before registering its SIGINT handler and change/add listeners. Under + // fake timers, flushing that chain requires alternating microtask drains + // (`await Promise.resolve()`) with zero-duration timer advances — timers + // advance queued macrotasks, Promise.resolve drains microtask-only awaits. + // 50 rounds is well above the ~10-level await depth of the setup chain, + // giving parallel-load runs headroom (the previous 20-round count flaked + // intermittently under heavy jest worker contention). + async function flushWatchSetup(): Promise { + for (let i = 0; i < 50; i++) { + await Promise.resolve(); + await jest.advanceTimersByTimeAsync(0); + } + } + + it('should create a chokidar watcher with bucket include patterns', async () => { + const result = makeResult(); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + const sigintListeners: Array<() => void> = []; + const processOnSpy = jest.spyOn(process, 'on').mockImplementation(((event: string, listener: () => void) => { + if (event === 'SIGINT') { + sigintListeners.push(listener); + } + return process; + }) as unknown as ProcessOn); + + const runPromise = command.run({ watch: true, debounce: 100 } as CliSyncOptions); + + await flushWatchSetup(); + + for (const listener of sigintListeners) { + listener(); + } + + await runPromise; + + processOnSpy.mockRestore(); + + expect(mockWatch).toHaveBeenCalledTimes(1); + const watchedPaths = mockWatch.mock.calls[0]![0] as string[]; + expect(watchedPaths.length).toBeGreaterThan(0); + expect(watchedPaths[0]).toContain('locales/en.json'); + }); + + it('should warn and skip when there are no watch paths', async () => { + mockLoadSyncConfig.mockResolvedValue({ + ...defaultSyncConfig, + buckets: {}, + }); + + const result = makeResult(); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + await command.run({ watch: true } as CliSyncOptions); + + const warnOutput = logWarnSpy.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); + expect(warnOutput).toContain('No source files to watch'); + expect(mockWatch).not.toHaveBeenCalled(); + }); + + it('fires --auto-commit on every watch cycle, not only the initial sync', async () => { + mockExecFile.mockReset(); + mockExecFile.mockImplementation( + ( + _cmd: string, + args: string[], + optsOrCb?: unknown, + cbArg?: (err: Error | null, result: { stdout: string; stderr: string }) => void, + ) => { + const cb = typeof optsOrCb === 'function' + ? (optsOrCb as (err: Error | null, result: { stdout: string; stderr: string }) => void) + : cbArg; + let stdout = ''; + if (Array.isArray(args) && args[0] === 'rev-parse' && args[1] === '--git-dir') { + stdout = '.git'; + } + if (cb) cb(null, { stdout, stderr: '' }); + }, + ); + mockExistsSync.mockReset(); + mockExistsSync.mockImplementation((p: string | URL) => { + return typeof p === 'string' && p.endsWith('.deepl-sync.lock'); + }); + + // Capture the debounced 'change'/'add' handlers installed by + // attachDebouncedWatchLoop so we can drive watch cycles deterministically. + const registeredHandlers: Array<(p?: string) => void> = []; + mockWatcherOn.mockImplementation((event: string, handler: (p?: string) => void) => { + if (event === 'change' || event === 'add') { + registeredHandlers.push(handler); + } + return mockWatcher; + }); + + const result = makeResult({ + fileResults: [ + { file: 'locales/de.json', locale: 'de', translated: 5, skipped: 0, failed: 0, written: true }, + ], + lockUpdated: true, + }); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + const sigintListeners: Array<() => void> = []; + const processOnSpy = jest.spyOn(process, 'on').mockImplementation(((event: string, listener: () => void) => { + if (event === 'SIGINT') sigintListeners.push(listener); + return process; + }) as unknown as ProcessOn); + + const runPromise = command.run({ watch: true, debounce: 50, autoCommit: true } as CliSyncOptions); + + await flushWatchSetup(); + + // Baseline: the pre-watch initial sync should already have committed once. + const commitsBefore = mockExecFile.mock.calls.filter( + (c: unknown[]) => Array.isArray(c[1]) && (c[1] as string[]).includes('commit'), + ).length; + expect(commitsBefore).toBeGreaterThanOrEqual(1); + + // Trigger a watch cycle by firing one of the registered change handlers, + // then advance timers past the debounce window and flush microtasks so + // the queued sync + autoCommit pipeline runs to completion. + expect(registeredHandlers.length).toBeGreaterThan(0); + registeredHandlers[0]!(); + await jest.advanceTimersByTimeAsync(60); + await flushWatchSetup(); + + const commitsAfterFirstCycle = mockExecFile.mock.calls.filter( + (c: unknown[]) => Array.isArray(c[1]) && (c[1] as string[]).includes('commit'), + ).length; + expect(commitsAfterFirstCycle).toBe(commitsBefore + 1); + + // Second cycle — an independent edit should produce another commit. + registeredHandlers[0]!(); + await jest.advanceTimersByTimeAsync(60); + await flushWatchSetup(); + + const commitsAfterSecondCycle = mockExecFile.mock.calls.filter( + (c: unknown[]) => Array.isArray(c[1]) && (c[1] as string[]).includes('commit'), + ).length; + expect(commitsAfterSecondCycle).toBe(commitsBefore + 2); + + for (const listener of sigintListeners) listener(); + await runPromise; + + processOnSpy.mockRestore(); + }); + + it('returns SIGINT/SIGTERM listener counts to their baseline after shutdown', async () => { + const result = makeResult(); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + const sigintBaseline = process.listenerCount('SIGINT'); + const sigtermBaseline = process.listenerCount('SIGTERM'); + + // Don't mock process.on — the whole point is to observe real listener + // churn across repeated invocations. Trigger shutdown by emitting the + // signal to the real process emitter (listeners only react synchronously). + const firstRun = command.run({ watch: true, debounce: 50 } as CliSyncOptions); + await flushWatchSetup(); + expect(process.listenerCount('SIGINT')).toBe(sigintBaseline + 1); + expect(process.listenerCount('SIGTERM')).toBe(sigtermBaseline + 1); + + process.emit('SIGINT'); + await firstRun; + + expect(process.listenerCount('SIGINT')).toBe(sigintBaseline); + expect(process.listenerCount('SIGTERM')).toBe(sigtermBaseline); + + // Second invocation should also add exactly one listener, then remove it. + const secondRun = command.run({ watch: true, debounce: 50 } as CliSyncOptions); + await flushWatchSetup(); + expect(process.listenerCount('SIGINT')).toBe(sigintBaseline + 1); + expect(process.listenerCount('SIGTERM')).toBe(sigtermBaseline + 1); + + process.emit('SIGTERM'); + await secondRun; + + expect(process.listenerCount('SIGINT')).toBe(sigintBaseline); + expect(process.listenerCount('SIGTERM')).toBe(sigtermBaseline); + }); + + it('should log the number of watched patterns', async () => { + const result = makeResult(); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + const sigintListeners: Array<() => void> = []; + const processOnSpy = jest.spyOn(process, 'on').mockImplementation(((event: string, listener: () => void) => { + if (event === 'SIGINT') { + sigintListeners.push(listener); + } + return process; + }) as unknown as ProcessOn); + + const runPromise = command.run({ watch: true, debounce: 100 } as CliSyncOptions); + + await flushWatchSetup(); + + for (const listener of sigintListeners) { + listener(); + } + + await runPromise; + + processOnSpy.mockRestore(); + + const infoOutput = logInfoSpy.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); + expect(infoOutput).toContain('Watching'); + expect(infoOutput).toContain('pattern(s)'); + }); + + // Watch mode previously reloaded+revalidated the sync config on every + // debounced change event. Config rarely changes during a watch session, + // so the reload is wasted work per tick. The cache invalidates on SIGHUP + // or when .deepl-sync.yaml itself is one of the changed files. + describe('config cache across watch ticks', () => { + function captureChangeHandlers(): Array<(p?: string) => void> { + const handlers: Array<(p?: string) => void> = []; + mockWatcherOn.mockImplementation((event: string, handler: (p?: string) => void) => { + if (event === 'change' || event === 'add') handlers.push(handler); + return mockWatcher; + }); + return handlers; + } + + it('loads sync config exactly once across 3 consecutive change events', async () => { + const result = makeResult(); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + const handlers = captureChangeHandlers(); + + const sigintListeners: Array<() => void> = []; + const processOnSpy = jest.spyOn(process, 'on').mockImplementation(((event: string, listener: () => void) => { + if (event === 'SIGINT') sigintListeners.push(listener); + return process; + }) as unknown as ProcessOn); + + const runPromise = command.run({ watch: true, debounce: 50 } as CliSyncOptions); + await flushWatchSetup(); + + const initialLoads = mockLoadSyncConfig.mock.calls.length; + expect(initialLoads).toBe(1); // pre-watch sync + + // Three debounced tick events — none touches the config file. + expect(handlers.length).toBeGreaterThan(0); + handlers[0]!('locales/en.json'); + await jest.advanceTimersByTimeAsync(60); + await flushWatchSetup(); + + handlers[0]!('locales/en.json'); + await jest.advanceTimersByTimeAsync(60); + await flushWatchSetup(); + + handlers[0]!('locales/en.json'); + await jest.advanceTimersByTimeAsync(60); + await flushWatchSetup(); + + expect(mockService.sync).toHaveBeenCalledTimes(4); // initial + 3 ticks + // Cache hit: no extra config loads after the initial one. + expect(mockLoadSyncConfig.mock.calls.length).toBe(initialLoads); + + for (const l of sigintListeners) l(); + await runPromise; + processOnSpy.mockRestore(); + }); + + it('reloads sync config when SIGHUP is received', async () => { + const result = makeResult(); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + const handlers = captureChangeHandlers(); + + const signalListeners: Record void>> = {}; + const processOnSpy = jest.spyOn(process, 'on').mockImplementation(((event: string, listener: () => void) => { + (signalListeners[event] ??= []).push(listener); + return process; + }) as unknown as ProcessOn); + + const runPromise = command.run({ watch: true, debounce: 50 } as CliSyncOptions); + await flushWatchSetup(); + + const initialLoads = mockLoadSyncConfig.mock.calls.length; + + // First tick — uses cache. + handlers[0]!('locales/en.json'); + await jest.advanceTimersByTimeAsync(60); + await flushWatchSetup(); + expect(mockLoadSyncConfig.mock.calls.length).toBe(initialLoads); + + // SIGHUP invalidates the cache. + expect(signalListeners['SIGHUP']?.length ?? 0).toBeGreaterThan(0); + for (const l of signalListeners['SIGHUP'] ?? []) l(); + + // Next tick must reload. + handlers[0]!('locales/en.json'); + await jest.advanceTimersByTimeAsync(60); + await flushWatchSetup(); + expect(mockLoadSyncConfig.mock.calls.length).toBe(initialLoads + 1); + + for (const l of signalListeners['SIGINT'] ?? []) l(); + await runPromise; + processOnSpy.mockRestore(); + }); + + it('reloads sync config when .deepl-sync.yaml is one of the changed files', async () => { + const result = makeResult(); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + const handlers = captureChangeHandlers(); + + const sigintListeners: Array<() => void> = []; + const processOnSpy = jest.spyOn(process, 'on').mockImplementation(((event: string, listener: () => void) => { + if (event === 'SIGINT') sigintListeners.push(listener); + return process; + }) as unknown as ProcessOn); + + const runPromise = command.run({ watch: true, debounce: 50 } as CliSyncOptions); + await flushWatchSetup(); + + const initialLoads = mockLoadSyncConfig.mock.calls.length; + + // Event carries the config-file path — the reload must fire. + handlers[0]!('/test/.deepl-sync.yaml'); + await jest.advanceTimersByTimeAsync(60); + await flushWatchSetup(); + + expect(mockLoadSyncConfig.mock.calls.length).toBe(initialLoads + 1); + + // A subsequent unrelated tick is again served from cache. + handlers[0]!('locales/en.json'); + await jest.advanceTimersByTimeAsync(60); + await flushWatchSetup(); + expect(mockLoadSyncConfig.mock.calls.length).toBe(initialLoads + 1); + + for (const l of sigintListeners) l(); + await runPromise; + processOnSpy.mockRestore(); + }); + + it('includes the .deepl-sync.yaml config file itself in the watched paths', async () => { + const result = makeResult(); + const mockService = createMockSyncService(result); + const command = new SyncCommand(mockService); + + captureChangeHandlers(); + + const sigintListeners: Array<() => void> = []; + const processOnSpy = jest.spyOn(process, 'on').mockImplementation(((event: string, listener: () => void) => { + if (event === 'SIGINT') sigintListeners.push(listener); + return process; + }) as unknown as ProcessOn); + + const runPromise = command.run({ watch: true, debounce: 50 } as CliSyncOptions); + await flushWatchSetup(); + + const watchedPaths = mockWatch.mock.calls[0]![0] as string[]; + expect(watchedPaths).toContain('/test/.deepl-sync.yaml'); + + for (const l of sigintListeners) l(); + await runPromise; + processOnSpy.mockRestore(); + }); + }); + }); +}); diff --git a/tests/unit/sync/sync-config.test.ts b/tests/unit/sync/sync-config.test.ts new file mode 100644 index 0000000..a726851 --- /dev/null +++ b/tests/unit/sync/sync-config.test.ts @@ -0,0 +1,1454 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + findSyncConfigFile, + loadSyncConfig, + validateSyncConfig, + applyCliOverrides, + SYNC_CONFIG_FILENAME, +} from '../../../src/sync/sync-config'; +import type { SyncConfig } from '../../../src/sync/types'; +import { ConfigError, ValidationError } from '../../../src/utils/errors'; + +const FIXTURES_DIR = path.resolve(__dirname, '../../fixtures/sync/configs'); + +describe('sync-config', () => { + describe('findSyncConfigFile', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-sync-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should find config in current directory', () => { + const configPath = path.join(tmpDir, SYNC_CONFIG_FILENAME); + fs.writeFileSync(configPath, 'version: 1\n'); + + const result = findSyncConfigFile(tmpDir); + expect(result).toBe(configPath); + }); + + it('should find config in parent directory', () => { + const configPath = path.join(tmpDir, SYNC_CONFIG_FILENAME); + fs.writeFileSync(configPath, 'version: 1\n'); + + const childDir = path.join(tmpDir, 'src', 'deep'); + fs.mkdirSync(childDir, { recursive: true }); + + const result = findSyncConfigFile(childDir); + expect(result).toBe(configPath); + }); + + it('should return null when no config is found', () => { + const result = findSyncConfigFile(tmpDir); + expect(result).toBeNull(); + }); + }); + + describe('validateSyncConfig', () => { + it('should accept a valid config', () => { + const raw = { + version: 1, + source_locale: 'en', + target_locales: ['de', 'fr'], + buckets: { + json: { include: ['locales/en.json'] }, + }, + }; + + const result = validateSyncConfig(raw); + expect(result.version).toBe(1); + expect(result.source_locale).toBe('en'); + expect(result.target_locales).toEqual(['de', 'fr']); + expect(result.buckets['json']?.include).toEqual(['locales/en.json']); + }); + + it('should throw when raw is not an object', () => { + expect(() => validateSyncConfig('not-an-object')).toThrow(ConfigError); + expect(() => validateSyncConfig('not-an-object')).toThrow('must be a YAML object'); + }); + + it('should throw when raw is null', () => { + expect(() => validateSyncConfig(null)).toThrow(ConfigError); + }); + + it('should throw when raw is an array', () => { + expect(() => validateSyncConfig([])).toThrow(ConfigError); + }); + + it('should throw when version is missing', () => { + expect(() => validateSyncConfig({ + source_locale: 'en', + target_locales: ['de'], + buckets: { json: { include: ['a.json'] } }, + })).toThrow('missing required field: version'); + }); + + it('should throw when version is not 1', () => { + expect(() => validateSyncConfig({ + version: 2, + source_locale: 'en', + target_locales: ['de'], + buckets: { json: { include: ['a.json'] } }, + })).toThrow('Unsupported sync config version: 2'); + }); + + it('should throw when source_locale is missing', () => { + expect(() => validateSyncConfig({ + version: 1, + target_locales: ['de'], + buckets: { json: { include: ['a.json'] } }, + })).toThrow('missing required field: source_locale'); + }); + + it('should throw when source_locale is empty string', () => { + expect(() => validateSyncConfig({ + version: 1, + source_locale: ' ', + target_locales: ['de'], + buckets: { json: { include: ['a.json'] } }, + })).toThrow('missing required field: source_locale'); + }); + + it('should reject source_locale containing path traversal', () => { + expect(() => validateSyncConfig({ + version: 1, + source_locale: '../evil', + target_locales: ['de'], + buckets: { json: { include: ['a.json'] } }, + })).toThrow('Invalid source locale "../evil"'); + }); + + it('should reject source_locale containing forward slash', () => { + expect(() => validateSyncConfig({ + version: 1, + source_locale: 'en/US', + target_locales: ['de'], + buckets: { json: { include: ['a.json'] } }, + })).toThrow('Invalid source locale "en/US"'); + }); + + it('should reject source_locale containing backslash', () => { + expect(() => validateSyncConfig({ + version: 1, + source_locale: 'en\\US', + target_locales: ['de'], + buckets: { json: { include: ['a.json'] } }, + })).toThrow('Invalid source locale "en\\US"'); + }); + + it('should reject target_locales containing path traversal', () => { + expect(() => validateSyncConfig({ + version: 1, + source_locale: 'en', + target_locales: ['../../tmp/evil'], + buckets: { json: { include: ['a.json'] } }, + })).toThrow('Invalid target locale "../../tmp/evil"'); + }); + + it('should reject target_locales with ../../etc/passwd traversal', () => { + expect(() => validateSyncConfig({ + version: 1, + source_locale: 'en', + target_locales: ['../../etc/passwd'], + buckets: { json: { include: ['a.json'] } }, + })).toThrow(ConfigError); + expect(() => validateSyncConfig({ + version: 1, + source_locale: 'en', + target_locales: ['../../etc/passwd'], + buckets: { json: { include: ['a.json'] } }, + })).toThrow(/path separators/); + }); + + it('should reject target_locales with en/../../tmp traversal', () => { + expect(() => validateSyncConfig({ + version: 1, + source_locale: 'en', + target_locales: ['en/../../tmp'], + buckets: { json: { include: ['a.json'] } }, + })).toThrow(ConfigError); + expect(() => validateSyncConfig({ + version: 1, + source_locale: 'en', + target_locales: ['en/../../tmp'], + buckets: { json: { include: ['a.json'] } }, + })).toThrow(/path separators/); + }); + + it('should reject source_locale with ../evil traversal', () => { + expect(() => validateSyncConfig({ + version: 1, + source_locale: '../evil', + target_locales: ['de'], + buckets: { json: { include: ['a.json'] } }, + })).toThrow(ConfigError); + expect(() => validateSyncConfig({ + version: 1, + source_locale: '../evil', + target_locales: ['de'], + buckets: { json: { include: ['a.json'] } }, + })).toThrow(/path separators/); + }); + + it('should throw when target_locales is empty', () => { + expect(() => validateSyncConfig({ + version: 1, + source_locale: 'en', + target_locales: [], + buckets: { json: { include: ['a.json'] } }, + })).toThrow('target_locales must be a non-empty array'); + }); + + it('should throw when target_locales is not an array', () => { + expect(() => validateSyncConfig({ + version: 1, + source_locale: 'en', + target_locales: 'de', + buckets: { json: { include: ['a.json'] } }, + })).toThrow('target_locales must be a non-empty array'); + }); + + it('should throw when buckets is empty', () => { + expect(() => validateSyncConfig({ + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: {}, + })).toThrow('buckets must be a non-empty object'); + }); + + it('should throw when buckets is missing', () => { + expect(() => validateSyncConfig({ + version: 1, + source_locale: 'en', + target_locales: ['de'], + })).toThrow('buckets must be a non-empty object'); + }); + + it('should throw when bucket is missing include', () => { + expect(() => validateSyncConfig({ + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { json: {} }, + })).toThrow('bucket "json" must have a non-empty include array'); + }); + + it('should throw when bucket include is empty', () => { + expect(() => validateSyncConfig({ + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { json: { include: [] } }, + })).toThrow('bucket "json" must have a non-empty include array'); + }); + + it('should preserve translation block through validation', () => { + const raw = { + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { json: { include: ['locales/en.json'] } }, + translation: { formality: 'more', glossary: 'auto' }, + }; + + const result = validateSyncConfig(raw); + expect(result.translation).toEqual({ formality: 'more', glossary: 'auto' }); + }); + + it('should preserve tms block through validation', () => { + const raw = { + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { json: { include: ['locales/en.json'] } }, + tms: { enabled: true, server: 'https://example.com', project_id: 'test' }, + }; + + const result = validateSyncConfig(raw); + expect(result.tms).toEqual({ enabled: true, server: 'https://example.com', project_id: 'test' }); + }); + + it('should preserve validation, sync, and ignore blocks', () => { + const raw = { + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { json: { include: ['locales/en.json'] } }, + validation: { check_placeholders: true, fail_on_error: false }, + sync: { concurrency: 10, batch_size: 25 }, + ignore: ['*.bak', 'tmp/**'], + }; + + const result = validateSyncConfig(raw); + expect(result.validation).toEqual({ check_placeholders: true, fail_on_error: false }); + expect(result.sync).toEqual({ concurrency: 10, batch_size: 25 }); + expect(result.ignore).toEqual(['*.bak', 'tmp/**']); + }); + + describe('sync.limits', () => { + const baseConfig = { + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { json: { include: ['a.json'] } }, + }; + + it('accepts valid limits within the hard-max ceiling', () => { + const result = validateSyncConfig({ + ...baseConfig, + sync: { + concurrency: 5, + batch_size: 50, + limits: { + max_entries_per_file: 50_000, + max_file_bytes: 8 * 1024 * 1024, + max_depth: 48, + }, + }, + }); + expect(result.sync?.limits).toEqual({ + max_entries_per_file: 50_000, + max_file_bytes: 8 * 1024 * 1024, + max_depth: 48, + }); + }); + + it('accepts omitted or partial limits blocks', () => { + expect(() => + validateSyncConfig({ + ...baseConfig, + sync: { concurrency: 5, batch_size: 50 }, + }), + ).not.toThrow(); + expect(() => + validateSyncConfig({ + ...baseConfig, + sync: { concurrency: 5, batch_size: 50, limits: { max_depth: 16 } }, + }), + ).not.toThrow(); + }); + + it('rejects max_entries_per_file above the 100_000 ceiling with ConfigError', () => { + expect(() => + validateSyncConfig({ + ...baseConfig, + sync: { + concurrency: 5, + batch_size: 50, + limits: { max_entries_per_file: 150_000 }, + }, + }), + ).toThrow(ConfigError); + }); + + it('rejects max_file_bytes above the 10 MiB ceiling with ConfigError', () => { + expect(() => + validateSyncConfig({ + ...baseConfig, + sync: { + concurrency: 5, + batch_size: 50, + limits: { max_file_bytes: 20 * 1024 * 1024 }, + }, + }), + ).toThrow(ConfigError); + }); + + it('rejects max_depth above the 64 ceiling with ConfigError', () => { + expect(() => + validateSyncConfig({ + ...baseConfig, + sync: { + concurrency: 5, + batch_size: 50, + limits: { max_depth: 128 }, + }, + }), + ).toThrow(ConfigError); + }); + + it('rejects non-integer limit values with ConfigError', () => { + expect(() => + validateSyncConfig({ + ...baseConfig, + sync: { + concurrency: 5, + batch_size: 50, + limits: { max_depth: 16.5 }, + }, + }), + ).toThrow(ConfigError); + }); + + it('rejects zero or negative limits with ConfigError', () => { + expect(() => + validateSyncConfig({ + ...baseConfig, + sync: { + concurrency: 5, + batch_size: 50, + limits: { max_depth: 0 }, + }, + }), + ).toThrow(ConfigError); + }); + + it('rejects unknown keys inside sync.limits with ConfigError', () => { + expect(() => + validateSyncConfig({ + ...baseConfig, + sync: { + concurrency: 5, + batch_size: 50, + limits: { max_unknown: 10 }, + }, + }), + ).toThrow(ConfigError); + }); + + it('rejects sync.limits that is not an object', () => { + expect(() => + validateSyncConfig({ + ...baseConfig, + sync: { concurrency: 5, batch_size: 50, limits: 'not an object' }, + }), + ).toThrow(ConfigError); + }); + }); + + it('should throw when translation is a boolean instead of object', () => { + expect(() => validateSyncConfig({ + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { json: { include: ['a.json'] } }, + translation: true, + })).toThrow(ConfigError); + expect(() => validateSyncConfig({ + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { json: { include: ['a.json'] } }, + translation: true, + })).toThrow('translation must be an object'); + }); + + it('should throw when translation is an array', () => { + expect(() => validateSyncConfig({ + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { json: { include: ['a.json'] } }, + translation: [1, 2], + })).toThrow(ConfigError); + expect(() => validateSyncConfig({ + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { json: { include: ['a.json'] } }, + translation: [1, 2], + })).toThrow('translation must be an object'); + }); + + it('should throw when translation is null', () => { + expect(() => validateSyncConfig({ + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { json: { include: ['a.json'] } }, + translation: null, + })).toThrow(ConfigError); + expect(() => validateSyncConfig({ + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { json: { include: ['a.json'] } }, + translation: null, + })).toThrow('translation must be an object'); + }); + + it('should throw when validation is a string instead of object', () => { + expect(() => validateSyncConfig({ + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { json: { include: ['a.json'] } }, + validation: 'invalid', + })).toThrow(ConfigError); + expect(() => validateSyncConfig({ + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { json: { include: ['a.json'] } }, + validation: 'invalid', + })).toThrow('validation must be an object'); + }); + + it('should accept valid target_path_pattern with {locale}', () => { + const result = validateSyncConfig({ + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { android_xml: { include: ['res/values/strings.xml'], target_path_pattern: 'res/values-{locale}/strings.xml' } }, + }); + expect(result.buckets['android_xml']!.target_path_pattern).toBe('res/values-{locale}/strings.xml'); + }); + + it('should throw when target_path_pattern is not a string', () => { + expect(() => validateSyncConfig({ + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { json: { include: ['a.json'], target_path_pattern: 42 } }, + })).toThrow('target_path_pattern must be a string'); + }); + + it('should throw when target_path_pattern lacks {locale}', () => { + expect(() => validateSyncConfig({ + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { json: { include: ['a.json'], target_path_pattern: 'res/values/strings.xml' } }, + })).toThrow('target_path_pattern must contain {locale} placeholder'); + }); + + it('should throw when target_path_pattern contains ".."', () => { + expect(() => validateSyncConfig({ + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { json: { include: ['a.json'], target_path_pattern: '../{locale}/strings.xml' } }, + })).toThrow('target_path_pattern must not contain ".."'); + }); + + describe('translation_memory_threshold validation', () => { + const baseConfig = { + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { json: { include: ['a.json'] } }, + }; + + it('should reject top-level translation_memory_threshold above 100', () => { + expect(() => validateSyncConfig({ + ...baseConfig, + translation: { translation_memory_threshold: 9999 }, + })).toThrow(ConfigError); + expect(() => validateSyncConfig({ + ...baseConfig, + translation: { translation_memory_threshold: 9999 }, + })).toThrow('translation.translation_memory_threshold must be an integer between 0 and 100, got: 9999'); + }); + + it('should reject negative top-level translation_memory_threshold', () => { + expect(() => validateSyncConfig({ + ...baseConfig, + translation: { translation_memory_threshold: -1 }, + })).toThrow('translation.translation_memory_threshold must be an integer between 0 and 100, got: -1'); + }); + + it('should reject non-integer top-level translation_memory_threshold', () => { + expect(() => validateSyncConfig({ + ...baseConfig, + translation: { translation_memory_threshold: 50.5 }, + })).toThrow('translation.translation_memory_threshold must be an integer between 0 and 100, got: 50.5'); + }); + + it('should reject non-numeric top-level translation_memory_threshold', () => { + expect(() => validateSyncConfig({ + ...baseConfig, + translation: { translation_memory_threshold: 'abc' }, + })).toThrow('translation.translation_memory_threshold must be an integer between 0 and 100, got: abc'); + }); + + it('should accept top-level translation_memory_threshold within range', () => { + const result = validateSyncConfig({ + ...baseConfig, + translation: { translation_memory_threshold: 75 }, + }); + expect(result.translation?.translation_memory_threshold).toBe(75); + }); + + it('should accept boundary value 0 for translation_memory_threshold', () => { + const result = validateSyncConfig({ + ...baseConfig, + translation: { translation_memory_threshold: 0 }, + }); + expect(result.translation?.translation_memory_threshold).toBe(0); + }); + + it('should reject per-locale translation_memory_threshold above 100 with locale key path', () => { + expect(() => validateSyncConfig({ + ...baseConfig, + translation: { + locale_overrides: { + de: { translation_memory_threshold: 200 }, + }, + }, + })).toThrow('translation.locale_overrides.de.translation_memory_threshold must be an integer between 0 and 100, got: 200'); + }); + + it('should accept per-locale translation_memory_threshold within range', () => { + const result = validateSyncConfig({ + ...baseConfig, + translation: { + locale_overrides: { + de: { translation_memory_threshold: 90 }, + }, + }, + }); + expect(result.translation?.locale_overrides?.['de']?.translation_memory_threshold).toBe(90); + }); + + it('should accept missing (undefined) translation_memory_threshold', () => { + const result = validateSyncConfig({ + ...baseConfig, + translation: { glossary: 'my-glossary' }, + }); + expect(result.translation?.translation_memory_threshold).toBeUndefined(); + }); + }); + + describe('translation_memory + model_type pairing validation', () => { + const baseConfig = { + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { json: { include: ['a.json'] } }, + }; + + it('should accept translation_memory without model_type', () => { + const result = validateSyncConfig({ + ...baseConfig, + translation: { translation_memory: 'my-tm' }, + }); + expect(result.translation?.translation_memory).toBe('my-tm'); + }); + + it('should accept translation_memory with model_type: quality_optimized', () => { + const result = validateSyncConfig({ + ...baseConfig, + translation: { translation_memory: 'my-tm', model_type: 'quality_optimized' }, + }); + expect(result.translation?.model_type).toBe('quality_optimized'); + }); + + it('should reject translation_memory with model_type: latency_optimized', () => { + expect(() => validateSyncConfig({ + ...baseConfig, + translation: { translation_memory: 'my-tm', model_type: 'latency_optimized' }, + })).toThrow(ConfigError); + expect(() => validateSyncConfig({ + ...baseConfig, + translation: { translation_memory: 'my-tm', model_type: 'latency_optimized' }, + })).toThrow( + "translation.model_type must be 'quality_optimized' when translation_memory is set, got: latency_optimized", + ); + }); + + it('should reject translation_memory with model_type: prefer_quality_optimized', () => { + expect(() => validateSyncConfig({ + ...baseConfig, + translation: { translation_memory: 'my-tm', model_type: 'prefer_quality_optimized' }, + })).toThrow( + "translation.model_type must be 'quality_optimized' when translation_memory is set, got: prefer_quality_optimized", + ); + }); + + it('should reject per-locale override with latency_optimized when TM set at top level', () => { + expect(() => validateSyncConfig({ + ...baseConfig, + translation: { + translation_memory: 'my-tm', + model_type: 'quality_optimized', + locale_overrides: { + de: { model_type: 'latency_optimized' }, + }, + }, + })).toThrow( + "translation.locale_overrides.de.model_type must be 'quality_optimized' when translation_memory is set, got: latency_optimized", + ); + }); + + it('should reject per-locale override with latency_optimized when TM set in the same override', () => { + expect(() => validateSyncConfig({ + ...baseConfig, + translation: { + model_type: 'quality_optimized', + locale_overrides: { + de: { translation_memory: 'de-tm', model_type: 'latency_optimized' }, + }, + }, + })).toThrow( + "translation.locale_overrides.de.model_type must be 'quality_optimized' when translation_memory is set, got: latency_optimized", + ); + }); + + it('should accept model_type: latency_optimized when translation_memory is not set', () => { + const result = validateSyncConfig({ + ...baseConfig, + translation: { model_type: 'latency_optimized' }, + }); + expect(result.translation?.model_type).toBe('latency_optimized'); + }); + }); + + describe('strict unknown-field rejection', () => { + const baseConfig = { + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { json: { include: ['a.json'] } }, + }; + + it('should reject unknown top-level field and name it in the error', () => { + const raw = { ...baseConfig, target_locale: 'en' }; + expect(() => validateSyncConfig(raw)).toThrow(ConfigError); + expect(() => validateSyncConfig(raw)).toThrow(/Unknown field "target_locale"/); + }); + + it('should suggest target_locales when user typed target_locale', () => { + const raw = { ...baseConfig, target_locale: 'en' }; + try { + validateSyncConfig(raw); + fail('expected throw'); + } catch (err) { + expect(err).toBeInstanceOf(ConfigError); + expect((err as ConfigError).suggestion).toMatch(/target_locales/); + } + }); + + it('should suggest buckets when user typed bucket', () => { + const raw = { + version: 1, + source_locale: 'en', + target_locales: ['de'], + bucket: { json: { include: ['a.json'] } }, + }; + try { + validateSyncConfig(raw); + fail('expected throw'); + } catch (err) { + expect(err).toBeInstanceOf(ConfigError); + expect((err as ConfigError).message).toMatch(/Unknown field "bucket"/); + expect((err as ConfigError).suggestion).toMatch(/buckets/); + } + }); + + it('should suggest translation when user typed translate', () => { + const raw = { ...baseConfig, translate: { formality: 'more' } }; + try { + validateSyncConfig(raw); + fail('expected throw'); + } catch (err) { + expect(err).toBeInstanceOf(ConfigError); + expect((err as ConfigError).message).toMatch(/Unknown field "translate"/); + expect((err as ConfigError).suggestion).toMatch(/translation/); + } + }); + + it('should reject unknown bucket-level field with bucket path in context', () => { + const raw = { + ...baseConfig, + buckets: { json: { includes: ['a.json'] } }, + }; + expect(() => validateSyncConfig(raw)).toThrow(ConfigError); + expect(() => validateSyncConfig(raw)).toThrow(/Unknown field "includes"/); + expect(() => validateSyncConfig(raw)).toThrow(/buckets\.json/); + }); + + it('should suggest include when user typed includes at bucket level', () => { + const raw = { + ...baseConfig, + buckets: { json: { includes: ['a.json'] } }, + }; + try { + validateSyncConfig(raw); + fail('expected throw'); + } catch (err) { + expect(err).toBeInstanceOf(ConfigError); + expect((err as ConfigError).suggestion).toMatch(/\binclude\b/); + } + }); + + it('should reject unknown translation field', () => { + const raw = { + ...baseConfig, + translation: { formalness: 'more' }, + }; + expect(() => validateSyncConfig(raw)).toThrow(ConfigError); + expect(() => validateSyncConfig(raw)).toThrow(/Unknown field "formalness"/); + expect(() => validateSyncConfig(raw)).toThrow(/translation/); + }); + + it('should reject unknown tms field and suggest api_key for apikey typo', () => { + const raw = { + ...baseConfig, + tms: { + enabled: true, + server: 'https://example.com', + project_id: 'test', + apikey: 'secret', + }, + }; + try { + validateSyncConfig(raw); + fail('expected throw'); + } catch (err) { + expect(err).toBeInstanceOf(ConfigError); + expect((err as ConfigError).message).toMatch(/Unknown field "apikey"/); + expect((err as ConfigError).message).toMatch(/tms/); + expect((err as ConfigError).suggestion).toMatch(/api_key/); + } + }); + + it('should reject unknown field inside locale_overrides.', () => { + const raw = { + ...baseConfig, + translation: { + locale_overrides: { + de: { formalness: 'more' }, + }, + }, + }; + expect(() => validateSyncConfig(raw)).toThrow(ConfigError); + expect(() => validateSyncConfig(raw)).toThrow(/Unknown field "formalness"/); + expect(() => validateSyncConfig(raw)).toThrow(/locale_overrides\.de/); + }); + + it('should reject unknown validation field', () => { + const raw = { + ...baseConfig, + validation: { fail_on_missings: true }, + }; + expect(() => validateSyncConfig(raw)).toThrow(ConfigError); + expect(() => validateSyncConfig(raw)).toThrow(/Unknown field "fail_on_missings"/); + }); + + it('should reject unknown sync-behavior field', () => { + const raw = { + ...baseConfig, + sync: { concurrency: 5, batch_size: 50, maxchars: 1000 }, + }; + expect(() => validateSyncConfig(raw)).toThrow(ConfigError); + expect(() => validateSyncConfig(raw)).toThrow(/Unknown field "maxchars"/); + }); + + it('should reject unknown context field', () => { + const raw = { + ...baseConfig, + context: { enabled: true, scanpaths: ['src/**/*.ts'] }, + }; + expect(() => validateSyncConfig(raw)).toThrow(ConfigError); + expect(() => validateSyncConfig(raw)).toThrow(/Unknown field "scanpaths"/); + }); + + it('should accept a fully-populated valid config with all known keys', () => { + const raw = { + version: 1, + source_locale: 'en', + target_locales: ['de', 'fr'], + buckets: { + json: { + include: ['locales/en.json'], + exclude: ['locales/en/_generated.json'], + key_style: 'nested' as const, + target_path_pattern: 'locales/{locale}.json', + }, + }, + translation: { + formality: 'more' as const, + model_type: 'quality_optimized' as const, + glossary: 'g', + translation_memory: 'tm', + translation_memory_threshold: 80, + custom_instructions: ['be concise'], + style_id: 's', + instruction_templates: { button: 'short' }, + length_limits: { enabled: true, expansion_factors: { de: 1.3 } }, + locale_overrides: { + de: { + formality: 'more' as const, + glossary: 'g-de', + translation_memory: 'tm', + translation_memory_threshold: 75, + custom_instructions: ['tone'], + style_id: 's-de', + model_type: 'quality_optimized' as const, + }, + }, + }, + context: { + enabled: true, + scan_paths: ['src/**/*.ts'], + function_names: ['t'], + context_lines: 3, + overrides: { save: 'Save button' }, + }, + validation: { + check_placeholders: true, + fail_on_error: false, + validate_after_sync: true, + fail_on_missing: true, + fail_on_stale: true, + }, + sync: { + concurrency: 5, + batch_size: 50, + max_characters: 100000, + backup: true, + batch: false, + }, + ignore: ['*.bak'], + tms: { + enabled: true, + server: 'https://example.com', + project_id: 'test', + api_key: 'secret', + token: 'bearer', + auto_push: false, + auto_pull: false, + require_review: ['de'], + timeout_ms: 30000, + }, + }; + expect(() => validateSyncConfig(raw)).not.toThrow(); + }); + }); + + describe('ConfigError suggestion contract', () => { + const malformedConfigs: ReadonlyArray<{ label: string; raw: unknown }> = [ + { label: 'non-object root', raw: 'not-an-object' }, + { label: 'null root', raw: null }, + { label: 'array root', raw: [] }, + { + label: 'missing version', + raw: { source_locale: 'en', target_locales: ['de'], buckets: { json: { include: ['a.json'] } } }, + }, + { + label: 'wrong version', + raw: { version: 2, source_locale: 'en', target_locales: ['de'], buckets: { json: { include: ['a.json'] } } }, + }, + { + label: 'missing source_locale', + raw: { version: 1, target_locales: ['de'], buckets: { json: { include: ['a.json'] } } }, + }, + { + label: 'source_locale with path separator', + raw: { version: 1, source_locale: 'en/US', target_locales: ['de'], buckets: { json: { include: ['a.json'] } } }, + }, + { + label: 'target_locales not array', + raw: { version: 1, source_locale: 'en', target_locales: 'de', buckets: { json: { include: ['a.json'] } } }, + }, + { + label: 'target_locales contains non-string', + raw: { version: 1, source_locale: 'en', target_locales: [42], buckets: { json: { include: ['a.json'] } } }, + }, + { + label: 'target locale with path traversal', + raw: { version: 1, source_locale: 'en', target_locales: ['../evil'], buckets: { json: { include: ['a.json'] } } }, + }, + { + label: 'target_locales contains source_locale', + raw: { version: 1, source_locale: 'en', target_locales: ['en'], buckets: { json: { include: ['a.json'] } } }, + }, + { + label: 'missing buckets', + raw: { version: 1, source_locale: 'en', target_locales: ['de'] }, + }, + { + label: 'empty buckets object', + raw: { version: 1, source_locale: 'en', target_locales: ['de'], buckets: {} }, + }, + { + label: 'bucket not an object', + raw: { version: 1, source_locale: 'en', target_locales: ['de'], buckets: { json: 'oops' } }, + }, + { + label: 'bucket missing include', + raw: { version: 1, source_locale: 'en', target_locales: ['de'], buckets: { json: {} } }, + }, + { + label: 'bucket include contains non-string', + raw: { version: 1, source_locale: 'en', target_locales: ['de'], buckets: { json: { include: [42] } } }, + }, + { + label: 'target_path_pattern wrong type', + raw: { version: 1, source_locale: 'en', target_locales: ['de'], buckets: { json: { include: ['a.json'], target_path_pattern: 42 } } }, + }, + { + label: 'target_path_pattern missing {locale}', + raw: { version: 1, source_locale: 'en', target_locales: ['de'], buckets: { json: { include: ['a.json'], target_path_pattern: 'res/strings.xml' } } }, + }, + { + label: 'target_path_pattern with ..', + raw: { version: 1, source_locale: 'en', target_locales: ['de'], buckets: { json: { include: ['a.json'], target_path_pattern: '../{locale}/strings.xml' } } }, + }, + { + label: 'translation block not an object', + raw: { version: 1, source_locale: 'en', target_locales: ['de'], buckets: { json: { include: ['a.json'] } }, translation: true }, + }, + { + label: 'translation_memory_threshold out of range', + raw: { + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { json: { include: ['a.json'] } }, + translation: { translation_memory_threshold: 9999 }, + }, + }, + { + label: 'translation_memory + incompatible model_type', + raw: { + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { json: { include: ['a.json'] } }, + translation: { translation_memory: 'tm', model_type: 'latency_optimized' }, + }, + }, + ]; + + it.each(malformedConfigs)('every ConfigError from $label includes a suggestion', ({ raw }) => { + let caught: unknown; + try { + validateSyncConfig(raw); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(ConfigError); + const err = caught as ConfigError; + expect(typeof err.suggestion).toBe('string'); + expect((err.suggestion ?? '').length).toBeGreaterThan(0); + }); + + it('suggestion for missing source_locale mentions source_locale', () => { + try { + validateSyncConfig({ version: 1, target_locales: ['de'], buckets: { json: { include: ['a.json'] } } }); + fail('expected throw'); + } catch (err) { + expect(err).toBeInstanceOf(ConfigError); + expect((err as ConfigError).suggestion).toMatch(/source_locale/); + } + }); + + it('suggestion for bucket missing include mentions include', () => { + try { + validateSyncConfig({ version: 1, source_locale: 'en', target_locales: ['de'], buckets: { json: {} } }); + fail('expected throw'); + } catch (err) { + expect(err).toBeInstanceOf(ConfigError); + expect((err as ConfigError).suggestion).toMatch(/include/); + } + }); + + it('suggestion for target_path_pattern missing placeholder mentions {locale}', () => { + try { + validateSyncConfig({ + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { json: { include: ['a.json'], target_path_pattern: 'res/strings.xml' } }, + }); + fail('expected throw'); + } catch (err) { + expect(err).toBeInstanceOf(ConfigError); + expect((err as ConfigError).suggestion).toMatch(/\{locale\}/); + } + }); + }); + }); + + describe('loadSyncConfig', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-sync-load-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should load and parse a valid YAML config', async () => { + const configPath = path.join(FIXTURES_DIR, 'valid.yaml'); + const result = await loadSyncConfig(undefined, { configPath }); + + expect(result.version).toBe(1); + expect(result.source_locale).toBe('en'); + expect(result.target_locales).toEqual(['de', 'fr']); + expect(result.buckets['json']?.include).toEqual(['locales/en.json']); + expect(result.configPath).toBe(configPath); + expect(result.projectRoot).toBe(path.dirname(configPath)); + }); + + it('should throw ConfigError when file is not found', async () => { + await expect( + loadSyncConfig(tmpDir), + ).rejects.toThrow(ConfigError); + }); + + it('should throw ConfigError when configPath override points to missing file', async () => { + await expect( + loadSyncConfig(undefined, { configPath: '/nonexistent/path/.deepl-sync.yaml' }), + ).rejects.toThrow(ConfigError); + }); + + it('should throw ConfigError with exit code 7 for malformed YAML', async () => { + const badYaml = path.join(tmpDir, SYNC_CONFIG_FILENAME); + fs.writeFileSync(badYaml, '{{{{invalid yaml'); + + await expect( + loadSyncConfig(tmpDir), + ).rejects.toThrow(ConfigError); + + try { + await loadSyncConfig(tmpDir); + fail('expected loadSyncConfig to throw'); + } catch (err) { + expect(err).toBeInstanceOf(ConfigError); + expect(err).not.toBeInstanceOf(ValidationError); + expect((err as ConfigError).exitCode).toBe(7); + expect((err as ConfigError).message).toContain('Failed to parse YAML'); + } + }); + + it('should use configPath override when provided', async () => { + const configPath = path.join(FIXTURES_DIR, 'minimal.yaml'); + const result = await loadSyncConfig('/some/other/dir', { configPath }); + + expect(result.source_locale).toBe('en'); + expect(result.configPath).toBe(configPath); + }); + + it('should merge overrides into the resolved config', async () => { + const configPath = path.join(FIXTURES_DIR, 'valid.yaml'); + const overrides = { frozen: true, dryRun: true, configPath }; + const result = await loadSyncConfig(undefined, overrides); + + expect(result.overrides.frozen).toBe(true); + expect(result.overrides.dryRun).toBe(true); + }); + + it('should load multi-bucket config', async () => { + const configPath = path.join(FIXTURES_DIR, 'multi-bucket.yaml'); + const result = await loadSyncConfig(undefined, { configPath }); + + expect(Object.keys(result.buckets)).toEqual(['json', 'yaml']); + expect(result.buckets['yaml']?.include).toEqual(['config/*.yaml', 'i18n/*.yml']); + }); + + it('should merge formality CLI override into existing translation block', async () => { + const configYaml = `version: 1 +source_locale: en +target_locales: [de] +buckets: + json: + include: ['locales/en.json'] +translation: + glossary: my-glossary +`; + const configPath = path.join(tmpDir, SYNC_CONFIG_FILENAME); + fs.writeFileSync(configPath, configYaml); + + const result = await loadSyncConfig(tmpDir, { formality: 'more' }); + expect(result.translation?.formality).toBe('more'); + expect(result.translation?.glossary).toBe('my-glossary'); + }); + + it('should create translation block when CLI override needs it', async () => { + const configPath = path.join(FIXTURES_DIR, 'valid.yaml'); + const result = await loadSyncConfig(undefined, { configPath, formality: 'less' }); + expect(result.translation?.formality).toBe('less'); + }); + + it('should merge glossary CLI override into translation block', async () => { + const configPath = path.join(FIXTURES_DIR, 'valid.yaml'); + const result = await loadSyncConfig(undefined, { configPath, glossary: 'my-glossary' }); + expect(result.translation?.glossary).toBe('my-glossary'); + }); + + it('should merge modelType CLI override into translation block', async () => { + const configPath = path.join(FIXTURES_DIR, 'valid.yaml'); + const result = await loadSyncConfig(undefined, { configPath, modelType: 'quality_optimized' }); + expect(result.translation?.model_type).toBe('quality_optimized'); + }); + + it('should merge context CLI override into context block', async () => { + const configPath = path.join(FIXTURES_DIR, 'valid.yaml'); + const result = await loadSyncConfig(undefined, { configPath, context: true }); + expect(result.context?.enabled).toBe(true); + }); + }); + + describe('applyCliOverrides', () => { + const baseConfig = (): SyncConfig => ({ + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { json: { include: ['locales/en.json'] } }, + }); + + it('should merge formality into existing translation block', () => { + const config = baseConfig(); + config.translation = { glossary: 'g' }; + const result = applyCliOverrides(config, { formality: 'more' }); + expect(result.translation?.formality).toBe('more'); + expect(result.translation?.glossary).toBe('g'); + }); + + it('should create translation block when only formality override is supplied', () => { + const result = applyCliOverrides(baseConfig(), { formality: 'less' }); + expect(result.translation?.formality).toBe('less'); + }); + + it('should merge glossary into translation block', () => { + const result = applyCliOverrides(baseConfig(), { glossary: 'my-g' }); + expect(result.translation?.glossary).toBe('my-g'); + }); + + it('should merge modelType into translation block', () => { + const result = applyCliOverrides(baseConfig(), { modelType: 'quality_optimized' }); + expect(result.translation?.model_type).toBe('quality_optimized'); + }); + + it('should merge context enabled into context block', () => { + const result = applyCliOverrides(baseConfig(), { context: true }); + expect(result.context?.enabled).toBe(true); + }); + + it('should preserve existing context block when overriding enabled', () => { + const config = baseConfig(); + config.context = { enabled: false, scan_paths: ['src/**/*.ts'] }; + const result = applyCliOverrides(config, { context: true }); + expect(result.context?.enabled).toBe(true); + expect(result.context?.scan_paths).toEqual(['src/**/*.ts']); + }); + + it('should merge batch flag into existing sync block', () => { + const config = baseConfig(); + config.sync = { concurrency: 3, batch_size: 25 }; + const result = applyCliOverrides(config, { batch: false }); + expect(result.sync?.batch).toBe(false); + expect(result.sync?.concurrency).toBe(3); + expect(result.sync?.batch_size).toBe(25); + }); + + it('should create a sync block with defaults when batch override needs it', () => { + const result = applyCliOverrides(baseConfig(), { batch: true }); + expect(result.sync?.batch).toBe(true); + expect(result.sync?.concurrency).toBe(5); + expect(result.sync?.batch_size).toBe(50); + }); + + it('should be a no-op when no overrides are supplied', () => { + const config = baseConfig(); + config.translation = { formality: 'default' }; + const result = applyCliOverrides(config, {}); + expect(result.translation?.formality).toBe('default'); + }); + + it('should reject model_type override that breaks translation_memory compatibility', () => { + const config = baseConfig(); + config.translation = { translation_memory: 'tm-1', model_type: 'quality_optimized' }; + expect(() => + applyCliOverrides(config, { modelType: 'latency_optimized' }), + ).toThrow(ConfigError); + }); + + it('should accept model_type override when translation_memory is not set', () => { + const result = applyCliOverrides(baseConfig(), { modelType: 'latency_optimized' }); + expect(result.translation?.model_type).toBe('latency_optimized'); + }); + }); + + describe('inline TMS credential warning', () => { + let tmpDir: string; + let originalIsTTY: boolean | undefined; + let originalApiKey: string | undefined; + let originalToken: string | undefined; + let stderrSpy: jest.SpyInstance; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-sync-tms-warn-')); + originalIsTTY = process.stderr.isTTY; + originalApiKey = process.env['TMS_API_KEY']; + originalToken = process.env['TMS_TOKEN']; + delete process.env['TMS_API_KEY']; + delete process.env['TMS_TOKEN']; + stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + Object.defineProperty(process.stderr, 'isTTY', { value: originalIsTTY, configurable: true }); + if (originalApiKey !== undefined) process.env['TMS_API_KEY'] = originalApiKey; + if (originalToken !== undefined) process.env['TMS_TOKEN'] = originalToken; + stderrSpy.mockRestore(); + }); + + const writeConfig = (extra: string): string => { + const configYaml = `version: 1 +source_locale: en +target_locales: [de] +buckets: + json: + include: ['locales/en.json'] +tms: + server: https://tms.example.com + project_id: demo +${extra} +`; + const configPath = path.join(tmpDir, SYNC_CONFIG_FILENAME); + fs.writeFileSync(configPath, configYaml); + return configPath; + }; + + it('emits a stderr warning when tms.api_key is inlined and stderr is not a TTY', async () => { + Object.defineProperty(process.stderr, 'isTTY', { value: false, configurable: true }); + writeConfig(' api_key: secret-key'); + + await loadSyncConfig(tmpDir); + + const writes = stderrSpy.mock.calls.map((c) => String(c[0])).join(''); + expect(writes).toMatch(/TMS_API_KEY/); + expect(writes).toMatch(/\.deepl-sync\.yaml/); + }); + + it('emits a stderr warning when tms.token is inlined and stderr is not a TTY', async () => { + Object.defineProperty(process.stderr, 'isTTY', { value: false, configurable: true }); + writeConfig(' token: secret-token'); + + await loadSyncConfig(tmpDir); + + const writes = stderrSpy.mock.calls.map((c) => String(c[0])).join(''); + expect(writes).toMatch(/TMS_TOKEN/); + }); + + it('still emits the warning when stderr is a TTY', async () => { + Object.defineProperty(process.stderr, 'isTTY', { value: true, configurable: true }); + writeConfig(' api_key: secret-key'); + + await loadSyncConfig(tmpDir); + + const writes = stderrSpy.mock.calls.map((c) => String(c[0])).join(''); + expect(writes).toMatch(/TMS_API_KEY/); + }); + + it('does not emit the warning when tms.api_key is absent', async () => { + Object.defineProperty(process.stderr, 'isTTY', { value: false, configurable: true }); + writeConfig(' enabled: false'); + + await loadSyncConfig(tmpDir); + + const writes = stderrSpy.mock.calls.map((c) => String(c[0])).join(''); + expect(writes).not.toMatch(/TMS_API_KEY/); + expect(writes).not.toMatch(/TMS_TOKEN/); + }); + + it('does not emit the warning when TMS_API_KEY env var is already set', async () => { + Object.defineProperty(process.stderr, 'isTTY', { value: false, configurable: true }); + process.env['TMS_API_KEY'] = 'env-key'; + writeConfig(' api_key: secret-key'); + + await loadSyncConfig(tmpDir); + + const writes = stderrSpy.mock.calls.map((c) => String(c[0])).join(''); + expect(writes).not.toMatch(/TMS_API_KEY/); + }); + }); + + describe('error message terminal sanitization', () => { + it('replaces control chars in unknown-field key before rendering in ConfigError', () => { + const evil = 'evil\x1b[31mkey\x00'; + try { + validateSyncConfig({ + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { json: { include: ['a.json'] } }, + [evil]: 1, + }); + throw new Error('expected ConfigError'); + } catch (err) { + expect(err).toBeInstanceOf(ConfigError); + const msg = (err as ConfigError).message; + expect(msg).not.toContain('\x1b'); + expect(msg).not.toContain('\x00'); + expect(msg).toContain('?'); + } + }); + + it('replaces zero-width codepoints in unknown-field key before rendering in ConfigError', () => { + const evil = 'foo\u200bbar'; + try { + validateSyncConfig({ + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { json: { include: ['a.json'] } }, + [evil]: 1, + }); + throw new Error('expected ConfigError'); + } catch (err) { + expect(err).toBeInstanceOf(ConfigError); + const msg = (err as ConfigError).message; + expect(msg).not.toContain('\u200b'); + } + }); + + it('replaces control chars in source_locale echo', () => { + expect.assertions(2); + try { + validateSyncConfig({ + version: 1, + source_locale: 'en\x1b/US', + target_locales: ['de'], + buckets: { json: { include: ['a.json'] } }, + }); + } catch (err) { + expect(err).toBeInstanceOf(ConfigError); + expect((err as ConfigError).message).not.toContain('\x1b'); + } + }); + + it('replaces control chars in target_locale echo', () => { + expect.assertions(2); + try { + validateSyncConfig({ + version: 1, + source_locale: 'en', + target_locales: ['de\x00/evil'], + buckets: { json: { include: ['a.json'] } }, + }); + } catch (err) { + expect(err).toBeInstanceOf(ConfigError); + expect((err as ConfigError).message).not.toContain('\x00'); + } + }); + + it('replaces control chars in bucket-name echo for malformed bucket', () => { + expect.assertions(2); + try { + validateSyncConfig({ + version: 1, + source_locale: 'en', + target_locales: ['de'], + buckets: { 'evil\x1bname': null }, + }); + } catch (err) { + expect(err).toBeInstanceOf(ConfigError); + expect((err as ConfigError).message).not.toContain('\x1b'); + } + }); + }); +}); diff --git a/tests/unit/sync/sync-context.test.ts b/tests/unit/sync/sync-context.test.ts new file mode 100644 index 0000000..50298fb --- /dev/null +++ b/tests/unit/sync/sync-context.test.ts @@ -0,0 +1,875 @@ +import fg from 'fast-glob'; +import { + buildKeyPatterns, + buildTemplateLiteralPatterns, + templateToGlobPattern, + extractContextFromSource, + extractTemplateLiteralMatches, + resolveTemplatePatterns, + synthesizeContext, + extractAllKeyContexts, + keyPathToContext, + sectionContextKey, + sectionToContext, + extractElementType, + DEFAULT_FUNCTION_NAMES, + ContextMatch, + TemplatePatternMatch, +} from '../../../src/sync/sync-context'; + +jest.mock('fast-glob'); +jest.mock('fs', () => { + const actual = jest.requireActual('fs'); + return { + ...actual, + promises: { + ...actual.promises, + readFile: jest.fn(), + }, + }; +}); + +import * as fs from 'fs'; + +const mockFastGlob = fg as jest.MockedFunction; +const mockReadFile = fs.promises.readFile as jest.MockedFunction; + +describe('sync-context', () => { + describe('buildKeyPatterns', () => { + it('should return a regex for each function name', () => { + const patterns = buildKeyPatterns(['t', 'i18n.t']); + expect(patterns).toHaveLength(2); + patterns.forEach((p) => expect(p).toBeInstanceOf(RegExp)); + }); + + it('should produce global regexes', () => { + const patterns = buildKeyPatterns(['t']); + expect(patterns[0]!.flags).toContain('g'); + }); + }); + + describe('extractContextFromSource', () => { + it('should match t() calls', () => { + const source = `const label = t('greeting');`; + const matches = extractContextFromSource(source, 'app.ts', ['t'], 0); + expect(matches).toHaveLength(1); + expect(matches[0]!.key).toBe('greeting'); + expect(matches[0]!.matchedFunction).toBe('t'); + expect(matches[0]!.line).toBe(1); + }); + + it('should match i18n.t() calls', () => { + const source = `i18n.t('welcome_message')`; + const matches = extractContextFromSource(source, 'app.ts', ['i18n.t'], 0); + expect(matches).toHaveLength(1); + expect(matches[0]!.key).toBe('welcome_message'); + expect(matches[0]!.matchedFunction).toBe('i18n.t'); + }); + + it('should match $t() calls', () => { + const source = `{{ $t('save') }}`; + const matches = extractContextFromSource(source, 'comp.vue', ['$t'], 0); + expect(matches).toHaveLength(1); + expect(matches[0]!.key).toBe('save'); + expect(matches[0]!.matchedFunction).toBe('$t'); + }); + + it('should match intl.formatMessage() calls', () => { + const source = `intl.formatMessage({ id: 'items_count' })`; + const matches = extractContextFromSource(source, 'app.tsx', ['intl.formatMessage'], 0); + expect(matches).toHaveLength(1); + expect(matches[0]!.key).toBe('items_count'); + expect(matches[0]!.matchedFunction).toBe('intl.formatMessage'); + }); + + it('should handle dotted keys like nav.home.title', () => { + const source = `t('nav.home.title')`; + const matches = extractContextFromSource(source, 'nav.ts', ['t'], 0); + expect(matches).toHaveLength(1); + expect(matches[0]!.key).toBe('nav.home.title'); + }); + + it('should return empty array when no matches found', () => { + const source = `const x = 42;\nconsole.log(x);`; + const matches = extractContextFromSource(source, 'math.ts', DEFAULT_FUNCTION_NAMES, 0); + expect(matches).toHaveLength(0); + }); + + it('should find multiple matches on the same line', () => { + const source = `const a = t('hello'), b = t('world');`; + const matches = extractContextFromSource(source, 'multi.ts', ['t'], 0); + expect(matches).toHaveLength(2); + expect(matches[0]!.key).toBe('hello'); + expect(matches[1]!.key).toBe('world'); + }); + + it('should find matches across multiple lines', () => { + const source = `t('line1')\nt('line2')\nt('line3')`; + const matches = extractContextFromSource(source, 'multi.ts', ['t'], 0); + expect(matches).toHaveLength(3); + expect(matches[0]!.line).toBe(1); + expect(matches[1]!.line).toBe(2); + expect(matches[2]!.line).toBe(3); + }); + + it('should include surrounding code with configurable context lines', () => { + const source = `line1\nline2\nt('key')\nline4\nline5`; + const matches = extractContextFromSource(source, 'ctx.ts', ['t'], 1); + expect(matches).toHaveLength(1); + expect(matches[0]!.surroundingCode).toBe("line2\nt('key')\nline4"); + }); + + it('should clamp context at start of file', () => { + const source = `t('first_line')\nline2\nline3`; + const matches = extractContextFromSource(source, 'start.ts', ['t'], 2); + expect(matches).toHaveLength(1); + expect(matches[0]!.surroundingCode).toBe("t('first_line')\nline2\nline3"); + }); + + it('should clamp context at end of file', () => { + const source = `line1\nline2\nt('last_line')`; + const matches = extractContextFromSource(source, 'end.ts', ['t'], 2); + expect(matches).toHaveLength(1); + expect(matches[0]!.surroundingCode).toBe("line1\nline2\nt('last_line')"); + }); + + it('should not match t() when preceded by a dot (method call)', () => { + const source = `obj.t('not_a_key')`; + const matches = extractContextFromSource(source, 'method.ts', ['t'], 0); + expect(matches).toHaveLength(0); + }); + + it('should not match t() when preceded by a word character', () => { + const source = `ият('not_a_key')`; + const matches = extractContextFromSource(source, 'method.ts', ['t'], 0); + expect(matches).toHaveLength(0); + }); + + it('should match t() with additional arguments after the key', () => { + const source = `t('key_with_args', { count: 5 })`; + const matches = extractContextFromSource(source, 'args.ts', ['t'], 0); + expect(matches).toHaveLength(1); + expect(matches[0]!.key).toBe('key_with_args'); + }); + + it('should match keys with double quotes', () => { + const source = `t("double_quoted")`; + const matches = extractContextFromSource(source, 'quotes.ts', ['t'], 0); + expect(matches).toHaveLength(1); + expect(matches[0]!.key).toBe('double_quoted'); + }); + + it('should use correct file path in matches', () => { + const source = `t('test')`; + const matches = extractContextFromSource(source, 'src/components/Header.tsx', ['t'], 0); + expect(matches[0]!.filePath).toBe('src/components/Header.tsx'); + }); + }); + + describe('synthesizeContext', () => { + function makeMatch(overrides: Partial = {}): ContextMatch { + return { + key: 'test_key', + filePath: 'app.ts', + line: 10, + surroundingCode: 'const x = t("test_key");', + matchedFunction: 't', + ...overrides, + }; + } + + it('should return empty string for empty array', () => { + expect(synthesizeContext([])).toBe(''); + }); + + it('should format a single match', () => { + const result = synthesizeContext([makeMatch()]); + expect(result).toContain('Used as t() in app.ts:10'); + expect(result).toContain('const x = t("test_key");'); + }); + + it('should format multiple matches', () => { + const matches = [ + makeMatch({ filePath: 'a.ts', line: 1 }), + makeMatch({ filePath: 'b.ts', line: 2 }), + ]; + const result = synthesizeContext(matches); + expect(result).toContain('a.ts:1'); + expect(result).toContain('b.ts:2'); + }); + + it('should cap displayed locations at 3', () => { + const matches = [ + makeMatch({ filePath: 'a.ts', line: 1 }), + makeMatch({ filePath: 'b.ts', line: 2 }), + makeMatch({ filePath: 'c.ts', line: 3 }), + makeMatch({ filePath: 'd.ts', line: 4 }), + makeMatch({ filePath: 'e.ts', line: 5 }), + ]; + const result = synthesizeContext(matches); + expect(result).toContain('a.ts:1'); + expect(result).toContain('b.ts:2'); + expect(result).toContain('c.ts:3'); + expect(result).not.toContain('d.ts:4'); + expect(result).not.toContain('e.ts:5'); + expect(result).toContain('...and 2 more occurrence(s)'); + }); + + it('should cap total string at 1000 characters', () => { + const longCode = 'x'.repeat(500); + const matches = [ + makeMatch({ surroundingCode: longCode, filePath: 'a.ts' }), + makeMatch({ surroundingCode: longCode, filePath: 'b.ts' }), + makeMatch({ surroundingCode: longCode, filePath: 'c.ts' }), + ]; + const result = synthesizeContext(matches); + expect(result.length).toBeLessThanOrEqual(1000); + expect(result).toMatch(/\.\.\.$/); + }); + + it('should not truncate when within limit', () => { + const result = synthesizeContext([makeMatch()]); + expect(result).not.toMatch(/\.\.\.$/); + }); + }); + + describe('extractAllKeyContexts', () => { + it('should aggregate keys from multiple files', async () => { + mockFastGlob.mockResolvedValue(['/project/src/a.ts', '/project/src/b.ts']); + + mockReadFile.mockImplementation((filePath: unknown) => { + if (String(filePath).includes('a.ts')) return Promise.resolve(`t('shared_key')`); + if (String(filePath).includes('b.ts')) return Promise.resolve(`t('shared_key')\nt('unique_key')`); + return Promise.resolve(''); + }); + + const { keyContexts } = await extractAllKeyContexts({ + scanPaths: ['src/**/*.ts'], + rootDir: '/project', + }); + + expect(keyContexts.size).toBe(2); + expect(keyContexts.get('shared_key')!.occurrences).toBe(2); + expect(keyContexts.get('unique_key')!.occurrences).toBe(1); + }); + + it('should skip unreadable files gracefully', async () => { + mockFastGlob.mockResolvedValue(['/project/src/good.ts', '/project/src/bad.ts']); + + mockReadFile.mockImplementation((filePath: unknown) => { + if (String(filePath).includes('bad.ts')) return Promise.reject(new Error('EACCES')); + return Promise.resolve(`t('found')`); + }); + + const { keyContexts } = await extractAllKeyContexts({ + scanPaths: ['src/**/*.ts'], + rootDir: '/project', + }); + + expect(keyContexts.size).toBe(1); + expect(keyContexts.has('found')).toBe(true); + }); + + it('should return empty map when no files match', async () => { + mockFastGlob.mockResolvedValue([]); + + const { keyContexts } = await extractAllKeyContexts({ + scanPaths: ['src/**/*.ts'], + rootDir: '/project', + }); + + expect(keyContexts.size).toBe(0); + }); + + it('should use custom function names when provided', async () => { + mockFastGlob.mockResolvedValue(['/project/src/app.ts']); + + mockReadFile.mockResolvedValue(`myTranslate('custom_key')`); + + const { keyContexts } = await extractAllKeyContexts({ + scanPaths: ['src/**/*.ts'], + rootDir: '/project', + functionNames: ['myTranslate'], + }); + + expect(keyContexts.size).toBe(1); + expect(keyContexts.has('custom_key')).toBe(true); + }); + + it('should use default function names when not specified', async () => { + mockFastGlob.mockResolvedValue(['/project/src/app.ts']); + + mockReadFile.mockResolvedValue(`t('default_fn')`); + + const { keyContexts } = await extractAllKeyContexts({ + scanPaths: ['src/**/*.ts'], + rootDir: '/project', + }); + + expect(keyContexts.has('default_fn')).toBe(true); + }); + + it('should pass correct glob options', async () => { + mockFastGlob.mockResolvedValue([]); + + await extractAllKeyContexts({ + scanPaths: ['src/**/*.ts'], + rootDir: '/project', + }); + + expect(mockFastGlob).toHaveBeenCalledWith( + ['/project/src/**/*.ts'], + expect.objectContaining({ + cwd: '/project', + absolute: true, + onlyFiles: true, + }), + ); + }); + + it('should generate context strings for each key', async () => { + mockFastGlob.mockResolvedValue(['/project/src/app.ts']); + + mockReadFile.mockResolvedValue(`const label = t('greeting');`); + + const { keyContexts } = await extractAllKeyContexts({ + scanPaths: ['src/**/*.ts'], + rootDir: '/project', + }); + + const ctx = keyContexts.get('greeting'); + expect(ctx).toBeDefined(); + expect(ctx!.context).toContain('Used as t()'); + expect(ctx!.context).toContain('greeting'); + }); + + it('should return template patterns from template literal t() calls', async () => { + mockFastGlob.mockResolvedValue(['/project/src/app.tsx']); + + mockReadFile.mockResolvedValue('const x = t(`features.${key}.title`);'); + + const { templatePatterns } = await extractAllKeyContexts({ + scanPaths: ['src/**/*.tsx'], + rootDir: '/project', + }); + + expect(templatePatterns).toHaveLength(1); + expect(templatePatterns[0]!.pattern).toBe('features.${key}.title'); + expect(templatePatterns[0]!.matchedFunction).toBe('t'); + }); + + describe('scan_paths traversal guard', () => { + it('should skip a scan_path with a leading parent-directory segment', async () => { + mockFastGlob.mockResolvedValue([]); + + await extractAllKeyContexts({ + scanPaths: ['../sensitive/**/*.ts'], + rootDir: '/project', + }); + + expect(mockFastGlob).toHaveBeenCalledWith([], expect.any(Object)); + }); + + it('should skip a scan_path with an absolute path outside the project root', async () => { + mockFastGlob.mockResolvedValue([]); + + await extractAllKeyContexts({ + scanPaths: ['/etc/**/*.conf'], + rootDir: '/project', + }); + + expect(mockFastGlob).toHaveBeenCalledWith([], expect.any(Object)); + }); + + it('should skip a scan_path that smuggles ".." through brace expansion', async () => { + mockFastGlob.mockResolvedValue([]); + + await extractAllKeyContexts({ + scanPaths: ['{..,src}/**/*.ts'], + rootDir: '/project', + }); + + expect(mockFastGlob).toHaveBeenCalledWith([], expect.any(Object)); + }); + + it('should skip a scan_path that smuggles ".." through an extglob', async () => { + mockFastGlob.mockResolvedValue([]); + + await extractAllKeyContexts({ + scanPaths: ['@(..)/**/*.ts'], + rootDir: '/project', + }); + + expect(mockFastGlob).toHaveBeenCalledWith([], expect.any(Object)); + }); + + it('should skip a scan_path with mid-pattern ".." segment', async () => { + mockFastGlob.mockResolvedValue([]); + + await extractAllKeyContexts({ + scanPaths: ['src/../etc/**'], + rootDir: '/project', + }); + + expect(mockFastGlob).toHaveBeenCalledWith([], expect.any(Object)); + }); + + it('should still forward a safe scan_path alongside a rejected one', async () => { + mockFastGlob.mockResolvedValue([]); + + await extractAllKeyContexts({ + scanPaths: ['src/**/*.ts', '{..,src}/**/*.ts'], + rootDir: '/project', + }); + + expect(mockFastGlob).toHaveBeenCalledWith(['/project/src/**/*.ts'], expect.any(Object)); + }); + }); + }); + + describe('buildTemplateLiteralPatterns', () => { + it('should return a regex for each non-intl function name', () => { + const patterns = buildTemplateLiteralPatterns(['t', 'i18n.t', 'intl.formatMessage']); + expect(patterns).toHaveLength(2); + patterns.forEach((p) => expect(p).toBeInstanceOf(RegExp)); + }); + + it('should produce global regexes', () => { + const patterns = buildTemplateLiteralPatterns(['t']); + expect(patterns[0]!.flags).toContain('g'); + }); + + it('should exclude intl.formatMessage', () => { + const patterns = buildTemplateLiteralPatterns(['intl.formatMessage']); + expect(patterns).toHaveLength(0); + }); + }); + + describe('templateToGlobPattern', () => { + it('should replace single interpolation with *', () => { + expect(templateToGlobPattern('nav.${key}')).toBe('nav.*'); + }); + + it('should replace interpolation in middle', () => { + expect(templateToGlobPattern('features.${key}.title')).toBe('features.*.title'); + }); + + it('should replace multiple interpolations', () => { + expect(templateToGlobPattern('pricing.${tier}.features.${fi}')).toBe('pricing.*.features.*'); + }); + + it('should pass through strings with no interpolations', () => { + expect(templateToGlobPattern('simple.key')).toBe('simple.key'); + }); + + it('should handle complex interpolation expressions', () => { + expect(templateToGlobPattern('items.${i + 1}.label')).toBe('items.*.label'); + }); + }); + + describe('extractTemplateLiteralMatches', () => { + it('should match t() calls with template literals', () => { + const source = 'const x = t(`nav.${key}`);'; + const matches = extractTemplateLiteralMatches(source, 'app.tsx', ['t'], 0); + expect(matches).toHaveLength(1); + expect(matches[0]!.pattern).toBe('nav.${key}'); + expect(matches[0]!.matchedFunction).toBe('t'); + expect(matches[0]!.line).toBe(1); + }); + + it('should match i18n.t() with template literals', () => { + const source = 'i18n.t(`section.${name}`)'; + const matches = extractTemplateLiteralMatches(source, 'app.tsx', ['i18n.t'], 0); + expect(matches).toHaveLength(1); + expect(matches[0]!.pattern).toBe('section.${name}'); + }); + + it('should match $t() with template literals', () => { + const source = '{{ $t(`items.${idx}`) }}'; + const matches = extractTemplateLiteralMatches(source, 'comp.vue', ['$t'], 0); + expect(matches).toHaveLength(1); + expect(matches[0]!.pattern).toBe('items.${idx}'); + }); + + it('should not match when preceded by a dot', () => { + const source = 'obj.t(`nav.${key}`)'; + const matches = extractTemplateLiteralMatches(source, 'app.tsx', ['t'], 0); + expect(matches).toHaveLength(0); + }); + + it('should ignore template literals without interpolation', () => { + const source = 't(`static.key`)'; + const matches = extractTemplateLiteralMatches(source, 'app.tsx', ['t'], 0); + expect(matches).toHaveLength(0); + }); + + it('should include surrounding code context', () => { + const source = 'line1\nline2\nt(`nav.${key}`)\nline4\nline5'; + const matches = extractTemplateLiteralMatches(source, 'app.tsx', ['t'], 1); + expect(matches).toHaveLength(1); + expect(matches[0]!.surroundingCode).toBe('line2\nt(`nav.${key}`)\nline4'); + }); + + it('should find multiple matches', () => { + const source = 't(`a.${x}`)\nt(`b.${y}`)'; + const matches = extractTemplateLiteralMatches(source, 'app.tsx', ['t'], 0); + expect(matches).toHaveLength(2); + expect(matches[0]!.pattern).toBe('a.${x}'); + expect(matches[1]!.pattern).toBe('b.${y}'); + }); + + it('should return empty array when no template literals found', () => { + const source = "t('static_key')"; + const matches = extractTemplateLiteralMatches(source, 'app.tsx', ['t'], 0); + expect(matches).toHaveLength(0); + }); + }); + + describe('resolveTemplatePatterns', () => { + function makeTemplateMatch(pattern: string, overrides: Partial = {}): TemplatePatternMatch { + return { + pattern, + filePath: 'app.tsx', + line: 10, + surroundingCode: `t(\`${pattern}\`)`, + matchedFunction: 't', + ...overrides, + }; + } + + it('should match known keys against a single-wildcard pattern', () => { + const patterns = [makeTemplateMatch('features.${key}.title')]; + const knownKeys = ['features.a.title', 'features.b.title', 'other.key']; + const result = resolveTemplatePatterns(patterns, knownKeys); + expect(result.size).toBe(2); + expect(result.has('features.a.title')).toBe(true); + expect(result.has('features.b.title')).toBe(true); + expect(result.has('other.key')).toBe(false); + }); + + it('should match known keys against a multi-wildcard pattern', () => { + const patterns = [makeTemplateMatch('pricing.${tier}.features.${fi}')]; + const knownKeys = ['pricing.free.features.0', 'pricing.pro.features.1', 'pricing.heading']; + const result = resolveTemplatePatterns(patterns, knownKeys); + expect(result.size).toBe(2); + expect(result.has('pricing.free.features.0')).toBe(true); + expect(result.has('pricing.pro.features.1')).toBe(true); + }); + + it('should not match across segment boundaries', () => { + const patterns = [makeTemplateMatch('nav.${key}')]; + const knownKeys = ['nav.home', 'nav.sub.deep', 'navigation.other']; + const result = resolveTemplatePatterns(patterns, knownKeys); + expect(result.size).toBe(1); + expect(result.has('nav.home')).toBe(true); + expect(result.has('nav.sub.deep')).toBe(false); + expect(result.has('navigation.other')).toBe(false); + }); + + it('should return empty map when no patterns match', () => { + const patterns = [makeTemplateMatch('missing.${x}')]; + const knownKeys = ['found.key', 'other.key']; + const result = resolveTemplatePatterns(patterns, knownKeys); + expect(result.size).toBe(0); + }); + + it('should aggregate matches from multiple template patterns', () => { + const patterns = [ + makeTemplateMatch('a.${x}', { filePath: 'comp1.tsx', line: 5 }), + makeTemplateMatch('a.${y}', { filePath: 'comp2.tsx', line: 10 }), + ]; + const knownKeys = ['a.foo']; + const result = resolveTemplatePatterns(patterns, knownKeys); + expect(result.size).toBe(1); + expect(result.get('a.foo')).toHaveLength(2); + }); + + it('should produce ContextMatch objects with correct fields', () => { + const patterns = [makeTemplateMatch('nav.${key}', { filePath: 'Navbar.tsx', line: 15 })]; + const result = resolveTemplatePatterns(patterns, ['nav.home']); + const matches = result.get('nav.home')!; + expect(matches[0]!.key).toBe('nav.home'); + expect(matches[0]!.filePath).toBe('Navbar.tsx'); + expect(matches[0]!.line).toBe(15); + expect(matches[0]!.matchedFunction).toBe('t'); + }); + + it('should collapse 1000 duplicate patterns to the same result as a single entry', () => { + const DUPS = 1_000; + const knownKeys = ['features.a.title', 'features.b.title', 'other.key']; + + const canonical = makeTemplateMatch('features.${k}.title', { filePath: 'comp.tsx', line: 1 }); + const dupPatterns = Array.from({ length: DUPS }, () => ({ ...canonical })); + dupPatterns.push(makeTemplateMatch('other.${x}', { filePath: 'other.tsx', line: 1 })); + + const singlePattern = [canonical]; + singlePattern.push(makeTemplateMatch('other.${x}', { filePath: 'other.tsx', line: 1 })); + + const resultSingle = resolveTemplatePatterns(singlePattern, knownKeys); + const resultDup = resolveTemplatePatterns(dupPatterns, knownKeys); + + expect(resultDup.has('features.a.title')).toBe(true); + expect(resultDup.has('features.b.title')).toBe(true); + expect(resultDup.has('other.key')).toBe(true); + + const singleCtx = synthesizeContext(resultSingle.get('features.a.title')!, { key: 'features.a.title' }); + const dupCtx = synthesizeContext(resultDup.get('features.a.title')!, { key: 'features.a.title' }); + expect(dupCtx).toBe(singleCtx); + }); + }); + + describe('DEFAULT_FUNCTION_NAMES', () => { + it('should include common i18n function names', () => { + expect(DEFAULT_FUNCTION_NAMES).toContain('t'); + expect(DEFAULT_FUNCTION_NAMES).toContain('i18n.t'); + expect(DEFAULT_FUNCTION_NAMES).toContain('$t'); + expect(DEFAULT_FUNCTION_NAMES).toContain('intl.formatMessage'); + }); + }); + + describe('keyPathToContext', () => { + it('should produce context for cta role', () => { + expect(keyPathToContext('pricing.free.cta')).toBe('Call-to-action in the pricing > free section.'); + }); + + it('should produce context for title role', () => { + expect(keyPathToContext('nav.features.title')).toBe('Title in the nav > features section.'); + }); + + it('should produce context for heading role', () => { + expect(keyPathToContext('page.heading')).toBe('Heading in the page section.'); + }); + + it('should produce context for description role', () => { + expect(keyPathToContext('settings.notifications.email.description')).toBe( + 'Description in the settings > notifications > email section.', + ); + }); + + it('should produce context for placeholder role', () => { + expect(keyPathToContext('form.name.placeholder')).toBe('Placeholder in the form > name section.'); + }); + + it('should produce context for tooltip role', () => { + expect(keyPathToContext('dashboard.tooltip')).toBe('Tooltip in the dashboard section.'); + }); + + it('should produce context for save role', () => { + expect(keyPathToContext('buttons.save')).toBe('Save action in the buttons section.'); + }); + + it('should produce context for quote role', () => { + expect(keyPathToContext('testimonials.items.0.quote')).toBe('Quote in the testimonials > items section.'); + }); + + it('should handle compound segments with modifier suffix', () => { + expect(keyPathToContext('hero.cta_primary')).toBe('Primary call-to-action in the hero section.'); + }); + + it('should return empty string for single-segment key', () => { + expect(keyPathToContext('greeting')).toBe(''); + }); + + it('should return empty string for single recognized role alone', () => { + expect(keyPathToContext('title')).toBe(''); + }); + + it('should skip numeric segments in the section path', () => { + expect(keyPathToContext('items.0.label')).toBe('Label in the items section.'); + }); + + it('should handle deeply nested keys (4+ levels)', () => { + expect(keyPathToContext('a.b.c.d.title')).toBe('Title in the a > b > c > d section.'); + }); + + it('should capitalize unknown role segments', () => { + expect(keyPathToContext('errors.generic')).toBe('Generic in the errors section.'); + }); + + it('should handle underscored unknown segments', () => { + expect(keyPathToContext('settings.dark_mode')).toBe('Dark mode in the settings section.'); + }); + + it('should return empty string for empty input', () => { + expect(keyPathToContext('')).toBe(''); + }); + + it('should return empty for key with only numeric parent', () => { + expect(keyPathToContext('0.title')).toBe(''); + }); + + it('should produce context for two-segment key with known role', () => { + expect(keyPathToContext('nav.features')).toBe('Features in the nav section.'); + }); + + it('should honor NUL separator for YAML-style flattened keys', () => { + expect(keyPathToContext('nav\0features\0title')).toBe('Title in the nav > features section.'); + }); + + it('should treat a literal dot as part of a single NUL-separated segment', () => { + expect(keyPathToContext('app\0version.major')).toBe('Version.major in the app section.'); + }); + }); + + describe('sectionContextKey', () => { + it('should return parent segments for dot-separated keys', () => { + expect(sectionContextKey('nav.title')).toBe('nav'); + expect(sectionContextKey('a.b.c')).toBe('a.b'); + }); + + it('should return empty string for single-segment dot keys', () => { + expect(sectionContextKey('title')).toBe(''); + }); + + it('should use NUL separator when key contains NUL', () => { + expect(sectionContextKey('nav\0title')).toBe('nav'); + expect(sectionContextKey('a\0b\0c')).toBe('a\0b'); + }); + + it('should treat a literal dot inside a NUL-separated key as part of one segment', () => { + expect(sectionContextKey('app\0version.major')).toBe('app'); + }); + + it('should return empty string for a flat key with a literal dot (no NUL)', () => { + expect(sectionContextKey('version.major')).toBe('version'); + }); + + it('should not split a flat YAML key with literal dot into two sections when using NUL form', () => { + expect(sectionContextKey('version.major')).not.toBe('version.major'); + expect(sectionContextKey('version.major')).toBe('version'); + }); + + it('should skip numeric segments in NUL-separated keys', () => { + expect(sectionContextKey('items\x000\x00label')).toBe('items'); + }); + }); + + describe('sectionToContext', () => { + it('should format dot-separated section keys', () => { + expect(sectionToContext('nav.features')).toBe('Used in the nav > features section.'); + }); + + it('should format NUL-separated section keys', () => { + expect(sectionToContext('nav\0features')).toBe('Used in the nav > features section.'); + }); + + it('should return empty string for empty section key', () => { + expect(sectionToContext('')).toBe(''); + }); + }); + + describe('extractElementType', () => { + it('should extract button element', () => { + expect(extractElementType('')).toBe('button'); + }); + + it('should extract h2 element with className', () => { + expect(extractElementType('

Title

')).toBe('h2'); + }); + + it('should extract th element', () => { + expect(extractElementType('Status')).toBe('th'); + }); + + it('should extract self-closing input', () => { + expect(extractElementType('')).toBe('input'); + }); + + it('should extract self-closing textarea', () => { + expect(extractElementType('