Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions webview-ui/playwright/AppProviders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,10 @@ import { providerIdentifiers } from "@roo-code/types"

import { ExtensionStateContextProvider } from "@/context/ExtensionStateContext"
import TranslationProvider from "@/i18n/TranslationContext"
import i18next, { loadTranslations } from "@/i18n/setup"
import i18next from "@/i18n/setup"
import { TooltipProvider } from "@/components/ui/tooltip"
import { TranslationContext as PlaywrightTranslationContext } from "@src/i18n/TranslationContext"

loadTranslations()

type InitialState = NonNullable<React.ComponentProps<typeof ExtensionStateContextProvider>["initialState"]>

interface AppProvidersProps {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ vi.mock("@/i18n/setup", () => ({
language: "en",
changeLanguage: vi.fn(),
},
loadTranslations: vi.fn(),
}))

vi.mock("react-i18next", () => ({
Expand Down
32 changes: 21 additions & 11 deletions webview-ui/src/i18n/TranslationContext.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { createContext, useContext, ReactNode, useEffect, useCallback } from "react"
import { useTranslation } from "react-i18next"
import i18next, { loadTranslations } from "./setup"
import i18next, { FALLBACK_LANGUAGE, loadLanguage } from "./setup"
import { useExtensionState } from "@/context/ExtensionStateContext"

// Create context for translations
Expand All @@ -18,19 +18,29 @@
const { i18n } = useTranslation()
// Get the extension state directly - it already contains all state properties
const extensionState = useExtensionState()
const language = extensionState.language || FALLBACK_LANGUAGE

Check warning on line 21 in webview-ui/src/i18n/TranslationContext.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/i18n/TranslationContext.tsx:21: 3 mutation test gaps; example: Survived ConditionalExpression mutant (replacement: false). See the job summary for the complete list and resolution guidance.

// Load translations once when the component mounts
// Only the fallback locale ships in the entry chunk, so any other language
// has to fetch its bundles before i18next can resolve a key. Switching after
// the fetch is deliberate: `changeLanguage` is what emits `languageChanged`,
// the event `useTranslation` subscribes to for re-renders. Until it fires the
// subtree keeps resolving keys through whichever language is still active:
// `fallbackLng` on a cold start, the previous language on a warm switch
// between two non-English locales. The swap-then-render shape is the same one
// this provider had when every locale was bundled.
useEffect(() => {
try {
loadTranslations()
} catch (error) {
console.error("Failed to load translations:", error)
}
}, [])
let cancelled = false

Check warning on line 32 in webview-ui/src/i18n/TranslationContext.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/i18n/TranslationContext.tsx:32: Survived BooleanLiteral mutant (replacement: true). See the job summary for the complete list and resolution guidance.

useEffect(() => {
i18n.changeLanguage(extensionState.language)
}, [i18n, extensionState.language])
void loadLanguage(language).then(() => {

Check warning on line 34 in webview-ui/src/i18n/TranslationContext.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/i18n/TranslationContext.tsx:34: Survived BlockStatement mutant (replacement: {}). See the job summary for the complete list and resolution guidance.
if (!cancelled) {
void i18n.changeLanguage(language)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
})

return () => {
cancelled = true
}
}, [i18n, language])

// Memoize the translation function to prevent unnecessary re-renders
const translate = useCallback(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { act, render, screen, waitFor } from "@testing-library/react"
import React from "react"

import i18next, { loadLanguage } from "../setup"
import TranslationProvider, { useAppTranslation } from "../TranslationContext"

// The provider reads the active language from extension state. Tests drive it
// through a mutable holder so a rerender can simulate a settings change.
const extensionState: { language: string } = { language: "en" }

vi.mock("@/context/ExtensionStateContext", () => ({
useExtensionState: () => extensionState,
}))

const Probe = () => {
const { t, i18n } = useAppTranslation()

return (
<div>
<span data-testid="error-title">{t("common:errorBoundary.title")}</span>
<span data-testid="active-language">{i18n.language}</span>
</div>
)
}

const renderProbe = (language: string) => {
extensionState.language = language

return render(
<TranslationProvider>
<Probe />
</TranslationProvider>,
)
}

describe("TranslationProvider locale loading", () => {
beforeEach(async () => {
// i18next is a module-level singleton, so a language left active by an
// earlier test would mask the fallback path this suite is checking.
await i18next.changeLanguage("en")
})

it("renders translated strings for a non-English initial language", async () => {
renderProbe("ko")

// Korean bundles are not in the entry chunk, so the first paint resolves
// through the English fallback and the switch lands after the fetch.
expect(screen.getByTestId("error-title")).toHaveTextContent("Something went wrong")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

await waitFor(() => {
expect(screen.getByTestId("error-title")).toHaveTextContent("문제가 발생했습니다")
})
expect(screen.getByTestId("active-language")).toHaveTextContent("ko")
})

it("loads a non-eager locale when the language changes at runtime", async () => {
const { rerender } = renderProbe("en")

expect(screen.getByTestId("error-title")).toHaveTextContent("Something went wrong")

extensionState.language = "ja"
await act(async () => {
rerender(
<TranslationProvider>
<Probe />
</TranslationProvider>,
)
})

await waitFor(() => {
expect(screen.getByTestId("error-title")).toHaveTextContent("問題が発生しました")
})
expect(screen.getByTestId("active-language")).toHaveTextContent("ja")
})

it("falls back to English when the requested locale has no resources", async () => {
renderProbe("xx-unsupported")

await waitFor(() => {
expect(screen.getByTestId("error-title")).toHaveTextContent("Something went wrong")
})
})

it("keeps English available after switching away and back", async () => {
const { rerender } = renderProbe("de")

await waitFor(() => {
expect(screen.getByTestId("error-title")).toHaveTextContent("Etwas ist schiefgelaufen")
})

extensionState.language = "en"
await act(async () => {
rerender(
<TranslationProvider>
<Probe />
</TranslationProvider>,
)
})

await waitFor(() => {
expect(screen.getByTestId("error-title")).toHaveTextContent("Something went wrong")
})
})

it("keeps the previous locale rendered while a warm switch is still loading", async () => {
const { rerender } = renderProbe("de")

await waitFor(() => {
expect(screen.getByTestId("error-title")).toHaveTextContent("Etwas ist schiefgelaufen")
})

extensionState.language = "fr"
await act(async () => {
rerender(
<TranslationProvider>
<Probe />
</TranslationProvider>,
)
})

// French is not in the entry chunk and no other case loads it, so the
// switch cannot have landed yet. A `changeLanguage` fired before the fetch
// resolves would paint the English fallback here instead of German.
expect(screen.getByTestId("error-title")).toHaveTextContent("Etwas ist schiefgelaufen")

await waitFor(() => {
expect(screen.getByTestId("error-title")).toHaveTextContent("Une erreur s'est produite")
})
expect(screen.getByTestId("active-language")).toHaveTextContent("fr")
})

it("skips the language switch when the provider unmounts before the fetch resolves", async () => {
const changeLanguage = vi.spyOn(i18next, "changeLanguage")

const { unmount } = renderProbe("vi")
// The locale chunk is still in flight here, so the effect cleanup runs
// before the fetch settles and `cancelled` decides the outcome.
unmount()

// `loadLanguage` memoizes the in-flight promise, so awaiting the same
// locale here waits for the exact resolution the guard is deciding on.
// The provider attached its `.then` first, so its callback has already run
// by the time this returns: the switch was suppressed, not left pending.
await loadLanguage("vi")

expect(i18next.hasResourceBundle("vi", "common")).toBe(true)
expect(changeLanguage).not.toHaveBeenCalledWith("vi")
expect(i18next.language).not.toBe("vi")
})
})
3 changes: 2 additions & 1 deletion webview-ui/src/i18n/__tests__/TranslationContext.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ vi.mock("../setup", () => ({
},
changeLanguage: vi.fn(),
},
loadTranslations: vi.fn(),
FALLBACK_LANGUAGE: "en",
loadLanguage: vi.fn(() => Promise.resolve()),
}))

const TestComponent = () => {
Expand Down
43 changes: 43 additions & 0 deletions webview-ui/src/i18n/__tests__/setup.rejection.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import i18next, { FALLBACK_LANGUAGE, loadLanguage } from "../setup"

// A dynamic import that rejects is what a 404 or a chunk missing from the
// deployed manifest looks like at runtime. One namespace is enough: `loadLanguage`
// registers a language only after every namespace resolves.
vi.mock("../locales/ja/common.json", () => {
throw new Error("Failed to fetch dynamically imported module")
})

describe("i18n setup locale load failure", () => {
afterEach(() => {
vi.restoreAllMocks()
})

it("resolves without throwing and keeps the English fallback", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined)

await expect(loadLanguage("ja")).resolves.toBeUndefined()

expect(warn).toHaveBeenCalledWith("Could not load ja translations:", expect.any(Error))
// All-or-nothing per language: the namespaces that did resolve are not
// registered either, so no half-translated locale can be activated.
expect(i18next.hasResourceBundle("ja", "common")).toBe(false)
expect(i18next.hasResourceBundle("ja", "settings")).toBe(false)
expect(i18next.t("common:errorBoundary.title", { lng: "ja" })).toBe("Something went wrong")
})

it("does not memoize a failed load, so the next call re-attempts it", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined)

await loadLanguage("ja")
await loadLanguage("ja")

// `loadedLanguages` is only updated on success and `pendingLanguages` is
// cleared in `finally`, so a transient failure must not be memoized.
// This covers `loadLanguage`'s own bookkeeping only: engines that cache a
// failed module fetch in the module map (every Chromium below 156, so the
// VS Code webview) can still reject the second import from that cache
// without re-requesting the chunk.
expect(warn).toHaveBeenCalledTimes(2)
expect(i18next.language).toBe(FALLBACK_LANGUAGE)
})
})
46 changes: 46 additions & 0 deletions webview-ui/src/i18n/__tests__/setup.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import i18next, { FALLBACK_LANGUAGE, loadLanguage } from "../setup"

describe("i18n setup locale loading", () => {
it("ships the fallback locale eagerly", () => {
expect(i18next.hasResourceBundle(FALLBACK_LANGUAGE, "common")).toBe(true)
expect(i18next.hasResourceBundle(FALLBACK_LANGUAGE, "settings")).toBe(true)
})

it("does not ship non-fallback locales in the entry graph", () => {
// `hasResourceBundle` is false until `loadLanguage` fetches a chunk, which
// is what keeps the 17 other locales out of the first paint.
for (const language of ["ko", "ja", "de", "zh-CN"]) {
expect(i18next.hasResourceBundle(language, "common")).toBe(false)
}
})

it("registers every namespace of a lazily loaded locale", async () => {
await loadLanguage("ko")

expect(i18next.hasResourceBundle("ko", "common")).toBe(true)
expect(i18next.hasResourceBundle("ko", "settings")).toBe(true)
expect(i18next.t("common:errorBoundary.title", { lng: "ko" })).toBe("문제가 발생했습니다")
})

it("shares one fetch across concurrent requests for the same locale", async () => {
const addResourceBundle = vi.spyOn(i18next, "addResourceBundle")

// `loadLanguage` memoizes the in-flight promise, so concurrent callers get
// the very same promise back: one fetch, one registration pass.
const inFlight = [loadLanguage("ja"), loadLanguage("ja"), loadLanguage("ja")]
expect(inFlight[1]).toBe(inFlight[0])
expect(inFlight[2]).toBe(inFlight[0])

const results = await Promise.all(inFlight)

expect(results).toHaveLength(3)
// 9 namespaces registered once each, not 27.
expect(addResourceBundle).toHaveBeenCalledTimes(9)
expect(i18next.t("common:errorBoundary.title", { lng: "ja" })).toBe("問題が発生しました")
})

it("resolves without throwing for a locale that has no bundles", async () => {
await expect(loadLanguage("xx-unsupported")).resolves.toBeUndefined()
expect(i18next.t("common:errorBoundary.title", { lng: "xx-unsupported" })).toBe("Something went wrong")
})
})
Loading
Loading