From a415324a6f4666782ee8313ee69e6c2ef91b7dda Mon Sep 17 00:00:00 2001 From: ShotaroKataoka Date: Mon, 2 Nov 2026 23:16:16 +0900 Subject: [PATCH] fix(web-ui): translate the deck list load error and test Settings - useDeckList: the first-load failure banner shows the translated decksPage.errorLoadDecks instead of the raw deckService error (English); the raw error goes to console.error. Translated at render, so the load effect does not re-run on locale change. - Add Settings.test.tsx (local): the agent-updated toast is translated (ja) and a failed PUT rolls the selection back with the translated error. Follow-up to review items 3 and 5 of #493. Verified: i18n key parity OK; eslint --quiet, tsc --noEmit pass; npm test 71 files pass; npm run build:cloud pass. --- web-ui/messages/en.json | 3 +- web-ui/messages/ja.json | 3 +- web-ui/src/components/Settings.test.tsx | 72 +++++++++++++++++++++++++ web-ui/src/hooks/useDeckList.test.ts | 13 ++++- web-ui/src/hooks/useDeckList.ts | 8 ++- 5 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 web-ui/src/components/Settings.test.tsx diff --git a/web-ui/messages/en.json b/web-ui/messages/en.json index 4db07a20..a5666ead 100644 --- a/web-ui/messages/en.json +++ b/web-ui/messages/en.json @@ -421,7 +421,8 @@ "bulkDeleteFailed": "{count, plural, one {# deck} other {# decks}} could not be deleted.", "bulkDeleteDescriptionLocal": "The selected deck folders will be deleted immediately. This action cannot be undone.", "errorUpdateVisibility": "Failed to update visibility.", - "errorDeleteDeck": "Failed to delete deck." + "errorDeleteDeck": "Failed to delete deck.", + "errorLoadDecks": "Failed to load decks." }, "mcp": { "connected": "Connected: {names}", diff --git a/web-ui/messages/ja.json b/web-ui/messages/ja.json index 1dbe9eb3..4132e880 100644 --- a/web-ui/messages/ja.json +++ b/web-ui/messages/ja.json @@ -421,7 +421,8 @@ "bulkDeleteFailed": "{count, plural, other {# 件のデッキを削除できませんでした。}}", "bulkDeleteDescriptionLocal": "選択したデッキのフォルダを即時に削除します。この操作は元に戻せません。", "errorUpdateVisibility": "公開範囲を更新できませんでした", - "errorDeleteDeck": "デッキを削除できませんでした" + "errorDeleteDeck": "デッキを削除できませんでした", + "errorLoadDecks": "デッキを読み込めませんでした" }, "mcp": { "connected": "接続済み: {names}", diff --git a/web-ui/src/components/Settings.test.tsx b/web-ui/src/components/Settings.test.tsx new file mode 100644 index 00000000..c3c792a4 --- /dev/null +++ b/web-ui/src/components/Settings.test.tsx @@ -0,0 +1,72 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT-0 +/** Settings (local): agent selection toasts are translated and a failed save rolls back. */ + +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest" +import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react" +import { toast } from "sonner" +import { renderWithIntl } from "@/test/renderWithIntl" + +vi.mock("@/lib/mode", () => ({ IS_LOCAL: true })) +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })) +vi.mock("@/services/localService", () => ({ + getAgentDefinitions: vi.fn(), + listLocalModels: vi.fn(), + updateAgentSelection: vi.fn(), +})) + +import { getAgentDefinitions, listLocalModels, updateAgentSelection } from "@/services/localService" +import { Settings } from "./Settings" + +// jsdom has no scrollIntoView; cmdk and ModelPicker call it. +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn() +}) + +const AGENTS = [ + { fileName: "a.json", name: "Agent A", description: "" }, + { fileName: "b.json", name: "Agent B", description: "" }, +] + +async function pickOrchestrator(locale: "en" | "ja") { + renderWithIntl( {}} />, { locale }) + const trigger = await screen.findByLabelText("Select orchestrator agent") + await waitFor(() => expect(trigger.textContent).toContain("Agent A")) + fireEvent.click(trigger) + fireEvent.click(screen.getByRole("option", { name: /Agent B/ })) + return trigger +} + +describe("Settings (local) — agent selection", () => { + beforeEach(() => { + vi.mocked(getAgentDefinitions).mockResolvedValue({ + agents: AGENTS, + selection: { orchestrator: "a.json", composer: "a.json", style: "a.json", translate: "a.json" }, + }) + vi.mocked(listLocalModels).mockResolvedValue([]) + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it("shows the translated toast after saving", async () => { + vi.mocked(updateAgentSelection).mockResolvedValue(undefined) + const trigger = await pickOrchestrator("ja") + await waitFor(() => expect(toast.success).toHaveBeenCalledWith( + "orchestrator エージェントを更新しました", + { description: "Agent B を使用します。次のチャットから反映され、現在のチャットには影響しません。" }, + )) + expect(updateAgentSelection).toHaveBeenCalledWith({ orchestrator: "b.json" }) + expect(trigger.textContent).toContain("Agent B") + }) + + it("rolls the selection back and shows the translated error when the save fails", async () => { + vi.mocked(updateAgentSelection).mockRejectedValue(new Error("HTTP 500")) + const trigger = await pickOrchestrator("ja") + await waitFor(() => expect(toast.error).toHaveBeenCalledWith("orchestrator エージェントを更新できませんでした")) + expect(toast.success).not.toHaveBeenCalled() + await waitFor(() => expect(trigger.textContent).toContain("Agent A")) + }) +}) diff --git a/web-ui/src/hooks/useDeckList.test.ts b/web-ui/src/hooks/useDeckList.test.ts index e5ddd207..8a050c8c 100644 --- a/web-ui/src/hooks/useDeckList.test.ts +++ b/web-ui/src/hooks/useDeckList.test.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT-0 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { act, renderHook } from "@testing-library/react" +import { act, renderHook, waitFor } from "@testing-library/react" import { toast } from "sonner" import { intlWrapper } from "@/test/renderWithIntl" import en from "../../messages/en.json" @@ -109,4 +109,15 @@ describe("useDeckList — translated action errors", () => { await act(async () => { await result.current.handleToggleVisibility("d1", "public") }) expect(toast.error).toHaveBeenCalledWith(expected) }) + + it.each([ + ["en", en.decksPage.errorLoadDecks], + ["ja", "デッキを読み込めませんでした"], + ] as const)("shows the %s banner instead of the raw error when the list cannot load", async (locale, expected) => { + api.listDecks.mockRejectedValueOnce(new Error("Failed to list decks: 500")) + vi.spyOn(console, "error").mockImplementation(() => {}) + const { result } = renderHook(() => useDeckList("token", true, null), { wrapper: intlWrapper(locale) }) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.error).toBe(expected) + }) }) diff --git a/web-ui/src/hooks/useDeckList.ts b/web-ui/src/hooks/useDeckList.ts index cd48d1e4..f63cae8e 100644 --- a/web-ui/src/hooks/useDeckList.ts +++ b/web-ui/src/hooks/useDeckList.ts @@ -51,7 +51,8 @@ export function useDeckList( const [favoriteIds, setFavoriteIds] = useState>(new Set()) const [activeListTab, setActiveListTab] = useState<"mine" | "favorites" | "public">("mine") const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) + /** The first list load failed; translated at render (the raw error goes to the console). */ + const [loadFailed, setLoadFailed] = useState(false) const [deleteTarget, setDeleteTarget] = useState(null) const [bulkDeleteTargets, setBulkDeleteTargets] = useState(null) const [bulkProgress, setBulkProgress] = useState<{ done: number; total: number } | null>(null) @@ -94,7 +95,8 @@ export function useDeckList( setFavoriteDecks(favDecks) } catch (err) { if (!initialLoadDone.current) { - setError(err instanceof Error ? err.message : "Failed to load decks") + console.error("Failed to load decks", err) + setLoadFailed(true) } } finally { initialLoadDone.current = true @@ -263,6 +265,8 @@ export function useDeckList( : activeListTab === "favorites" ? favoriteDecks : publicDecks + const error = loadFailed ? t("errorLoadDecks") : null + return { decks, tabDecks, favoriteIds, activeListTab, setActiveListTab, loading, error, deleteTarget, setDeleteTarget,