diff --git a/dashboard/src/__tests__/AuditPage.test.tsx b/dashboard/src/__tests__/AuditPage.test.tsx index 41b7b1295..73dcae201 100644 --- a/dashboard/src/__tests__/AuditPage.test.tsx +++ b/dashboard/src/__tests__/AuditPage.test.tsx @@ -226,7 +226,7 @@ describe('AuditPage', () => { expect(screen.getByText('Page 1 of 2')).toBeDefined(); }); - await act(async () => { fireEvent.click(screen.getByLabelText('Next page')); }); + await act(async () => { fireEvent.click(screen.getByLabelText('Go to next page')); }); await waitFor(() => { expect(mockFetchAuditLogs).toHaveBeenLastCalledWith(expect.objectContaining({ diff --git a/dashboard/src/__tests__/LiveTerminal.test.tsx b/dashboard/src/__tests__/LiveTerminal.test.tsx index 2b9be2c97..449d9f2b9 100644 --- a/dashboard/src/__tests__/LiveTerminal.test.tsx +++ b/dashboard/src/__tests__/LiveTerminal.test.tsx @@ -13,7 +13,7 @@ const src = readFileSync(resolve(__dirname, '../components/session/LiveTerminal. describe('LiveTerminal — streaming failure UX (issue #2347)', () => { it('failure detail banner has accessible retry button', () => { - expect(src).toContain('aria-label="Retry terminal connection"'); + expect(src).toContain('aria-label={t("aria.retryTerminal")}'); }); it('failure banner mentions transcript and metrics fallback', () => { diff --git a/dashboard/src/__tests__/OverviewPage.test.tsx b/dashboard/src/__tests__/OverviewPage.test.tsx index c5d74ffb0..2697a0246 100644 --- a/dashboard/src/__tests__/OverviewPage.test.tsx +++ b/dashboard/src/__tests__/OverviewPage.test.tsx @@ -29,10 +29,20 @@ vi.mock('../store/useStore', () => ({ useStore: vi.fn((sel: (s: Record) => unknown) => sel({ sseError: null })), })); -// Mock i18n -vi.mock('../i18n/context', () => ({ - useT: () => (key: string) => key, -})); +// Mock i18n — resolves keys from the English catalog +vi.mock('../i18n/context', async () => { + const { en } = await import('../i18n/en'); + const catalog: Record = {}; + const flatten = (obj: any, prefix: string) => { + for (const [k, v] of Object.entries(obj)) { + const key = prefix ? prefix + '.' + k : k; + if (typeof v === 'string') catalog[key] = v; + else if (typeof v === 'object' && v !== null) flatten(v, key); + } + }; + flatten(en, ''); + return { useT: () => (key: string) => catalog[key] || key }; +}); // Mock child components vi.mock('../components/overview/HomeStatusPanel', () => ({ @@ -58,7 +68,7 @@ describe('OverviewPage (CCMeter redesign)', () => { it('renders page title', () => { render(); - expect(screen.getByText('overview.title')).not.toBeNull(); + expect(screen.getByText('Overview')).not.toBeNull(); }); it('renders New Session button', () => { diff --git a/dashboard/src/__tests__/TerminalPassthrough.test.tsx b/dashboard/src/__tests__/TerminalPassthrough.test.tsx index d25ab4163..90c957262 100644 --- a/dashboard/src/__tests__/TerminalPassthrough.test.tsx +++ b/dashboard/src/__tests__/TerminalPassthrough.test.tsx @@ -20,7 +20,7 @@ describe('TerminalPassthrough', () => { describe('streaming failure UX (issue #2347)', () => { it('failure detail banner has accessible retry button', () => { - expect(src).toContain('aria-label="Retry terminal connection"'); + expect(src).toContain('aria.retryTerminal'); }); it('failure banner mentions transcript and metrics fallback', () => { diff --git a/dashboard/src/__tests__/i18n-integration.test.tsx b/dashboard/src/__tests__/i18n-integration.test.tsx index 96d6b5ccb..420c98f31 100644 --- a/dashboard/src/__tests__/i18n-integration.test.tsx +++ b/dashboard/src/__tests__/i18n-integration.test.tsx @@ -3,6 +3,9 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; +// Needs real useT — override global mock +vi.unmock("../i18n/context"); + vi.mock('../utils/logger', () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, })); diff --git a/dashboard/src/__tests__/setup.ts b/dashboard/src/__tests__/setup.ts index 2e8f2bc33..4b88f032e 100644 --- a/dashboard/src/__tests__/setup.ts +++ b/dashboard/src/__tests__/setup.ts @@ -64,3 +64,42 @@ vi.mock('../../api/resilient-websocket', () => ({ }), })); + +// Safe useT mock: resolves i18n keys using the English catalog. +// Returns a stable function reference to avoid re-render loops. +vi.mock('../i18n/context', async (importOriginal) => { + const actual = await importOriginal(); + let enObj: any = {}; + try { + const mod = await vi.importActual('../i18n/en'); + enObj = (mod as any).en || (mod as any).default || mod; + } catch { + // Fallback: empty catalog + } + + const catalog: Record = {}; + const flatten = (obj: any, prefix: string) => { + for (const [k, v] of Object.entries(obj)) { + const key = prefix ? prefix + '.' + k : k; + if (typeof v === 'string') catalog[key] = v; + else if (typeof v === 'object' && v !== null) flatten(v, key); + } + }; + flatten(enObj, ''); + + const stableT = (key: string, params?: Record): string => { + let val = catalog[key] || key; + if (params) { + Object.entries(params).forEach(([k, v]) => { + val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v)); + }); + } + return val; + }; + + return { + ...actual, + useT: () => stableT, + I18nProvider: actual.I18nProvider, + }; +}); diff --git a/dashboard/src/__tests__/touch-targets.test.ts b/dashboard/src/__tests__/touch-targets.test.ts index 3e04db473..2b0b17136 100644 --- a/dashboard/src/__tests__/touch-targets.test.ts +++ b/dashboard/src/__tests__/touch-targets.test.ts @@ -21,7 +21,7 @@ describe('Mobile touch targets (issue #2350)', () => { const lines = src.split('\n'); let found = false; for (let i = 0; i < lines.length; i++) { - if (lines[i].includes("aria.newSession") || lines[i].includes('aria-label="New Session')) { + if (lines[i].includes('aria.newSessionCmd')) { // Search within 5 lines in both directions for className with min-h for (let j = Math.max(0, i - 5); j <= Math.min(lines.length - 1, i + 5); j++) { if (lines[j].includes('className="') && lines[j].includes('min-h-[44px]')) { diff --git a/dashboard/src/components/ConfirmDialog.tsx b/dashboard/src/components/ConfirmDialog.tsx index cde413ec0..72142326b 100644 --- a/dashboard/src/components/ConfirmDialog.tsx +++ b/dashboard/src/components/ConfirmDialog.tsx @@ -103,7 +103,7 @@ export function ConfirmDialog({ diff --git a/dashboard/src/components/CreatePipelineModal.tsx b/dashboard/src/components/CreatePipelineModal.tsx index c596a5763..4035cf1c4 100644 --- a/dashboard/src/components/CreatePipelineModal.tsx +++ b/dashboard/src/components/CreatePipelineModal.tsx @@ -2,12 +2,12 @@ * components/CreatePipelineModal.tsx — Modal dialog for creating new pipelines. */ -import { useState, useEffect, useRef, useCallback } from 'react' -import { useT } from '../i18n/context'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { useFocusTrap } from '../hooks/useFocusTrap'; import { useNavigate } from 'react-router-dom'; import { X, Loader2, Plus, Trash2 } from 'lucide-react'; import { createPipeline } from '../api/client'; +import { useT } from '../i18n/context'; interface CreatePipelineModalProps { open: boolean; @@ -120,11 +120,11 @@ export default function CreatePipelineModal({ open, onClose }: CreatePipelineMod
-
+
{/* Header */}

New Pipeline

-
diff --git a/dashboard/src/components/CreateSessionModal.tsx b/dashboard/src/components/CreateSessionModal.tsx index 54cba4d61..990c8de2b 100644 --- a/dashboard/src/components/CreateSessionModal.tsx +++ b/dashboard/src/components/CreateSessionModal.tsx @@ -2,13 +2,13 @@ * components/CreateSessionModal.tsx — Modal dialog for creating new sessions. */ -import { useState, useEffect, useRef, useCallback } from 'react' -import { useT } from '../i18n/context'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { useFocusTrap } from '../hooks/useFocusTrap'; import { useNavigate } from 'react-router-dom'; import { X, Loader2, Plus, Trash2 } from 'lucide-react'; import { createSession, batchCreateSessions, getTemplates } from '../api/client'; import type { SessionTemplate } from '../types'; +import { useT } from '../i18n/context'; interface CreateSessionModalProps { open: boolean; @@ -194,7 +194,7 @@ export default function CreateSessionModal({ open, onClose }: CreateSessionModal /> {/* Modal */} -
+
{/* Header */}
@@ -237,7 +237,7 @@ export default function CreateSessionModal({ open, onClose }: CreateSessionModal )}
- diff --git a/dashboard/src/components/Layout.tsx b/dashboard/src/components/Layout.tsx index d3174761a..d54f46517 100644 --- a/dashboard/src/components/Layout.tsx +++ b/dashboard/src/components/Layout.tsx @@ -4,8 +4,7 @@ import { logger } from '../utils/logger'; */ import { NavLink, Outlet } from 'react-router-dom'; -import { useEffect, useState } from 'react' -import { useT } from '../i18n/context'; +import { useEffect, useState } from 'react'; import Breadcrumb from './shared/Breadcrumb'; import { ErrorBoundary } from './shared/ErrorBoundary'; import { useTheme } from '../hooks/useTheme'; @@ -42,6 +41,7 @@ import { checkForUpdates, getHealth, subscribeGlobalSSE, type UpdateCheckResult import ToastContainer from './ToastContainer'; import ConnectionBanner from './ConnectionBanner'; import { ShieldWordmark } from './brand/ShieldLogo'; +import { useT } from '../i18n/context'; interface NavItem { to: string; @@ -106,7 +106,8 @@ function isMobileSidebarViewport(): boolean { } export default function Layout() { - const t = useT(); + const t = useT(); + const sseConnected = useStore((s) => s.sseConnected); const setSseConnected = useStore((s) => s.setSseConnected); const sseError = useStore((s) => s.sseError); @@ -408,7 +409,7 @@ export default function Layout() { {/* ── Sidebar ─────────────────────────────────────────── */}
{/* Nav links */} -