Skip to content

Commit

Permalink
Improve i18n support and add missing translations (#6070)
Browse files Browse the repository at this point in the history
Co-authored-by: openhands <[email protected]>
  • Loading branch information
neubig and openhands-agent authored Jan 14, 2025
1 parent 92b8d55 commit f0ebf3e
Show file tree
Hide file tree
Showing 59 changed files with 5,189 additions and 2,285 deletions.
2 changes: 1 addition & 1 deletion frontend/__tests__/components/browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ describe("Browser", () => {
});

// i18n empty message key
expect(screen.getByText("BROWSER$EMPTY_MESSAGE")).toBeInTheDocument();
expect(screen.getByText("BROWSER$NO_PAGE_LOADED")).toBeInTheDocument();
});

it("renders the url and a screenshot", () => {
Expand Down
8 changes: 3 additions & 5 deletions frontend/__tests__/components/chat/chat-input.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,10 @@ describe("ChatInput", () => {
expect(onSubmitMock).not.toHaveBeenCalled();
});

it("should render a placeholder", () => {
render(
<ChatInput placeholder="Enter your message" onSubmit={onSubmitMock} />,
);
it("should render a placeholder with translation key", () => {
render(<ChatInput onSubmit={onSubmitMock} />);

const textarea = screen.getByPlaceholderText("Enter your message");
const textarea = screen.getByPlaceholderText("SUGGESTIONS$WHAT_TO_BUILD");
expect(textarea).toBeInTheDocument();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ describe("ConversationPanel", () => {

renderConversationPanel();

const emptyState = await screen.findByText("No conversations found");
const emptyState = await screen.findByText("CONVERSATION$NO_CONVERSATIONS");
expect(emptyState).toBeInTheDocument();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ describe("GitHubRepositorySelector", () => {
);

expect(
screen.getByPlaceholderText("Select a GitHub project"),
screen.getByPlaceholderText("LANDING$SELECT_REPO"),
).toBeInTheDocument();
});

Expand Down
10 changes: 7 additions & 3 deletions frontend/__tests__/components/features/sidebar/sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ describe("Sidebar", () => {
await user.click(norskOption);

const tokenInput =
within(accountSettingsModal).getByLabelText(/github token/i);
within(accountSettingsModal).getByLabelText(/GITHUB\$TOKEN_OPTIONAL/i);
await user.type(tokenInput, "new-token");

const saveButton =
Expand All @@ -151,7 +151,11 @@ describe("Sidebar", () => {

const settingsModal = screen.getByTestId("ai-config-modal");

const apiKeyInput = within(settingsModal).getByLabelText(/api key/i);
// Click the advanced options switch to show the API key input
const advancedOptionsSwitch = within(settingsModal).getByTestId("advanced-option-switch");
await user.click(advancedOptionsSwitch);

const apiKeyInput = within(settingsModal).getByLabelText(/API\$KEY/i);
await user.type(apiKeyInput, "SET");

const saveButton = within(settingsModal).getByTestId(
Expand All @@ -162,7 +166,7 @@ describe("Sidebar", () => {
expect(saveSettingsSpy).toHaveBeenCalledWith({
...MOCK_USER_PREFERENCES.settings,
llm_api_key: undefined,
llm_base_url: undefined,
llm_base_url: "",
security_analyzer: undefined,
});
});
Expand Down
17 changes: 9 additions & 8 deletions frontend/__tests__/components/feedback-form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "test-utils";
import { FeedbackForm } from "#/components/features/feedback/feedback-form";
import { I18nKey } from "#/i18n/declaration";

describe("FeedbackForm", () => {
const user = userEvent.setup();
Expand All @@ -28,20 +29,20 @@ describe("FeedbackForm", () => {
<FeedbackForm polarity="positive" onClose={onCloseMock} />,
);

screen.getByLabelText("Email");
screen.getByLabelText("Private");
screen.getByLabelText("Public");
screen.getByLabelText(I18nKey.FEEDBACK$EMAIL_LABEL);
screen.getByLabelText(I18nKey.FEEDBACK$PRIVATE_LABEL);
screen.getByLabelText(I18nKey.FEEDBACK$PUBLIC_LABEL);

screen.getByRole("button", { name: "Submit" });
screen.getByRole("button", { name: "Cancel" });
screen.getByRole("button", { name: I18nKey.FEEDBACK$CONTRIBUTE_LABEL });
screen.getByRole("button", { name: I18nKey.FEEDBACK$CANCEL_LABEL });
});

it("should switch between private and public permissions", async () => {
renderWithProviders(
<FeedbackForm polarity="positive" onClose={onCloseMock} />,
);
const privateRadio = screen.getByLabelText("Private");
const publicRadio = screen.getByLabelText("Public");
const privateRadio = screen.getByLabelText(I18nKey.FEEDBACK$PRIVATE_LABEL);
const publicRadio = screen.getByLabelText(I18nKey.FEEDBACK$PUBLIC_LABEL);

expect(privateRadio).toBeChecked(); // private is the default value
expect(publicRadio).not.toBeChecked();
Expand All @@ -59,7 +60,7 @@ describe("FeedbackForm", () => {
renderWithProviders(
<FeedbackForm polarity="positive" onClose={onCloseMock} />,
);
await user.click(screen.getByRole("button", { name: "Cancel" }));
await user.click(screen.getByRole("button", { name: I18nKey.FEEDBACK$CANCEL_LABEL }));

expect(onCloseMock).toHaveBeenCalled();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ describe("InteractiveChatBox", () => {
expect(onChange).not.toHaveBeenCalledWith("");

// Submit the message with image
const submitButton = screen.getByRole("button", { name: "Send" });
const submitButton = screen.getByRole("button", { name: "BUTTON$SEND" });
await user.click(submitButton);

// Verify onSubmit was called with the message and image
Expand Down
190 changes: 190 additions & 0 deletions frontend/__tests__/components/landing-translations.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { render, screen } from "@testing-library/react";
import { test, expect, describe, vi } from "vitest";
import { useTranslation } from "react-i18next";
import translations from "../../src/i18n/translation.json";
import { UserAvatar } from "../../src/components/features/sidebar/user-avatar";

vi.mock("@nextui-org/react", () => ({
Tooltip: ({ content, children }: { content: string; children: React.ReactNode }) => (
<div>
{children}
<div>{content}</div>
</div>
),
}));

const supportedLanguages = ['en', 'ja', 'zh-CN', 'zh-TW', 'ko-KR', 'de', 'no', 'it', 'pt', 'es', 'ar', 'fr', 'tr'];

// Helper function to check if a translation exists for all supported languages
function checkTranslationExists(key: string) {
const missingTranslations: string[] = [];

const translationEntry = (translations as Record<string, Record<string, string>>)[key];
if (!translationEntry) {
throw new Error(`Translation key "${key}" does not exist in translation.json`);
}

for (const lang of supportedLanguages) {
if (!translationEntry[lang]) {
missingTranslations.push(lang);
}
}

return missingTranslations;
}

// Helper function to find duplicate translation keys
function findDuplicateKeys(obj: Record<string, any>) {
const seen = new Set<string>();
const duplicates = new Set<string>();

// Only check top-level keys as these are our translation keys
for (const key in obj) {
if (seen.has(key)) {
duplicates.add(key);
} else {
seen.add(key);
}
}

return Array.from(duplicates);
}

vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => {
const translationEntry = (translations as Record<string, Record<string, string>>)[key];
return translationEntry?.ja || key;
},
}),
}));

describe("Landing page translations", () => {
test("should render Japanese translations correctly", () => {
// Mock a simple component that uses the translations
const TestComponent = () => {
const { t } = useTranslation();
return (
<div>
<UserAvatar onClick={() => {}} />
<div data-testid="main-content">
<h1>{t("LANDING$TITLE")}</h1>
<button>{t("VSCODE$OPEN")}</button>
<button>{t("SUGGESTIONS$INCREASE_TEST_COVERAGE")}</button>
<button>{t("SUGGESTIONS$AUTO_MERGE_PRS")}</button>
<button>{t("SUGGESTIONS$FIX_README")}</button>
<button>{t("SUGGESTIONS$CLEAN_DEPENDENCIES")}</button>
</div>
<div data-testid="tabs">
<span>{t("WORKSPACE$TERMINAL_TAB_LABEL")}</span>
<span>{t("WORKSPACE$BROWSER_TAB_LABEL")}</span>
<span>{t("WORKSPACE$JUPYTER_TAB_LABEL")}</span>
<span>{t("WORKSPACE$CODE_EDITOR_TAB_LABEL")}</span>
</div>
<div data-testid="workspace-label">{t("WORKSPACE$TITLE")}</div>
<button data-testid="new-project">{t("PROJECT$NEW_PROJECT")}</button>
<div data-testid="status">
<span>{t("TERMINAL$WAITING_FOR_CLIENT")}</span>
<span>{t("STATUS$CONNECTED")}</span>
<span>{t("STATUS$CONNECTED_TO_SERVER")}</span>
</div>
<div data-testid="time">
<span>{`5 ${t("TIME$MINUTES_AGO")}`}</span>
<span>{`2 ${t("TIME$HOURS_AGO")}`}</span>
<span>{`3 ${t("TIME$DAYS_AGO")}`}</span>
</div>
</div>
);
};

render(<TestComponent />);

// Check main content translations
expect(screen.getByText("開発を始めましょう!")).toBeInTheDocument();
expect(screen.getByText("VS Codeで開く")).toBeInTheDocument();
expect(screen.getByText("テストカバレッジを向上させる")).toBeInTheDocument();
expect(screen.getByText("Dependabot PRを自動マージ")).toBeInTheDocument();
expect(screen.getByText("READMEを改善")).toBeInTheDocument();
expect(screen.getByText("依存関係を整理")).toBeInTheDocument();

// Check user avatar tooltip
const userAvatar = screen.getByTestId("user-avatar");
userAvatar.focus();
expect(screen.getByText("アカウント設定")).toBeInTheDocument();

// Check tab labels
const tabs = screen.getByTestId("tabs");
expect(tabs).toHaveTextContent("ターミナル");
expect(tabs).toHaveTextContent("ブラウザ");
expect(tabs).toHaveTextContent("Jupyter");
expect(tabs).toHaveTextContent("コードエディタ");

// Check workspace label and new project button
expect(screen.getByTestId("workspace-label")).toHaveTextContent("ワークスペース");
expect(screen.getByTestId("new-project")).toHaveTextContent("新規プロジェクト");

// Check status messages
const status = screen.getByTestId("status");
expect(status).toHaveTextContent("クライアントの準備を待機中");
expect(status).toHaveTextContent("接続済み");
expect(status).toHaveTextContent("サーバーに接続済み");

// Check account settings menu
expect(screen.getByText("アカウント設定")).toBeInTheDocument();

// Check time-related translations
const time = screen.getByTestId("time");
expect(time).toHaveTextContent("5 分前");
expect(time).toHaveTextContent("2 時間前");
expect(time).toHaveTextContent("3 日前");
});

test("all translation keys should have translations for all supported languages", () => {
// Test all translation keys used in the component
const translationKeys = [
"LANDING$TITLE",
"VSCODE$OPEN",
"SUGGESTIONS$INCREASE_TEST_COVERAGE",
"SUGGESTIONS$AUTO_MERGE_PRS",
"SUGGESTIONS$FIX_README",
"SUGGESTIONS$CLEAN_DEPENDENCIES",
"WORKSPACE$TERMINAL_TAB_LABEL",
"WORKSPACE$BROWSER_TAB_LABEL",
"WORKSPACE$JUPYTER_TAB_LABEL",
"WORKSPACE$CODE_EDITOR_TAB_LABEL",
"WORKSPACE$TITLE",
"PROJECT$NEW_PROJECT",
"TERMINAL$WAITING_FOR_CLIENT",
"STATUS$CONNECTED",
"STATUS$CONNECTED_TO_SERVER",
"TIME$MINUTES_AGO",
"TIME$HOURS_AGO",
"TIME$DAYS_AGO"
];

// Check all keys and collect missing translations
const missingTranslationsMap = new Map<string, string[]>();
translationKeys.forEach(key => {
const missing = checkTranslationExists(key);
if (missing.length > 0) {
missingTranslationsMap.set(key, missing);
}
});

// If any translations are missing, throw an error with all missing translations
if (missingTranslationsMap.size > 0) {
const errorMessage = Array.from(missingTranslationsMap.entries())
.map(([key, langs]) => `\n- "${key}" is missing translations for: ${langs.join(', ')}`)
.join('');
throw new Error(`Missing translations:${errorMessage}`);
}
});

test("translation file should not have duplicate keys", () => {
const duplicates = findDuplicateKeys(translations);

if (duplicates.length > 0) {
throw new Error(`Found duplicate translation keys: ${duplicates.join(', ')}`);
}
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ModelSelector } from "#/components/shared/modals/settings/model-selector";
import { I18nKey } from "#/i18n/declaration";

// Mock react-i18next
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: { [key: string]: string } = {
LLM$PROVIDER: "LLM Provider",
LLM$MODEL: "LLM Model",
LLM$SELECT_PROVIDER_PLACEHOLDER: "Select a provider",
LLM$SELECT_MODEL_PLACEHOLDER: "Select a model",
};
return translations[key] || key;
},
}),
}));

describe("ModelSelector", () => {
const models = {
Expand Down
27 changes: 27 additions & 0 deletions frontend/__tests__/components/suggestion-item.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SuggestionItem } from "#/components/features/suggestions/suggestion-item";
import { I18nKey } from "#/i18n/declaration";

vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
"SUGGESTIONS$TODO_APP": "ToDoリストアプリを開発する",
"LANDING$BUILD_APP_BUTTON": "プルリクエストを表示するアプリを開発する",
"SUGGESTIONS$HACKER_NEWS": "Hacker Newsのトップ記事を表示するbashスクリプトを作成する",
};
return translations[key] || key;
},
}),
}));

describe("SuggestionItem", () => {
const suggestionItem = { label: "suggestion1", value: "a long text value" };
Expand All @@ -18,6 +32,19 @@ describe("SuggestionItem", () => {
expect(screen.getByText(/suggestion1/i)).toBeInTheDocument();
});

it("should render a translated suggestion when using I18nKey", async () => {
const translatedSuggestion = {
label: I18nKey.SUGGESTIONS$TODO_APP,
value: "todo app value",
};

const { container } = render(<SuggestionItem suggestion={translatedSuggestion} onClick={onClick} />);
console.log('Rendered HTML:', container.innerHTML);


expect(screen.getByText("ToDoリストアプリを開発する")).toBeInTheDocument();
});

it("should call onClick when clicking a suggestion", async () => {
const user = userEvent.setup();
render(<SuggestionItem suggestion={suggestionItem} onClick={onClick} />);
Expand Down
Loading

0 comments on commit f0ebf3e

Please sign in to comment.