From a4540bbcdfa492e215fbd6ca43fe8e96d1f39d57 Mon Sep 17 00:00:00 2001 From: RamGopalSrikar Date: Thu, 26 Mar 2026 17:12:28 -0400 Subject: [PATCH] feat(i18n): refactor language constants and improve GP pipeline - Extract SUPPORTED_LANGUAGES into src/frontend/src/constants/languages.ts - Update LanguageForm to import from shared constants, add aria-label - Add settings.languageSelectAriaLabel key to en.json - Update LanguageSelector component to use shared SUPPORTED_LANGUAGES - Fix gp_client and download_translations scripts - Update GP upload/download/nightly GitHub Actions workflows - Add GP script tests --- .github/workflows/gp-download.yml | 3 - .github/workflows/gp-upload.yml | 1 - .github/workflows/nightly_build.yml | 13 +- scripts/gp/download_translations.py | 7 + scripts/gp/gp_client.py | 9 +- scripts/gp/tests/__init__.py | 0 .../gp/tests/test_download_translations.py | 100 ++++++++ scripts/gp/tests/test_gp_client.py | 226 ++++++++++++++++++ scripts/gp/tests/test_upload_strings.py | 76 ++++++ .../components/LanguageSelector/index.tsx | 16 +- src/frontend/src/constants/languages.ts | 9 + src/frontend/src/i18next.d.ts | 11 + src/frontend/src/locales/en.json | 1 + .../__tests__/LanguageForm.test.tsx | 92 +++++++ .../components/LanguageForm/index.tsx | 14 +- 15 files changed, 541 insertions(+), 37 deletions(-) create mode 100644 scripts/gp/tests/__init__.py create mode 100644 scripts/gp/tests/test_download_translations.py create mode 100644 scripts/gp/tests/test_gp_client.py create mode 100644 scripts/gp/tests/test_upload_strings.py create mode 100644 src/frontend/src/constants/languages.ts create mode 100644 src/frontend/src/i18next.d.ts create mode 100644 src/frontend/src/pages/SettingsPage/pages/GeneralPage/components/LanguageForm/__tests__/LanguageForm.test.tsx diff --git a/.github/workflows/gp-download.yml b/.github/workflows/gp-download.yml index 7b52cd531e..e505c698c3 100644 --- a/.github/workflows/gp-download.yml +++ b/.github/workflows/gp-download.yml @@ -1,9 +1,6 @@ name: GP Download Translations on: - push: - branches: - - feat/globalization-pipeline # temp: remove before merge schedule: - cron: "0 6 * * *" # Daily at 6am UTC workflow_dispatch: diff --git a/.github/workflows/gp-upload.yml b/.github/workflows/gp-upload.yml index f7cd738c9a..5aa33f9576 100644 --- a/.github/workflows/gp-upload.yml +++ b/.github/workflows/gp-upload.yml @@ -4,7 +4,6 @@ on: push: branches: - main - - feat/globalization-pipeline # temp: remove before merge paths: - "src/frontend/src/locales/en.json" workflow_dispatch: diff --git a/.github/workflows/nightly_build.yml b/.github/workflows/nightly_build.yml index b93b00e41e..a0ab91de61 100644 --- a/.github/workflows/nightly_build.yml +++ b/.github/workflows/nightly_build.yml @@ -210,6 +210,7 @@ jobs: name: Download translations from Globalization Pipeline needs: [validate-inputs, resolve-release-branch, create-nightly-tag] runs-on: ubuntu-latest + continue-on-error: true environment: GP-test permissions: contents: write @@ -246,9 +247,9 @@ jobs: git push frontend-tests-linux: - if: always() && github.repository == 'langflow-ai/langflow' && !inputs.skip_frontend_tests && needs.download-gp-bundle.result != 'cancelled' + if: always() && github.repository == 'langflow-ai/langflow' && !inputs.skip_frontend_tests name: Run Frontend Tests - Linux - needs: [resolve-release-branch, create-nightly-tag, download-gp-bundle] + needs: [resolve-release-branch, create-nightly-tag] uses: ./.github/workflows/typescript_test.yml with: tests_folder: "tests" @@ -262,9 +263,9 @@ jobs: TAVILY_API_KEY: ${{ secrets.TAVILY_API_KEY }} frontend-tests-windows: - if: always() && github.repository == 'langflow-ai/langflow' && !inputs.skip_frontend_tests && needs.download-gp-bundle.result != 'cancelled' + if: always() && github.repository == 'langflow-ai/langflow' && !inputs.skip_frontend_tests name: Run Frontend Tests - Windows - needs: [resolve-release-branch, create-nightly-tag, download-gp-bundle] + needs: [resolve-release-branch, create-nightly-tag] # Windows tests are non-blocking - the release-nightly-build job only checks # frontend-tests-linux.result, allowing Windows tests to fail without blocking the build. # This gives us visibility into Windows-specific issues while we stabilize the tests. @@ -305,10 +306,10 @@ jobs: # ref: ${{ needs.create-nightly-tag.outputs.tag }} release-nightly-build: - if: github.repository == 'langflow-ai/langflow' && always() && needs.frontend-tests-linux.result != 'failure' && needs.backend-unit-tests.result != 'failure' && needs.download-gp-bundle.result != 'failure' + if: github.repository == 'langflow-ai/langflow' && always() && needs.frontend-tests-linux.result != 'failure' && needs.backend-unit-tests.result != 'failure' name: Run Nightly Langflow Build needs: - [validate-inputs, create-nightly-tag, frontend-tests-linux, frontend-tests-windows, backend-unit-tests, download-gp-bundle] + [validate-inputs, create-nightly-tag, frontend-tests-linux, frontend-tests-windows, backend-unit-tests] uses: ./.github/workflows/release_nightly.yml with: build_docker_base: true diff --git a/scripts/gp/download_translations.py b/scripts/gp/download_translations.py index cc8ea7c07b..1f3cd3f84a 100644 --- a/scripts/gp/download_translations.py +++ b/scripts/gp/download_translations.py @@ -6,6 +6,7 @@ Usage: import argparse import json +import sys from pathlib import Path from gp_client import TARGET_LANGS, get_strings @@ -19,6 +20,7 @@ def main(): output_dir = Path(args.output) output_dir.mkdir(parents=True, exist_ok=True) + failed = [] for lang in TARGET_LANGS: print(f"Downloading '{lang}' translations...") try: @@ -44,6 +46,11 @@ def main(): except Exception as e: # noqa: BLE001 print(f" Error downloading '{lang}': {e}") + failed.append(lang) + + if failed: + print(f"\nFAILED languages: {failed}") + sys.exit(1) print("\nDone.") diff --git a/scripts/gp/gp_client.py b/scripts/gp/gp_client.py index aef7ae01d8..93a5e54c05 100644 --- a/scripts/gp/gp_client.py +++ b/scripts/gp/gp_client.py @@ -22,6 +22,7 @@ GP_INSTANCE = os.getenv("GP_INSTANCE", "langflow-test") GP_BUNDLE = os.getenv("GP_BUNDLE", "langflow-ui") TARGET_LANGS = ["fr", "ja", "es", "de", "pt", "zh-Hans"] REQUEST_TIMEOUT = 30 +VERIFY_SSL = os.getenv("GP_VERIFY_SSL", "true").lower() != "false" def get_headers(url, method, body=None): @@ -57,7 +58,7 @@ def get_headers(url, method, body=None): def list_bundles(): """List all bundles in the GP instance.""" url = f"{BASE_URL}/{GP_INSTANCE}/v2/bundles" - response = requests.get(url, headers=get_headers(url, "GET"), verify=False, timeout=REQUEST_TIMEOUT) # noqa: S501 + response = requests.get(url, headers=get_headers(url, "GET"), verify=VERIFY_SSL, timeout=REQUEST_TIMEOUT) response.raise_for_status() return response.json() @@ -70,7 +71,7 @@ def create_bundle(source_lang="en"): url, headers=get_headers(url, "PUT", body), json=body, - verify=False, # noqa: S501 + verify=VERIFY_SSL, timeout=REQUEST_TIMEOUT, ) response.raise_for_status() @@ -84,7 +85,7 @@ def upload_strings(strings, lang="en"): url, headers=get_headers(url, "PUT", strings), json=strings, - verify=False, # noqa: S501 + verify=VERIFY_SSL, timeout=REQUEST_TIMEOUT, ) response.raise_for_status() @@ -94,6 +95,6 @@ def upload_strings(strings, lang="en"): def get_strings(lang): """Download translated strings for a language from GP.""" url = f"{BASE_URL}/{GP_INSTANCE}/v2/bundles/{GP_BUNDLE}/{lang}" - response = requests.get(url, headers=get_headers(url, "GET"), verify=False, timeout=REQUEST_TIMEOUT) # noqa: S501 + response = requests.get(url, headers=get_headers(url, "GET"), verify=VERIFY_SSL, timeout=REQUEST_TIMEOUT) response.raise_for_status() return response.json() diff --git a/scripts/gp/tests/__init__.py b/scripts/gp/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/scripts/gp/tests/test_download_translations.py b/scripts/gp/tests/test_download_translations.py new file mode 100644 index 0000000000..811ede5902 --- /dev/null +++ b/scripts/gp/tests/test_download_translations.py @@ -0,0 +1,100 @@ +"""Tests for download_translations.py.""" + +import json +import sys +from unittest.mock import patch + +import pytest + + +def _run_main(output_dir: str): + """Helper to invoke download_translations.main() with an --output argument.""" + with patch("sys.argv", ["download_translations.py", "--output", output_dir]): + import importlib + + import scripts.gp.download_translations as dl_mod # noqa: PLC0415 + + importlib.reload(dl_mod) + dl_mod.main() + + +SAMPLE_RESPONSE = { + "resourceStrings": { + "hello": {"value": "Bonjour"}, + "bye": {"value": "Au revoir"}, + } +} + + +class TestDownloadTranslations: + def test_writes_json_files_for_each_language(self, tmp_path): + with ( + patch("scripts.gp.download_translations.get_strings", return_value=SAMPLE_RESPONSE), + patch("scripts.gp.download_translations.TARGET_LANGS", ["fr", "es"]), + ): + _run_main(str(tmp_path)) + + for lang in ["fr", "es"]: + out = tmp_path / f"{lang}.json" + assert out.exists() + data = json.loads(out.read_text(encoding="utf-8")) + assert data == {"hello": "Bonjour", "bye": "Au revoir"} + + def test_skips_language_with_empty_strings(self, tmp_path): + def _get_strings(lang): + if lang == "ja": + return {"resourceStrings": {}} + return SAMPLE_RESPONSE + + with ( + patch("scripts.gp.download_translations.get_strings", side_effect=_get_strings), + patch("scripts.gp.download_translations.TARGET_LANGS", ["fr", "ja"]), + ): + _run_main(str(tmp_path)) + + assert (tmp_path / "fr.json").exists() + assert not (tmp_path / "ja.json").exists() + + def test_exits_with_error_on_partial_failure(self, tmp_path): + def _get_strings(lang): + if lang == "de": + raise ConnectionError("network error") + return SAMPLE_RESPONSE + + with ( + patch("scripts.gp.download_translations.get_strings", side_effect=_get_strings), + patch("scripts.gp.download_translations.TARGET_LANGS", ["fr", "de"]), + ): + with pytest.raises(SystemExit) as exc_info: + _run_main(str(tmp_path)) + + assert exc_info.value.code == 1 + # Successful language should still have been written + assert (tmp_path / "fr.json").exists() + + def test_exits_cleanly_when_all_succeed(self, tmp_path): + with ( + patch("scripts.gp.download_translations.get_strings", return_value=SAMPLE_RESPONSE), + patch("scripts.gp.download_translations.TARGET_LANGS", ["fr"]), + ): + # Should not raise + _run_main(str(tmp_path)) + + assert (tmp_path / "fr.json").exists() + + def test_handles_flat_string_values_in_response(self, tmp_path): + flat_response = { + "resourceStrings": { + "hello": "Hola", + "bye": "Adiós", + } + } + + with ( + patch("scripts.gp.download_translations.get_strings", return_value=flat_response), + patch("scripts.gp.download_translations.TARGET_LANGS", ["es"]), + ): + _run_main(str(tmp_path)) + + data = json.loads((tmp_path / "es.json").read_text(encoding="utf-8")) + assert data == {"hello": "Hola", "bye": "Adiós"} diff --git a/scripts/gp/tests/test_gp_client.py b/scripts/gp/tests/test_gp_client.py new file mode 100644 index 0000000000..696590d93c --- /dev/null +++ b/scripts/gp/tests/test_gp_client.py @@ -0,0 +1,226 @@ +"""Tests for gp_client.py — HMAC auth and API operations.""" + +import base64 +import hashlib +import hmac +import json +from unittest.mock import MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _expected_signature(password: str, message: str) -> str: + sig = hmac.new( + bytes(password, "ISO-8859-1"), + msg=bytes(message, "ISO-8859-1"), + digestmod=hashlib.sha1, + ).digest() + return base64.b64encode(sig).decode() + + +# --------------------------------------------------------------------------- +# get_headers +# --------------------------------------------------------------------------- + +class TestGetHeaders: + def test_get_request_authorization_format(self): + with patch.dict( + "os.environ", + {"GP_ADMIN_USER_ID": "user123", "GP_ADMIN_PASSWORD": "secret"}, + ): + import importlib + import scripts.gp.gp_client as gp_client # noqa: PLC0415 + importlib.reload(gp_client) + + headers = gp_client.get_headers("https://example.com/api", "GET") + + assert headers["Authorization"].startswith("GP-HMAC user123:") + assert "GP-Date" in headers + assert headers["accept"] == "application/json" + assert "Content-Type" not in headers + + def test_put_request_includes_content_type(self): + with patch.dict( + "os.environ", + {"GP_ADMIN_USER_ID": "user123", "GP_ADMIN_PASSWORD": "secret"}, + ): + import importlib + import scripts.gp.gp_client as gp_client # noqa: PLC0415 + importlib.reload(gp_client) + + headers = gp_client.get_headers("https://example.com/api", "PUT", {"key": "val"}) + + assert headers["Content-Type"] == "application/json" + + def test_patch_request_uses_merge_patch_content_type(self): + with patch.dict( + "os.environ", + {"GP_ADMIN_USER_ID": "user123", "GP_ADMIN_PASSWORD": "secret"}, + ): + import importlib + import scripts.gp.gp_client as gp_client # noqa: PLC0415 + importlib.reload(gp_client) + + headers = gp_client.get_headers("https://example.com/api", "PATCH", {}) + + assert headers["Content-Type"] == "application/merge-patch+json" + + def test_hmac_signature_is_deterministic_for_same_inputs(self): + with patch.dict( + "os.environ", + {"GP_ADMIN_USER_ID": "user123", "GP_ADMIN_PASSWORD": "secret"}, + ): + import importlib + import scripts.gp.gp_client as gp_client # noqa: PLC0415 + importlib.reload(gp_client) + + with patch("scripts.gp.gp_client.datetime") as mock_dt: + mock_dt.now.return_value.strftime.return_value = "Thu, 26 Mar 2026 12:00:00 UTC" + + h1 = gp_client.get_headers("https://example.com", "GET") + h2 = gp_client.get_headers("https://example.com", "GET") + + assert h1["Authorization"] == h2["Authorization"] + + +# --------------------------------------------------------------------------- +# list_bundles +# --------------------------------------------------------------------------- + +class TestListBundles: + def test_returns_parsed_json_on_success(self): + mock_response = MagicMock() + mock_response.json.return_value = {"bundleIds": ["langflow-ui"]} + + with patch.dict( + "os.environ", + {"GP_ADMIN_USER_ID": "u", "GP_ADMIN_PASSWORD": "p"}, + ), patch("scripts.gp.gp_client.requests.get", return_value=mock_response) as mock_get: + import importlib + import scripts.gp.gp_client as gp_client # noqa: PLC0415 + importlib.reload(gp_client) + + result = gp_client.list_bundles() + + mock_get.assert_called_once() + mock_response.raise_for_status.assert_called_once() + assert result == {"bundleIds": ["langflow-ui"]} + + def test_raises_on_http_error(self): + import requests as req + + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = req.exceptions.HTTPError("401 Unauthorized") + + with patch.dict( + "os.environ", + {"GP_ADMIN_USER_ID": "u", "GP_ADMIN_PASSWORD": "p"}, + ), patch("scripts.gp.gp_client.requests.get", return_value=mock_response): + import importlib + import scripts.gp.gp_client as gp_client # noqa: PLC0415 + importlib.reload(gp_client) + + with pytest.raises(req.exceptions.HTTPError): + gp_client.list_bundles() + + def test_raises_on_timeout(self): + import requests as req + + with patch.dict( + "os.environ", + {"GP_ADMIN_USER_ID": "u", "GP_ADMIN_PASSWORD": "p"}, + ), patch( + "scripts.gp.gp_client.requests.get", + side_effect=req.exceptions.Timeout, + ): + import importlib + import scripts.gp.gp_client as gp_client # noqa: PLC0415 + importlib.reload(gp_client) + + with pytest.raises(req.exceptions.Timeout): + gp_client.list_bundles() + + +# --------------------------------------------------------------------------- +# upload_strings +# --------------------------------------------------------------------------- + +class TestUploadStrings: + def test_successful_upload(self): + mock_response = MagicMock() + mock_response.json.return_value = {"status": "ok"} + + with patch.dict( + "os.environ", + {"GP_ADMIN_USER_ID": "u", "GP_ADMIN_PASSWORD": "p"}, + ), patch("scripts.gp.gp_client.requests.put", return_value=mock_response) as mock_put: + import importlib + import scripts.gp.gp_client as gp_client # noqa: PLC0415 + importlib.reload(gp_client) + + result = gp_client.upload_strings({"hello": "Hello"}) + + mock_put.assert_called_once() + assert result == {"status": "ok"} + + def test_raises_on_server_error(self): + import requests as req + + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = req.exceptions.HTTPError("500") + + with patch.dict( + "os.environ", + {"GP_ADMIN_USER_ID": "u", "GP_ADMIN_PASSWORD": "p"}, + ), patch("scripts.gp.gp_client.requests.put", return_value=mock_response): + import importlib + import scripts.gp.gp_client as gp_client # noqa: PLC0415 + importlib.reload(gp_client) + + with pytest.raises(req.exceptions.HTTPError): + gp_client.upload_strings({"hello": "Hello"}) + + +# --------------------------------------------------------------------------- +# get_strings +# --------------------------------------------------------------------------- + +class TestGetStrings: + def test_returns_parsed_json(self): + mock_response = MagicMock() + mock_response.json.return_value = { + "resourceStrings": {"hello": {"value": "Bonjour"}} + } + + with patch.dict( + "os.environ", + {"GP_ADMIN_USER_ID": "u", "GP_ADMIN_PASSWORD": "p"}, + ), patch("scripts.gp.gp_client.requests.get", return_value=mock_response): + import importlib + import scripts.gp.gp_client as gp_client # noqa: PLC0415 + importlib.reload(gp_client) + + result = gp_client.get_strings("fr") + + assert result["resourceStrings"]["hello"]["value"] == "Bonjour" + + def test_raises_on_403(self): + import requests as req + + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = req.exceptions.HTTPError("403 Forbidden") + + with patch.dict( + "os.environ", + {"GP_ADMIN_USER_ID": "u", "GP_ADMIN_PASSWORD": "p"}, + ), patch("scripts.gp.gp_client.requests.get", return_value=mock_response): + import importlib + import scripts.gp.gp_client as gp_client # noqa: PLC0415 + importlib.reload(gp_client) + + with pytest.raises(req.exceptions.HTTPError): + gp_client.get_strings("fr") diff --git a/scripts/gp/tests/test_upload_strings.py b/scripts/gp/tests/test_upload_strings.py new file mode 100644 index 0000000000..884a36689a --- /dev/null +++ b/scripts/gp/tests/test_upload_strings.py @@ -0,0 +1,76 @@ +"""Tests for upload_strings.py.""" + +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock, call, patch + +import pytest + + +def _run_main(source_path: str): + """Helper to invoke upload_strings.main() with a --source argument.""" + with patch("sys.argv", ["upload_strings.py", "--source", source_path]): + import importlib + + import scripts.gp.upload_strings as upload_mod # noqa: PLC0415 + + importlib.reload(upload_mod) + upload_mod.main() + + +class TestUploadStrings: + def test_uploads_strings_when_bundle_exists(self, tmp_path): + source = tmp_path / "en.json" + source.write_text(json.dumps({"hello": "Hello", "bye": "Bye"}), encoding="utf-8") + + with ( + patch("scripts.gp.upload_strings.list_bundles", return_value={"bundleIds": ["langflow-ui"]}), + patch("scripts.gp.upload_strings.create_bundle") as mock_create, + patch("scripts.gp.upload_strings.upload_strings") as mock_upload, + patch("scripts.gp.upload_strings.GP_BUNDLE", "langflow-ui"), + ): + _run_main(str(source)) + + mock_create.assert_not_called() + mock_upload.assert_called_once_with({"hello": "Hello", "bye": "Bye"}) + + def test_creates_bundle_when_missing_then_uploads(self, tmp_path): + source = tmp_path / "en.json" + source.write_text(json.dumps({"hello": "Hello"}), encoding="utf-8") + + with ( + patch("scripts.gp.upload_strings.list_bundles", return_value={"bundleIds": []}), + patch("scripts.gp.upload_strings.create_bundle") as mock_create, + patch("scripts.gp.upload_strings.upload_strings") as mock_upload, + patch("scripts.gp.upload_strings.GP_BUNDLE", "langflow-ui"), + ): + _run_main(str(source)) + + mock_create.assert_called_once() + mock_upload.assert_called_once_with({"hello": "Hello"}) + + def test_empty_json_file_uploads_empty_dict(self, tmp_path): + source = tmp_path / "en.json" + source.write_text("{}", encoding="utf-8") + + with ( + patch("scripts.gp.upload_strings.list_bundles", return_value={"bundleIds": ["langflow-ui"]}), + patch("scripts.gp.upload_strings.create_bundle"), + patch("scripts.gp.upload_strings.upload_strings") as mock_upload, + patch("scripts.gp.upload_strings.GP_BUNDLE", "langflow-ui"), + ): + _run_main(str(source)) + + mock_upload.assert_called_once_with({}) + + def test_raises_when_source_file_missing(self, tmp_path): + missing = str(tmp_path / "missing.json") + + with ( + patch("scripts.gp.upload_strings.list_bundles", return_value={"bundleIds": []}), + patch("scripts.gp.upload_strings.create_bundle"), + patch("scripts.gp.upload_strings.upload_strings"), + ): + with pytest.raises(FileNotFoundError): + _run_main(missing) diff --git a/src/frontend/src/components/core/appHeaderComponent/components/LanguageSelector/index.tsx b/src/frontend/src/components/core/appHeaderComponent/components/LanguageSelector/index.tsx index 5ff1ac7cb7..e02fb2fd68 100644 --- a/src/frontend/src/components/core/appHeaderComponent/components/LanguageSelector/index.tsx +++ b/src/frontend/src/components/core/appHeaderComponent/components/LanguageSelector/index.tsx @@ -1,19 +1,10 @@ import { useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; +import { SUPPORTED_LANGUAGES } from "@/constants/languages"; import { useTypesStore } from "@/stores/typesStore"; -const LANGUAGES = [ - { code: "en", label: "English" }, - { code: "fr", label: "Français" }, - { code: "es", label: "Español" }, - { code: "de", label: "Deutsch" }, - { code: "pt", label: "Português" }, - { code: "ja", label: "日本語" }, - { code: "zh-Hans", label: "中文" }, -]; - export const LanguageSelector = () => { - const { i18n } = useTranslation(); + const { t, i18n } = useTranslation(); const queryClient = useQueryClient(); const setTypes = useTypesStore((state) => state.setTypes); @@ -26,11 +17,12 @@ export const LanguageSelector = () => { return ( handleChange(e.target.value)} className="rounded border border-border bg-background px-2 py-1.5 text-sm text-foreground" > - {LANGUAGES.map((lang) => ( + {SUPPORTED_LANGUAGES.map((lang) => (