Skip to content
Merged
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
2 changes: 1 addition & 1 deletion dashboard/src/__tests__/AuditPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/__tests__/LiveTerminal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
20 changes: 15 additions & 5 deletions dashboard/src/__tests__/OverviewPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,20 @@ vi.mock('../store/useStore', () => ({
useStore: vi.fn((sel: (s: Record<string, unknown>) => 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<string, string> = {};
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', () => ({
Expand All @@ -58,7 +68,7 @@ describe('OverviewPage (CCMeter redesign)', () => {

it('renders page title', () => {
render(<OverviewPage />);
expect(screen.getByText('overview.title')).not.toBeNull();
expect(screen.getByText('Overview')).not.toBeNull();
});

it('renders New Session button', () => {
Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/__tests__/TerminalPassthrough.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
3 changes: 3 additions & 0 deletions dashboard/src/__tests__/i18n-integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
}));
Expand Down
39 changes: 39 additions & 0 deletions dashboard/src/__tests__/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('../i18n/context')>();
let enObj: any = {};
try {
const mod = await vi.importActual<typeof import('../i18n/en')>('../i18n/en');
enObj = (mod as any).en || (mod as any).default || mod;
} catch {
// Fallback: empty catalog
}

const catalog: Record<string, string> = {};
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, string | number>): 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,
};
});
2 changes: 1 addition & 1 deletion dashboard/src/__tests__/touch-targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]')) {
Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/components/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export function ConfirmDialog({
<button
type="button"
onClick={onCancel}
className="flex-1 min-h-[44px] px-3 py-2 text-xs font-medium rounded bg-[var(--color-void)] border border-[var(--color-void-lighter)] text-[var(--color-text-primary)] hover:text-[var(--color-text-primary)] hover:border-[#333] transition-colors"
className="flex-1 min-h-[44px] px-3 py-2 text-xs font-medium rounded bg-[var(--color-void)] border border-[var(--color-void-lighter)] text-[var(--color-text-primary)] hover:text-[var(--color-text-primary)] hover:border-muted transition-colors"
>
{cancelLabel}
</button>
Expand Down
10 changes: 5 additions & 5 deletions dashboard/src/components/CreatePipelineModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -120,11 +120,11 @@ export default function CreatePipelineModal({ open, onClose }: CreatePipelineMod
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={handleClose} />

<div ref={trapRef} role="dialog" aria-modal="true" aria-label={t('aria.createNewPipeline')} className="relative w-full max-w-2xl mx-4 bg-[var(--color-surface)] border border-[var(--color-void-lighter)] rounded-lg shadow-2xl max-h-[90vh] overflow-y-auto">
<div ref={trapRef} role="dialog" aria-modal="true" aria-label={t("aria.createNewPipeline")} className="relative w-full max-w-2xl mx-4 bg-[var(--color-surface)] border border-[var(--color-void-lighter)] rounded-lg shadow-2xl max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between px-4 sm:px-5 py-4 border-b border-[var(--color-void-lighter)]">
<h2 className="text-sm font-semibold text-[var(--color-text-primary)]">New Pipeline</h2>
<button aria-label={t('aria.close')}
<button aria-label={t("aria.close")}
onClick={handleClose}
className="min-h-[44px] min-w-[44px] flex items-center justify-center text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] transition-colors"
>
Expand All @@ -144,7 +144,7 @@ export default function CreatePipelineModal({ open, onClose }: CreatePipelineMod
value={pipelineName}
onChange={(e) => setPipelineName(e.target.value)}
placeholder="my-pipeline"
aria-label={t('aria.pipelineName')}
aria-label={t("aria.pipelineName")}
className="w-full min-h-[44px] px-3 py-2.5 text-sm bg-[var(--color-void)] border border-[var(--color-void-lighter)] rounded text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] focus:outline-none focus:border-[var(--color-accent)]"
/>
</div>
Expand Down
10 changes: 5 additions & 5 deletions dashboard/src/components/CreateSessionModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -194,7 +194,7 @@ export default function CreateSessionModal({ open, onClose }: CreateSessionModal
/>

{/* Modal */}
<div ref={trapRef} role="dialog" aria-modal="true" aria-label={t('aria.createNewSession')} className={`relative w-full ${mode === 'batch' ? 'max-w-2xl' : 'max-w-md'} mx-4 bg-[var(--color-surface)] border border-[var(--color-void-lighter)] rounded-lg shadow-2xl max-h-[90vh] overflow-y-auto`}>
<div ref={trapRef} role="dialog" aria-modal="true" aria-label={t("aria.createNewSession")} className={`relative w-full ${mode === 'batch' ? 'max-w-2xl' : 'max-w-md'} mx-4 bg-[var(--color-surface)] border border-[var(--color-void-lighter)] rounded-lg shadow-2xl max-h-[90vh] overflow-y-auto`}>
{/* Header */}
<div className="flex items-center justify-between px-4 sm:px-5 py-4 border-b border-[var(--color-void-lighter)]">
<div className="flex items-center gap-4">
Expand Down Expand Up @@ -237,7 +237,7 @@ export default function CreateSessionModal({ open, onClose }: CreateSessionModal
)}
</div>
</div>
<button aria-label={t('aria.close')}
<button aria-label={t("aria.close")}
onClick={handleClose}
className="min-h-[44px] min-w-[44px] flex items-center justify-center text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] transition-colors"
>
Expand Down Expand Up @@ -390,7 +390,7 @@ export default function CreateSessionModal({ open, onClose }: CreateSessionModal
<button
type="button"
onClick={() => removeBatchRow(i)}
aria-label={t('aria.removeRow')}
aria-label={t("aria.removeRow")}
disabled={batchRows.length <= 1}
className="min-h-[44px] min-w-[44px] flex items-center justify-center text-[var(--color-text-muted)] hover:text-[var(--color-error)] transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
Expand Down
14 changes: 7 additions & 7 deletions dashboard/src/components/KeyboardShortcutsHelp/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { useT } from '../../i18n/context';
import { useEffect, useState } from 'react';
import { X, Keyboard } from 'lucide-react';
import { SHORTCUTS } from '../../hooks/useKeyboardShortcuts';
import { useT } from '../../i18n/context';

export function KeyboardShortcutsHelp({
open,
Expand All @@ -10,14 +10,14 @@ export function KeyboardShortcutsHelp({
open: boolean;
onClose: () => void;
}) {
const translate = useT();
const t = useT();
const [visible, setVisible] = useState(false);

useEffect(() => {
if (open) setVisible(true);
else {
const timer = setTimeout(() => setVisible(false), 200);
return () => clearTimeout(timer);
const t = setTimeout(() => setVisible(false), 200);
return () => clearTimeout(t);
}
}, [open]);

Expand All @@ -31,7 +31,7 @@ export function KeyboardShortcutsHelp({
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label={translate('aria.keyboardShortcuts')}
aria-label={t("aria.keyboardShortcuts")}
>
<div
className="w-full max-w-md rounded-xl border border-[var(--color-void-lighter)]/60 bg-[var(--color-surface)] p-6 shadow-2xl"
Expand All @@ -45,7 +45,7 @@ export function KeyboardShortcutsHelp({
<button
onClick={onClose}
className="rounded p-1 text-[var(--color-text-muted)] hover:bg-[var(--color-void-lighter)]/50 hover:text-[var(--color-text-primary)] transition-colors"
aria-label={translate('aria.close')}
aria-label={t("aria.close")}
>
<X className="h-4 w-4" />
</button>
Expand Down
21 changes: 11 additions & 10 deletions dashboard/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -408,7 +409,7 @@ export default function Layout() {

{/* ── Sidebar ─────────────────────────────────────────── */}
<aside
aria-label={t('aria.primarySidebar')}
aria-label={t("aria.primarySidebar")}
className={`
fixed inset-y-0 left-0 z-40 flex flex-col border-r border-white/5 bg-transparent backdrop-blur-xl
transition-all duration-300 ease-in-out
Expand All @@ -430,15 +431,15 @@ export default function Layout() {
tabIndex={hiddenMobileSidebarControlTabIndex}
disabled={isMobileSidebarHidden}
className="md:hidden inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-lg text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-[var(--color-text-muted)] dark:hover:bg-void-lighter dark:hover:text-[var(--color-text-primary)]"
aria-label={t('aria.closeMenu')}
aria-label={t("aria.closeMenu")}
aria-hidden={isMobileSidebarHidden ? 'true' : undefined}
>
<X className="h-5 w-5" />
</button>
</div>

{/* Nav links */}
<nav className="flex flex-col gap-4 px-3 py-6 flex-1 overflow-y-auto overflow-x-hidden" aria-label={t('aria.mainNavigation')}>
<nav className="flex flex-col gap-4 px-3 py-6 flex-1 overflow-y-auto overflow-x-hidden" aria-label={t("aria.mainNavigation")}>
{NAV_GROUPS.map((group) => (
<div key={group.label} className="flex flex-col gap-1">
{!isCollapsed && (
Expand Down Expand Up @@ -473,7 +474,7 @@ export default function Layout() {
{/* Bottom section: Settings + toggle + logout */}
<div className="border-t border-white/5 px-3 py-4 flex flex-col gap-2">
{identityLabel && identityDetailLabel && !isCollapsed && (
<div className="px-3 py-2" aria-label={t('aria.signedInUser')}>
<div className="px-3 py-2" aria-label={t("aria.signedInUser")}>
<p className="truncate text-xs font-medium text-slate-700 dark:text-[var(--color-text-primary)]">{identityLabel}</p>
<p className="truncate text-[11px] text-slate-500 dark:text-[var(--color-text-muted)]">
{identityDetailLabel}
Expand Down Expand Up @@ -521,7 +522,7 @@ export default function Layout() {
onClick={handleLogout}
tabIndex={hiddenMobileSidebarControlTabIndex}
className={`flex items-center gap-2.5 rounded-lg px-3 py-3 min-h-[44px] text-sm font-medium text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-[var(--color-text-muted)] dark:hover:bg-void-lighter dark:hover:text-[var(--color-text-primary)] transition-colors w-full ${isCollapsed ? 'justify-center' : ''}`}
aria-label={t('aria.signOut')}
aria-label={t("aria.signOut")}
>
<LogOut className="h-4 w-4 shrink-0" />
{!isCollapsed && <span className="truncate">Sign out</span>}
Expand All @@ -542,7 +543,7 @@ export default function Layout() {
tabIndex={isMobileDrawerOpen ? -1 : undefined}
aria-hidden={isMobileDrawerOpen ? 'true' : undefined}
className="md:hidden inline-flex h-11 w-11 items-center justify-center rounded-lg text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-[var(--color-text-muted)] dark:hover:bg-void-lighter dark:hover:text-[var(--color-text-primary)] transition-colors"
aria-label={t('aria.openMenu')}
aria-label={t("aria.openMenu")}
>
<Menu className="h-5 w-5" />
</button>
Expand All @@ -561,7 +562,7 @@ export default function Layout() {
<button
type="button"
onClick={openNewSession}
aria-label={t('aria.newSessionCmd')}
aria-label={t("aria.newSessionCmd")}
title="New Session (⌘N)"
className="inline-flex h-11 w-11 items-center justify-center rounded-lg p-2.5 min-h-[44px] min-w-[44px] text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-[var(--color-text-muted)] dark:hover:bg-void-lighter dark:hover:text-[var(--color-text-primary)] transition-colors"
>
Expand Down
10 changes: 5 additions & 5 deletions dashboard/src/components/NewSessionDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@
* Width: 480px desktop, full-width mobile.
*/

import { useState, useCallback, useEffect, useRef } from 'react'
import { useT } from '../i18n/context';
import { useState, useCallback, useEffect, useRef } from 'react';
import { useFocusTrap } from '../hooks/useFocusTrap';
import { useNavigate } from 'react-router-dom';
import { Loader2, Plus, X } from 'lucide-react';
Expand All @@ -15,6 +14,7 @@ import type { SessionTemplate } from '../types';
import { useToastStore } from '../store/useToastStore';
import { useDrawerStore } from '../store/useDrawerStore';
import { useConfetti } from '../hooks/useConfetti';
import { useT } from '../i18n/context';

const PERMISSION_MODES = [
{ value: 'default', label: 'Default (prompt)' },
Expand All @@ -23,9 +23,9 @@ const PERMISSION_MODES = [
];

export function NewSessionDrawer() {
const t = useT();
const navigate = useNavigate();
const addToast = useToastStore((t) => t.addToast);
const t = useT();
const { newSessionOpen, closeNewSession } = useDrawerStore();
const { triggerFirstSessionConfetti } = useConfetti();

Expand Down Expand Up @@ -128,7 +128,7 @@ export function NewSessionDrawer() {
key="drawer-panel"
role="dialog"
aria-modal="true"
aria-label={t('aria.newSession')}
aria-label={t("aria.newSession")}
ref={trapRef as React.Ref<HTMLDivElement>}
initial={{ x: '100%' }}
animate={{ x: 0 }}
Expand All @@ -145,7 +145,7 @@ export function NewSessionDrawer() {
<button
type="button"
onClick={closeNewSession}
aria-label={t('aria.closeDrawer')}
aria-label={t("aria.closeDrawer")}
className="rounded-lg p-2 text-[var(--color-text-muted)] hover:bg-white/5 hover:text-[var(--color-text-primary)] transition-colors"
>
<X className="h-4 w-4" />
Expand Down
Loading
Loading