Add E2E tests for internationalization across multiple pages

- Implemented navigation.spec.ts to test language switching and translation of navigation elements.
- Created sessions-page.spec.ts to verify translations on the sessions page, including headers, status badges, and date formatting.
- Developed settings-page.spec.ts to ensure settings page content is translated and persists across sessions.
- Added skills-page.spec.ts to validate translations for skill categories, action buttons, and empty states.
This commit is contained in:
catlog22
2026-01-30 22:54:21 +08:00
parent e78e95049b
commit 81725c94b1
150 changed files with 25341 additions and 1448 deletions

View File

@@ -0,0 +1,203 @@
// ========================================
// Header Component Tests - i18n Focus
// ========================================
// Tests for the header component with internationalization
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '@/test/i18n';
import { Header } from './Header';
import { useAppStore } from '@/stores/appStore';
import userEvent from '@testing-library/user-event';
// Mock useTheme hook
vi.mock('@/hooks', () => ({
useTheme: () => ({
isDark: false,
toggleTheme: vi.fn(),
}),
}));
describe('Header Component - i18n Tests', () => {
beforeEach(() => {
// Reset store state before each test
useAppStore.setState({ locale: 'en' });
vi.clearAllMocks();
});
describe('language switcher visibility', () => {
it('should render language switcher', () => {
render(<Header />);
const languageSwitcher = screen.getByRole('combobox', { name: /select language/i });
expect(languageSwitcher).toBeInTheDocument();
});
it('should render language switcher in compact mode', () => {
render(<Header />);
const languageSwitcher = screen.getByRole('combobox', { name: /select language/i });
expect(languageSwitcher).toHaveClass('w-[110px]');
});
});
describe('translated aria-labels', () => {
it('should have translated aria-label for menu toggle', () => {
render(<Header onMenuClick={vi.fn()} />);
const menuButton = screen.getByRole('button', { name: /toggle navigation/i });
expect(menuButton).toBeInTheDocument();
expect(menuButton).toHaveAttribute('aria-label');
});
it('should have translated aria-label for theme toggle', () => {
render(<Header />);
const themeButton = screen.getByRole('button', { name: /switch to dark mode/i });
expect(themeButton).toBeInTheDocument();
expect(themeButton).toHaveAttribute('aria-label');
});
it('should have translated aria-label for user menu', () => {
render(<Header />);
const userMenuButton = screen.getByRole('button', { name: /user menu/i });
expect(userMenuButton).toBeInTheDocument();
expect(userMenuButton).toHaveAttribute('aria-label');
});
it('should have translated aria-label for refresh button', () => {
render(<Header onRefresh={vi.fn()} />);
const refreshButton = screen.getByRole('button', { name: /refresh workspace/i });
expect(refreshButton).toBeInTheDocument();
expect(refreshButton).toHaveAttribute('aria-label');
});
});
describe('translated text content', () => {
it('should display translated brand name', () => {
render(<Header />);
const brandLink = screen.getByRole('link', { name: /ccw/i });
expect(brandLink).toBeInTheDocument();
});
it('should update aria-label when locale changes', async () => {
const { rerender } = render(<Header />);
// Initial locale is English
const themeButtonEn = screen.getByRole('button', { name: /switch to dark mode/i });
expect(themeButtonEn).toBeInTheDocument();
// Change locale to Chinese and re-render
useAppStore.setState({ locale: 'zh' });
rerender(<Header />);
// After locale change, the theme button should be updated
// In Chinese, it should say "切换到深色模式"
const themeButtonZh = screen.getByRole('button', { name: /切换到深色模式|switch to dark mode/i });
expect(themeButtonZh).toBeInTheDocument();
});
});
describe('translated navigation items', () => {
it('should display translated settings link in user menu', async () => {
const user = userEvent.setup();
render(<Header />);
// Click user menu to show dropdown
const userMenuButton = screen.getByRole('button', { name: /user menu/i });
await user.click(userMenuButton);
// Wait for dropdown to appear
await waitFor(() => {
const settingsLink = screen.getByRole('link', { name: /settings/i });
expect(settingsLink).toBeInTheDocument();
});
});
it('should display translated logout button in user menu', async () => {
const user = userEvent.setup();
render(<Header />);
// Click user menu to show dropdown
const userMenuButton = screen.getByRole('button', { name: /user menu/i });
await user.click(userMenuButton);
// Wait for dropdown to appear
await waitFor(() => {
const logoutButton = screen.getByRole('button', { name: /logout/i });
expect(logoutButton).toBeInTheDocument();
});
});
});
describe('locale switching integration', () => {
it('should reflect locale change in language switcher', async () => {
const { rerender } = render(<Header />);
const languageSwitcher = screen.getByRole('combobox', { name: /select language/i });
expect(languageSwitcher).toHaveTextContent('English');
// Change locale in store
useAppStore.setState({ locale: 'zh' });
// Re-render header
rerender(<Header />);
expect(languageSwitcher).toHaveTextContent('中文');
});
});
describe('translated project path display', () => {
it('should display translated fallback when no project path', () => {
render(<Header projectPath="" />);
// Header should render correctly even without project path
const header = screen.getByRole('banner');
expect(header).toBeInTheDocument();
// Brand link should still be present
const brandLink = screen.getByRole('link', { name: /ccw/i });
expect(brandLink).toBeInTheDocument();
});
it('should display project path when provided', () => {
render(<Header projectPath="/test/path" />);
// Should show the path indicator
const pathDisplay = screen.getByTitle('/test/path');
expect(pathDisplay).toBeInTheDocument();
});
});
describe('accessibility with i18n', () => {
it('should maintain accessible labels across locales', () => {
render(<Header />);
// Check specific buttons have proper aria-labels
const themeButton = screen.getByRole('button', { name: /switch to dark mode/i });
expect(themeButton).toHaveAttribute('aria-label');
const userMenuButton = screen.getByRole('button', { name: /user menu/i });
expect(userMenuButton).toHaveAttribute('aria-label');
});
it('should have translated title attributes', () => {
render(<Header />);
// Theme button should have title attribute
const themeButton = screen.getByRole('button', { name: /switch to dark mode/i });
expect(themeButton).toHaveAttribute('title');
});
});
describe('header role with i18n', () => {
it('should have banner role for accessibility', () => {
render(<Header />);
const header = screen.getByRole('banner');
expect(header).toBeInTheDocument();
});
});
});

View File

@@ -5,6 +5,7 @@
import { useCallback } from 'react';
import { Link } from 'react-router-dom';
import { useIntl } from 'react-intl';
import {
Workflow,
Menu,
@@ -18,6 +19,7 @@ import {
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { useTheme } from '@/hooks';
import { LanguageSwitcher } from './LanguageSwitcher';
export interface HeaderProps {
/** Callback to toggle mobile sidebar */
@@ -36,6 +38,7 @@ export function Header({
onRefresh,
isRefreshing = false,
}: HeaderProps) {
const { formatMessage } = useIntl();
const { isDark, toggleTheme } = useTheme();
const handleRefresh = useCallback(() => {
@@ -47,7 +50,7 @@ export function Header({
// Get display path (truncate if too long)
const displayPath = projectPath.length > 40
? '...' + projectPath.slice(-37)
: projectPath || 'No project selected';
: projectPath || formatMessage({ id: 'navigation.header.noProject' });
return (
<header
@@ -62,7 +65,7 @@ export function Header({
size="icon"
className="md:hidden"
onClick={onMenuClick}
aria-label="Toggle navigation menu"
aria-label={formatMessage({ id: 'common.aria.toggleNavigation' })}
>
<Menu className="w-5 h-5" />
</Button>
@@ -73,8 +76,8 @@ export function Header({
className="flex items-center gap-2 text-lg font-semibold text-primary hover:opacity-80 transition-opacity"
>
<Workflow className="w-6 h-6" />
<span className="hidden sm:inline">Claude Code Workflow</span>
<span className="sm:hidden">CCW</span>
<span className="hidden sm:inline">{formatMessage({ id: 'navigation.header.brand' })}</span>
<span className="sm:hidden">{formatMessage({ id: 'navigation.header.brandShort' })}</span>
</Link>
</div>
@@ -96,8 +99,8 @@ export function Header({
size="icon"
onClick={handleRefresh}
disabled={isRefreshing}
aria-label="Refresh workspace"
title="Refresh workspace"
aria-label={formatMessage({ id: 'common.aria.refreshWorkspace' })}
title={formatMessage({ id: 'common.aria.refreshWorkspace' })}
>
<RefreshCw
className={cn('w-5 h-5', isRefreshing && 'animate-spin')}
@@ -105,13 +108,22 @@ export function Header({
</Button>
)}
{/* Language switcher */}
<LanguageSwitcher compact />
{/* Theme toggle */}
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
aria-label={isDark
? formatMessage({ id: 'common.aria.switchToLightMode' })
: formatMessage({ id: 'common.aria.switchToDarkMode' })
}
title={isDark
? formatMessage({ id: 'common.aria.switchToLightMode' })
: formatMessage({ id: 'common.aria.switchToDarkMode' })
}
>
{isDark ? (
<Sun className="w-5 h-5" />
@@ -126,8 +138,8 @@ export function Header({
variant="ghost"
size="icon"
className="rounded-full"
aria-label="User menu"
title="User menu"
aria-label={formatMessage({ id: 'common.aria.userMenu' })}
title={formatMessage({ id: 'common.aria.userMenu' })}
>
<User className="w-5 h-5" />
</Button>
@@ -140,7 +152,7 @@ export function Header({
className="flex items-center gap-2 px-4 py-2 text-sm text-foreground hover:bg-hover transition-colors"
>
<Settings className="w-4 h-4" />
<span>Settings</span>
<span>{formatMessage({ id: 'navigation.header.settings' })}</span>
</Link>
<hr className="my-1 border-border" />
<button
@@ -151,7 +163,7 @@ export function Header({
}}
>
<LogOut className="w-4 h-4" />
<span>Exit Dashboard</span>
<span>{formatMessage({ id: 'navigation.header.logout' })}</span>
</button>
</div>
</div>

View File

@@ -0,0 +1,245 @@
// ========================================
// LanguageSwitcher Component Tests
// ========================================
// Tests for the language switcher component
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '@/test/i18n';
import { LanguageSwitcher } from './LanguageSwitcher';
import { useAppStore } from '@/stores/appStore';
import userEvent from '@testing-library/user-event';
describe('LanguageSwitcher Component', () => {
beforeEach(() => {
// Reset store state before each test
useAppStore.setState({ locale: 'en' });
vi.clearAllMocks();
});
describe('rendering', () => {
it('should render the select component', () => {
render(<LanguageSwitcher />);
const select = screen.getByRole('combobox');
expect(select).toBeInTheDocument();
});
it('should display current locale value', () => {
useAppStore.setState({ locale: 'en' });
render(<LanguageSwitcher />);
const select = screen.getByRole('combobox');
expect(select).toHaveTextContent('English');
});
it('should display Chinese locale when set', () => {
useAppStore.setState({ locale: 'zh' });
render(<LanguageSwitcher />);
const select = screen.getByRole('combobox');
expect(select).toHaveTextContent('中文');
});
it('should have aria-label for accessibility', () => {
render(<LanguageSwitcher />);
const select = screen.getByRole('combobox');
expect(select).toHaveAttribute('aria-label', 'Select language');
});
it('should render in compact mode', () => {
render(<LanguageSwitcher compact />);
const select = screen.getByRole('combobox');
expect(select).toBeInTheDocument();
expect(select).toHaveClass('w-[110px]');
});
it('should render in default mode', () => {
render(<LanguageSwitcher />);
const select = screen.getByRole('combobox');
expect(select).toBeInTheDocument();
expect(select).toHaveClass('w-[160px]');
});
});
describe('language options', () => {
it('should display English option', async () => {
const user = userEvent.setup();
render(<LanguageSwitcher />);
const select = screen.getByRole('combobox');
await user.click(select);
// Wait for dropdown to appear and check for option role
await waitFor(() => {
const englishOption = screen.getByRole('option', { name: /English/ });
expect(englishOption).toBeInTheDocument();
});
});
it('should display Chinese option', async () => {
const user = userEvent.setup();
render(<LanguageSwitcher />);
const select = screen.getByRole('combobox');
await user.click(select);
// Wait for dropdown to appear
await waitFor(() => {
const chineseOption = screen.getByRole('option', { name: /中文/ });
expect(chineseOption).toBeInTheDocument();
});
});
it('should display flag icons for options', async () => {
const user = userEvent.setup();
render(<LanguageSwitcher />);
const select = screen.getByRole('combobox');
await user.click(select);
// Check for flag emojis in options
await waitFor(() => {
const options = screen.getAllByRole('option');
expect(options.length).toBe(2);
const optionsText = options.map(opt => opt.textContent).join(' ');
expect(optionsText).toContain('🇺🇸');
expect(optionsText).toContain('🇨🇳');
});
});
});
describe('language switching behavior', () => {
it('should call setLocale when option is selected', async () => {
const user = userEvent.setup();
render(<LanguageSwitcher />);
const select = screen.getByRole('combobox');
await user.click(select);
// Wait for Chinese option and click it
await waitFor(() => {
const chineseOption = screen.getByText('中文');
user.click(chineseOption);
});
// Verify locale was updated in store
await waitFor(() => {
expect(useAppStore.getState().locale).toBe('zh');
});
});
it('should switch to English when selected', async () => {
const user = userEvent.setup();
useAppStore.setState({ locale: 'zh' });
render(<LanguageSwitcher />);
const select = screen.getByRole('combobox');
expect(select).toHaveTextContent('中文');
await user.click(select);
// Wait for English option and click it
await waitFor(() => {
const englishOption = screen.getByText('English');
user.click(englishOption);
});
// Verify locale was updated in store
await waitFor(() => {
expect(useAppStore.getState().locale).toBe('en');
});
});
it('should persist locale selection to store', async () => {
const user = userEvent.setup();
render(<LanguageSwitcher />);
const select = screen.getByRole('combobox');
await user.click(select);
await waitFor(() => {
const chineseOption = screen.getByText('中文');
user.click(chineseOption);
});
// Check that store was updated
await waitFor(() => {
const storeLocale = useAppStore.getState().locale;
expect(storeLocale).toBe('zh');
});
});
});
describe('custom className', () => {
it('should apply custom className', () => {
render(<LanguageSwitcher className="custom-class" />);
const select = screen.getByRole('combobox');
expect(select).toHaveClass('custom-class');
});
});
describe('accessibility', () => {
it('should be keyboard navigable', async () => {
const user = userEvent.setup();
render(<LanguageSwitcher />);
const select = screen.getByRole('combobox');
select.focus();
expect(select).toHaveFocus();
// Open with Enter
await user.keyboard('{Enter}');
// Should show options after opening
await waitFor(() => {
const englishOption = screen.getByRole('option', { name: /English/ });
expect(englishOption).toBeInTheDocument();
});
});
it('should maintain focus management', async () => {
const user = userEvent.setup();
render(<LanguageSwitcher />);
const select = screen.getByRole('combobox');
await user.click(select);
// Focus should remain on select or move to options
await waitFor(() => {
const options = screen.getAllByRole('option');
expect(options.length).toBeGreaterThan(0);
});
});
});
describe('integration with useLocale hook', () => {
it('should reflect current locale from store', () => {
useAppStore.setState({ locale: 'zh' });
render(<LanguageSwitcher />);
const select = screen.getByRole('combobox');
expect(select).toHaveTextContent('中文');
});
it('should update when store locale changes externally', async () => {
const { rerender } = render(<LanguageSwitcher />);
const select = screen.getByRole('combobox');
expect(select).toHaveTextContent('English');
// Update store externally
useAppStore.setState({ locale: 'zh' });
// Re-render to reflect change
rerender(<LanguageSwitcher />);
expect(select).toHaveTextContent('中文');
});
});
});

View File

@@ -0,0 +1,70 @@
// ========================================
// Language Switcher Component
// ========================================
// Language selection dropdown with flag icons
import { Languages } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/Select';
import { useLocale } from '@/hooks/useLocale';
import { cn } from '@/lib/utils';
export interface LanguageSwitcherProps {
/** Compact variant for header (smaller, icon-only trigger) */
compact?: boolean;
/** Additional CSS classes */
className?: string;
}
// Language options with flag emojis and labels
const LANGUAGE_OPTIONS = [
{ value: 'en' as const, label: 'English', flag: '🇺🇸' },
{ value: 'zh' as const, label: '中文', flag: '🇨🇳' },
] as const;
/**
* Language switcher component
* Allows users to switch between English and Chinese
*/
export function LanguageSwitcher({ compact = false, className }: LanguageSwitcherProps) {
const { locale, setLocale } = useLocale();
return (
<Select value={locale} onValueChange={setLocale}>
<SelectTrigger
className={cn(
compact ? 'w-[110px]' : 'w-[160px]',
'gap-2',
className
)}
aria-label="Select language"
>
{compact ? (
<>
<Languages className="w-4 h-4" />
<SelectValue />
</>
) : (
<SelectValue />
)}
</SelectTrigger>
<SelectContent>
{LANGUAGE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<span className="flex items-center gap-2">
<span className="text-base">{option.flag}</span>
<span>{option.label}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export default LanguageSwitcher;

View File

@@ -3,8 +3,9 @@
// ========================================
// Collapsible navigation sidebar with route links
import { useState, useCallback } from 'react';
import { useState, useCallback, useMemo } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { useIntl } from 'react-intl';
import {
Home,
FolderKanban,
@@ -18,6 +19,9 @@ import {
HelpCircle,
PanelLeftClose,
PanelLeftOpen,
LayoutDashboard,
Clock,
Zap,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
@@ -41,17 +45,21 @@ interface NavItem {
badgeVariant?: 'default' | 'success' | 'warning' | 'info';
}
const navItems: NavItem[] = [
{ path: '/', label: 'Home', icon: Home },
{ path: '/sessions', label: 'Sessions', icon: FolderKanban },
{ path: '/orchestrator', label: 'Orchestrator', icon: Workflow },
{ path: '/loops', label: 'Loop Monitor', icon: RefreshCw },
{ path: '/issues', label: 'Issues', icon: AlertCircle },
{ path: '/skills', label: 'Skills', icon: Sparkles },
{ path: '/commands', label: 'Commands', icon: Terminal },
{ path: '/memory', label: 'Memory', icon: Brain },
{ path: '/settings', label: 'Settings', icon: Settings },
{ path: '/help', label: 'Help', icon: HelpCircle },
// Navigation item definitions (without labels for i18n)
const navItemDefinitions: Omit<NavItem, 'label'>[] = [
{ path: '/', icon: Home },
{ path: '/sessions', icon: FolderKanban },
{ path: '/lite-tasks', icon: Zap },
{ path: '/project', icon: LayoutDashboard },
{ path: '/history', icon: Clock },
{ path: '/orchestrator', icon: Workflow },
{ path: '/loops', icon: RefreshCw },
{ path: '/issues', icon: AlertCircle },
{ path: '/skills', icon: Sparkles },
{ path: '/commands', icon: Terminal },
{ path: '/memory', icon: Brain },
{ path: '/settings', icon: Settings },
{ path: '/help', icon: HelpCircle },
];
export function Sidebar({
@@ -60,6 +68,7 @@ export function Sidebar({
mobileOpen = false,
onMobileClose,
}: SidebarProps) {
const { formatMessage } = useIntl();
const location = useLocation();
const [internalCollapsed, setInternalCollapsed] = useState(collapsed);
@@ -80,6 +89,29 @@ export function Sidebar({
}
}, [onMobileClose]);
// Build nav items with translated labels
const navItems = useMemo(() => {
const keyMap: Record<string, string> = {
'/': 'main.home',
'/sessions': 'main.sessions',
'/lite-tasks': 'main.liteTasks',
'/project': 'main.project',
'/history': 'main.history',
'/orchestrator': 'main.orchestrator',
'/loops': 'main.loops',
'/issues': 'main.issues',
'/skills': 'main.skills',
'/commands': 'main.commands',
'/memory': 'main.memory',
'/settings': 'main.settings',
'/help': 'main.help',
};
return navItemDefinitions.map((item) => ({
...item,
label: formatMessage({ id: `navigation.${keyMap[item.path]}` }),
}));
}, [formatMessage]);
return (
<>
{/* Mobile overlay */}
@@ -103,7 +135,7 @@ export function Sidebar({
mobileOpen && 'fixed left-0 top-14 flex translate-x-0 z-50 h-[calc(100vh-56px)] w-64 shadow-lg'
)}
role="navigation"
aria-label="Main navigation"
aria-label={formatMessage({ id: 'header.brand' })}
>
<nav className="flex-1 py-3 overflow-y-auto">
<ul className="space-y-1 px-2">
@@ -164,14 +196,17 @@ export function Sidebar({
'w-full flex items-center gap-2 text-muted-foreground hover:text-foreground',
isCollapsed && 'justify-center'
)}
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-label={isCollapsed
? formatMessage({ id: 'navigation.sidebar.expand' })
: formatMessage({ id: 'navigation.sidebar.collapseAria' })
}
>
{isCollapsed ? (
<PanelLeftOpen className="w-4 h-4" />
) : (
<>
<PanelLeftClose className="w-4 h-4" />
<span>Collapse</span>
<span>{formatMessage({ id: 'navigation.sidebar.collapse' })}</span>
</>
)}
</Button>