diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a989a68806..23640fcff7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,7 @@ jobs: - run: npx prettier --check . - run: npx eslint src/ - run: npm run lint # TypeScript type checking + - run: npm run i18n:size-check # Translation bundle size budget test: runs-on: ubuntu-latest diff --git a/.prettierignore b/.prettierignore index adadb71119..82d990a5a8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,4 @@ node_modules/ coverage/ *.min.js .gitignore +.claude/ diff --git a/docs/releases.md b/docs/releases.md index d053eb9477..81e0e7f113 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -17,60 +17,60 @@ Maestro can update itself automatically! This feature was introduced in **v0.8.7 **Latest: v0.15.2** | Released March 12, 2026 -# Major 0.15.x Additions - -๐ŸŽถ **Maestro Symphony** โ€” Contribute to open source with AI assistance! Browse curated issues from projects with the `runmaestro.ai` label, clone repos with one click, and automatically process the relevant Auto Run playbooks. Track your contributions, streaks, and stats. You're contributing CPU and tokens towards your favorite open source projects and features. - -๐ŸŽฌ **Director's Notes** โ€” Aggregates history across all agents into a unified timeline with search, filters, and an activity graph. Includes an AI Overview tab that generates a structured synopsis of recent work. Off by default, gated behind a new "Encore Features" panel under settings. This is a precursor to an eventual plugin system, allowing for extensions and customizations without bloating the core app. - -๐Ÿท๏ธ **Conductor Profile** โ€” Available under Settings > General. Provide a short description on how Maestro agents should interface with you. - -๐Ÿง  **Three-State Thinking Toggle** โ€” The thinking toggle now cycles through three modes: off, on, and sticky. Sticky mode keeps thinking content visible after the response completes. Cycle with CMD/CTRL+SHIFT+K. - -๐Ÿค– **Factory.ai Droid Support** โ€” Added support for the [Factory.ai](https://factory.ai/product/cli) droid agent. Full session management and output parsing integration. - -## Change in v0.15.2 - -Patch release with bug fixes, UX improvements, and cherry-picks from the 0.16.0 RC. - -### New Features - -- **Cmd+0 โ†’ Last Tab:** Remapped Cmd+0 to jump to last tab; Cmd+Shift+0 now resets font size -- **Unsent draft protection:** Confirm dialog before closing tabs with unsent draft input -- **Read-only CLI flag:** Added `--read-only` flag to `maestro-cli send` command -- **Gemini read-only enforcement:** Gemini `-y` flag now works in read-only mode -- **Capability-based providers:** Replaced hardcoded agent ID checks with capability flags and shared metadata - -### Bug Fixes - -- **Sticky overlay scroll:** Fixed sticky overlays breaking tab scroll-into-view -- **Director's Notes stats:** Count only agents with entries in lookback window -- **SSH remote config:** Check `sessionSshRemoteConfig` as primary SSH remote ID source -- **.maestro file tree:** Always show .maestro directory even when dotfiles are hidden -- **Provider hardening:** Prototype safety, capability gates, stale map cleanup -- **Session search:** Per-session error resilience and metadata-based title matching -- **File tree stale loads:** Load sequence counter prevents stale file tree updates -- **File tree Unicode:** NFC normalization prevents duplicate entries -- **File tree duplicates:** Tree-structured data resolves duplicate entries -- **File tree auto-refresh:** Timer no longer destroyed on right panel tab switch -- **Menu z-index:** Branding header menu renders above sidebar content -- **Dropdown clipping:** Fixed hamburger menu and live overlay dropdown clipping -- **Font size shortcuts:** Restored Cmd+/- font size shortcuts lost with custom menu -- **Draft input preservation:** Replaying a previous message no longer discards current draft -- **SSH directory collision:** Skip warning when agents are on different SSH hosts -- **IPC error handling:** Handle expected IPC errors gracefully -- **Auto-focus on mode switch:** Input field auto-focuses when toggling AI/Shell mode -- **OpenCode parser:** Preserve JSON error events; reset resultEmitted on step_start -- **NDJSON performance:** Eliminated triple JSON parsing on hot path -- **Agent config overrides:** Apply config overrides in context groomer before spawning -- **Stale closure fix:** Resolved model not saving in wizard agent config - -### Visual Polish - -- **Light theme contrast:** Improved syntax highlighting contrast across all light themes -- **Context warning sash:** Dark text colors in light mode for readability -- **Session name dimming:** Use `textMain` color to prevent visual dimming -- **Session name pill:** Allow shrinking so date doesn't collide with type pill +# Major 0.15.x Additions + +๐ŸŽถ **Maestro Symphony** โ€” Contribute to open source with AI assistance! Browse curated issues from projects with the `runmaestro.ai` label, clone repos with one click, and automatically process the relevant Auto Run playbooks. Track your contributions, streaks, and stats. You're contributing CPU and tokens towards your favorite open source projects and features. + +๐ŸŽฌ **Director's Notes** โ€” Aggregates history across all agents into a unified timeline with search, filters, and an activity graph. Includes an AI Overview tab that generates a structured synopsis of recent work. Off by default, gated behind a new "Encore Features" panel under settings. This is a precursor to an eventual plugin system, allowing for extensions and customizations without bloating the core app. + +๐Ÿท๏ธ **Conductor Profile** โ€” Available under Settings > General. Provide a short description on how Maestro agents should interface with you. + +๐Ÿง  **Three-State Thinking Toggle** โ€” The thinking toggle now cycles through three modes: off, on, and sticky. Sticky mode keeps thinking content visible after the response completes. Cycle with CMD/CTRL+SHIFT+K. + +๐Ÿค– **Factory.ai Droid Support** โ€” Added support for the [Factory.ai](https://factory.ai/product/cli) droid agent. Full session management and output parsing integration. + +## Change in v0.15.2 + +Patch release with bug fixes, UX improvements, and cherry-picks from the 0.16.0 RC. + +### New Features + +- **Cmd+0 โ†’ Last Tab:** Remapped Cmd+0 to jump to last tab; Cmd+Shift+0 now resets font size +- **Unsent draft protection:** Confirm dialog before closing tabs with unsent draft input +- **Read-only CLI flag:** Added `--read-only` flag to `maestro-cli send` command +- **Gemini read-only enforcement:** Gemini `-y` flag now works in read-only mode +- **Capability-based providers:** Replaced hardcoded agent ID checks with capability flags and shared metadata + +### Bug Fixes + +- **Sticky overlay scroll:** Fixed sticky overlays breaking tab scroll-into-view +- **Director's Notes stats:** Count only agents with entries in lookback window +- **SSH remote config:** Check `sessionSshRemoteConfig` as primary SSH remote ID source +- **.maestro file tree:** Always show .maestro directory even when dotfiles are hidden +- **Provider hardening:** Prototype safety, capability gates, stale map cleanup +- **Session search:** Per-session error resilience and metadata-based title matching +- **File tree stale loads:** Load sequence counter prevents stale file tree updates +- **File tree Unicode:** NFC normalization prevents duplicate entries +- **File tree duplicates:** Tree-structured data resolves duplicate entries +- **File tree auto-refresh:** Timer no longer destroyed on right panel tab switch +- **Menu z-index:** Branding header menu renders above sidebar content +- **Dropdown clipping:** Fixed hamburger menu and live overlay dropdown clipping +- **Font size shortcuts:** Restored Cmd+/- font size shortcuts lost with custom menu +- **Draft input preservation:** Replaying a previous message no longer discards current draft +- **SSH directory collision:** Skip warning when agents are on different SSH hosts +- **IPC error handling:** Handle expected IPC errors gracefully +- **Auto-focus on mode switch:** Input field auto-focuses when toggling AI/Shell mode +- **OpenCode parser:** Preserve JSON error events; reset resultEmitted on step_start +- **NDJSON performance:** Eliminated triple JSON parsing on hot path +- **Agent config overrides:** Apply config overrides in context groomer before spawning +- **Stale closure fix:** Resolved model not saving in wizard agent config + +### Visual Polish + +- **Light theme contrast:** Improved syntax highlighting contrast across all light themes +- **Context warning sash:** Dark text colors in light mode for readability +- **Session name dimming:** Use `textMain` color to prevent visual dimming +- **Session name pill:** Allow shrinking so date doesn't collide with type pill - **Scroll-to-bottom arrow:** Removed noisy indicator from terminal output view ### Previous Releases in this Series @@ -83,41 +83,41 @@ Patch release with bug fixes, UX improvements, and cherry-picks from the 0.16.0 **Latest: v0.14.5** | Released January 24, 2026 -Changes in this point release include: - -- Desktop app performance improvements (more to come on this, we want Maestro blazing fast) ๐ŸŒ -- Added local manifest feature for custom playbooks ๐Ÿ“– -- Agents are now inherently aware of your activity history as seen in the history panel ๐Ÿ“œ (this is built-in cross context memory!) -- Added markdown rendering support for AI responses in mobile view ๐Ÿ“ฑ -- Bugfix in tracking costs from JSONL files that were aged out ๐Ÿฆ -- Added BlueSky social media handle for leaderboard ๐Ÿฆ‹ -- Added options to disable GPU rendering and confetti ๐ŸŽŠ -- Better handling of large files in preview ๐Ÿ—„๏ธ -- Bug fix in Claude context calculation ๐Ÿงฎ -- Addressed bug in OpenSpec version reporting ๐Ÿ› - -The major contributions to 0.14.x remain: - -๐Ÿ—„๏ธ Document Graphs. Launch from file preview or from the FIle tree panel. Explore relationships between Markdown documents that contain links between documents and to URLs. - -๐Ÿ“ถ SSH support for agents. Manage a remote agent with feature parity over SSH. Includes support for Git and File tree panels. Manage agents on remote systems or in containers. This even works for Group Chat, which is rad as hell. - -๐Ÿง™โ€โ™‚๏ธ Added an in-tab wizard for generating Auto Run Playbooks via `/wizard` or a new button in the Auto Run panel. - -# Smaller Changes in 014.x - -- Improved User Dashboard, available from hamburger menu, command palette or hotkey ๐ŸŽ›๏ธ -- Leaderboard tracking now works across multiple systems and syncs level from cloud ๐Ÿ† -- Agent duplication. Pro tip: Consider a group of unused "Template" agents โœŒ๏ธ -- New setting to prevent system from going to sleep while agents are active ๐Ÿ›๏ธ -- The tab menu has a new "Publish as GitHub Gist" option ๐Ÿ“ -- The tab menu has options to move the tab to the first or last position ๐Ÿ”€ -- [Maestro-Playbooks](https://github.com/pedramamini/Maestro-Playbooks) can now contain non-markdown assets ๐Ÿ“™ -- Improved default shell detection ๐Ÿš -- Added logic to prevent overlapping TTS notifications ๐Ÿ’ฌ -- Added "Toggle Bookmark" shortcut (CTRL/CMD+SHIFT+B) โŒจ๏ธ -- Gist publishing now shows previous URLs with copy button ๐Ÿ“‹ - +Changes in this point release include: + +- Desktop app performance improvements (more to come on this, we want Maestro blazing fast) ๐ŸŒ +- Added local manifest feature for custom playbooks ๐Ÿ“– +- Agents are now inherently aware of your activity history as seen in the history panel ๐Ÿ“œ (this is built-in cross context memory!) +- Added markdown rendering support for AI responses in mobile view ๐Ÿ“ฑ +- Bugfix in tracking costs from JSONL files that were aged out ๐Ÿฆ +- Added BlueSky social media handle for leaderboard ๐Ÿฆ‹ +- Added options to disable GPU rendering and confetti ๐ŸŽŠ +- Better handling of large files in preview ๐Ÿ—„๏ธ +- Bug fix in Claude context calculation ๐Ÿงฎ +- Addressed bug in OpenSpec version reporting ๐Ÿ› + +The major contributions to 0.14.x remain: + +๐Ÿ—„๏ธ Document Graphs. Launch from file preview or from the FIle tree panel. Explore relationships between Markdown documents that contain links between documents and to URLs. + +๐Ÿ“ถ SSH support for agents. Manage a remote agent with feature parity over SSH. Includes support for Git and File tree panels. Manage agents on remote systems or in containers. This even works for Group Chat, which is rad as hell. + +๐Ÿง™โ€โ™‚๏ธ Added an in-tab wizard for generating Auto Run Playbooks via `/wizard` or a new button in the Auto Run panel. + +# Smaller Changes in 014.x + +- Improved User Dashboard, available from hamburger menu, command palette or hotkey ๐ŸŽ›๏ธ +- Leaderboard tracking now works across multiple systems and syncs level from cloud ๐Ÿ† +- Agent duplication. Pro tip: Consider a group of unused "Template" agents โœŒ๏ธ +- New setting to prevent system from going to sleep while agents are active ๐Ÿ›๏ธ +- The tab menu has a new "Publish as GitHub Gist" option ๐Ÿ“ +- The tab menu has options to move the tab to the first or last position ๐Ÿ”€ +- [Maestro-Playbooks](https://github.com/pedramamini/Maestro-Playbooks) can now contain non-markdown assets ๐Ÿ“™ +- Improved default shell detection ๐Ÿš +- Added logic to prevent overlapping TTS notifications ๐Ÿ’ฌ +- Added "Toggle Bookmark" shortcut (CTRL/CMD+SHIFT+B) โŒจ๏ธ +- Gist publishing now shows previous URLs with copy button ๐Ÿ“‹ + Thanks for the contributions: @t1mmen @aejfager @Crumbgrabber @whglaser @b3nw @deandebeer @shadown @breki @charles-dyfis-net @ronaldeddings @jlengrand @ksylvan ### Previous Releases in this Series @@ -136,20 +136,22 @@ Thanks for the contributions: @t1mmen @aejfager @Crumbgrabber @whglaser @b3nw @d ### Changes -- TAKE TWO! Fixed Linux ARM64 build architecture contamination issues ๐Ÿ—๏ธ - -### v0.13.1 Changes -- Fixed Linux ARM64 build architecture contamination issues ๐Ÿ—๏ธ -- Enhanced error handling for Auto Run batch processing ๐Ÿšจ - -### v0.13.0 Changes -- Added a global usage dashboard, data collection begins with this install ๐ŸŽ›๏ธ -- Added a Playbook Exchange for downloading pre-defined Auto Run playbooks from [Maestro-Playbooks](https://github.com/pedramamini/Maestro-Playbooks) ๐Ÿ“• -- Bundled OpenSpec commands for structured change proposals ๐Ÿ“ -- Added pre-release channel support for beta/RC updates ๐Ÿงช -- Implemented global hands-on time tracking across sessions โฑ๏ธ -- Added new keyboard shortcut for agent settings (Opt+Cmd+, | Ctrl+Alt+,) โŒจ๏ธ -- Added directory size calculation with file/folder counts in file explorer ๐Ÿ“Š +- TAKE TWO! Fixed Linux ARM64 build architecture contamination issues ๐Ÿ—๏ธ + +### v0.13.1 Changes + +- Fixed Linux ARM64 build architecture contamination issues ๐Ÿ—๏ธ +- Enhanced error handling for Auto Run batch processing ๐Ÿšจ + +### v0.13.0 Changes + +- Added a global usage dashboard, data collection begins with this install ๐ŸŽ›๏ธ +- Added a Playbook Exchange for downloading pre-defined Auto Run playbooks from [Maestro-Playbooks](https://github.com/pedramamini/Maestro-Playbooks) ๐Ÿ“• +- Bundled OpenSpec commands for structured change proposals ๐Ÿ“ +- Added pre-release channel support for beta/RC updates ๐Ÿงช +- Implemented global hands-on time tracking across sessions โฑ๏ธ +- Added new keyboard shortcut for agent settings (Opt+Cmd+, | Ctrl+Alt+,) โŒจ๏ธ +- Added directory size calculation with file/folder counts in file explorer ๐Ÿ“Š - Added sleep detection to exclude laptop sleep from time tracking โฐ ### Previous Releases in this Series @@ -163,22 +165,26 @@ Thanks for the contributions: @t1mmen @aejfager @Crumbgrabber @whglaser @b3nw @d **Latest: v0.12.3** | Released December 28, 2025 -The big changes in the v0.12.x line are the following three: - -## Show Thinking -๐Ÿค” There is now a toggle to show thinking for the agent, the default for new tabs is off, though this can be changed under Settings > General. The toggle shows next to History and Read-Only. Very similar pattern. This has been the #1 most requested feature, though personally, I don't think I'll use it as I prefer to not see the details of the work, but the results of the work. Just as we work with our colleagues. - -## GitHub Spec-Kit Integration -๐ŸŽฏ Added [GitHub Spec-Kit](https://github.com/github/spec-kit) commands into Maestro with a built in updater to grab the latest prompts from the repository. We do override `/speckit-implement` (the final step) to create Auto Run docs and guide the user through their execution, which thanks to Wortrees from v0.11.x allows us to run in parallel! - -## Context Management Tools -๐Ÿ“– Added context management options from tab right-click menu. You can now compress, merge, and transfer contexts between agents. You will received (configurable) warnings at 60% and 80% context consumption with a hint to compact. - -## Changes Specific to v0.12.3: -- We now have hosted documentation through Mintlify ๐Ÿ“š -- Export any tab conversation as self-contained themed HTML file ๐Ÿ“„ -- Publish files as private/public Gists ๐ŸŒ -- Added tab hover overlay menu with close operations and export ๐Ÿ“‹ +The big changes in the v0.12.x line are the following three: + +## Show Thinking + +๐Ÿค” There is now a toggle to show thinking for the agent, the default for new tabs is off, though this can be changed under Settings > General. The toggle shows next to History and Read-Only. Very similar pattern. This has been the #1 most requested feature, though personally, I don't think I'll use it as I prefer to not see the details of the work, but the results of the work. Just as we work with our colleagues. + +## GitHub Spec-Kit Integration + +๐ŸŽฏ Added [GitHub Spec-Kit](https://github.com/github/spec-kit) commands into Maestro with a built in updater to grab the latest prompts from the repository. We do override `/speckit-implement` (the final step) to create Auto Run docs and guide the user through their execution, which thanks to Wortrees from v0.11.x allows us to run in parallel! + +## Context Management Tools + +๐Ÿ“– Added context management options from tab right-click menu. You can now compress, merge, and transfer contexts between agents. You will received (configurable) warnings at 60% and 80% context consumption with a hint to compact. + +## Changes Specific to v0.12.3: + +- We now have hosted documentation through Mintlify ๐Ÿ“š +- Export any tab conversation as self-contained themed HTML file ๐Ÿ“„ +- Publish files as private/public Gists ๐ŸŒ +- Added tab hover overlay menu with close operations and export ๐Ÿ“‹ - Added social handles to achievement share images ๐Ÿ† ### Previous Releases in this Series @@ -192,12 +198,12 @@ The big changes in the v0.12.x line are the following three: **Latest: v0.11.0** | Released December 22, 2025 -๐ŸŒณ Github Worktree support was added. Any agent bound to a Git repository has the option to enable worktrees, each of which show up as a sub-agent with their own write-lock and Auto Run capability. Now you can truly develop in parallel on the same project and issue PRs when you're ready, all from within Maestro. Huge improvement, major thanks to @petersilberman. - -# Other Changes - -- @ file mentions now include documents from your Auto Run folder (which may not live in your agent working directory) ๐Ÿ—„๏ธ -- The wizard is now capable of detecting and continuing on past started projects ๐Ÿง™ +๐ŸŒณ Github Worktree support was added. Any agent bound to a Git repository has the option to enable worktrees, each of which show up as a sub-agent with their own write-lock and Auto Run capability. Now you can truly develop in parallel on the same project and issue PRs when you're ready, all from within Maestro. Huge improvement, major thanks to @petersilberman. + +# Other Changes + +- @ file mentions now include documents from your Auto Run folder (which may not live in your agent working directory) ๐Ÿ—„๏ธ +- The wizard is now capable of detecting and continuing on past started projects ๐Ÿง™ - Bug fixes ๐Ÿ›๐Ÿœ๐Ÿž --- @@ -208,14 +214,14 @@ The big changes in the v0.12.x line are the following three: ### Changes -- Export group chats as self-contained HTML โฌ‡๏ธ -- Enhanced system process viewer now has details view with full process args ๐Ÿ’ป -- Update button hides until platform binaries are available in releases. โณ -- Added Auto Run stall detection at the loop level, if no documents are updated after a loop ๐Ÿ” -- Improved Codex session discovery ๐Ÿ” -- Windows compatibility fixes ๐Ÿ› -- 64-bit Linux ARM build issue fixed (thanks @LilYoopug) ๐Ÿœ -- Addressed session enumeration issues with Codex and OpenCode ๐Ÿž +- Export group chats as self-contained HTML โฌ‡๏ธ +- Enhanced system process viewer now has details view with full process args ๐Ÿ’ป +- Update button hides until platform binaries are available in releases. โณ +- Added Auto Run stall detection at the loop level, if no documents are updated after a loop ๐Ÿ” +- Improved Codex session discovery ๐Ÿ” +- Windows compatibility fixes ๐Ÿ› +- 64-bit Linux ARM build issue fixed (thanks @LilYoopug) ๐Ÿœ +- Addressed session enumeration issues with Codex and OpenCode ๐Ÿž - Addressed pathing issues around gh command (thanks @oliveiraantoniocc) ๐Ÿ ### Previous Releases in this Series @@ -231,13 +237,13 @@ The big changes in the v0.12.x line are the following three: ### Changes -- Add Sentry crashing reporting monitoring with opt-out ๐Ÿ› -- Stability fixes on v0.9.0 along with all the changes it brought along, including... - - Major refactor to enable supporting of multiple providers ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ - - Added OpenAI Codex support ๐Ÿ‘จโ€๐Ÿ’ป - - Added OpenCode support ๐Ÿ‘ฉโ€๐Ÿ’ป - - Error handling system detects and recovers from agent failures ๐Ÿšจ - - Added option to specify CLI arguments to AI providers โœจ +- Add Sentry crashing reporting monitoring with opt-out ๐Ÿ› +- Stability fixes on v0.9.0 along with all the changes it brought along, including... + - Major refactor to enable supporting of multiple providers ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ + - Added OpenAI Codex support ๐Ÿ‘จโ€๐Ÿ’ป + - Added OpenCode support ๐Ÿ‘ฉโ€๐Ÿ’ป + - Error handling system detects and recovers from agent failures ๐Ÿšจ + - Added option to specify CLI arguments to AI providers โœจ - Bunch of other little tweaks and additions ๐Ÿ’Ž ### Previous Releases in this Series @@ -252,19 +258,19 @@ The big changes in the v0.12.x line are the following three: ### Changes -- Added "Nudge" messages. Short static copy to include with every interactive message sent, perhaps to remind the agent on how to work ๐Ÿ“Œ -- Addressed various resource consumption issues to reduce battery cost ๐Ÿ“‰ -- Implemented fuzzy file search in quick actions for instant navigation ๐Ÿ” -- Added "clear" command support to clean terminal shell logs ๐Ÿงน -- Simplified search highlighting by integrating into markdown pipeline โœจ -- Enhanced update checker to filter prerelease tags like -rc, -beta ๐Ÿš€ -- Fixed RPM package compatibility for OpenSUSE Tumbleweed ๐Ÿง (H/T @JOduMonT) -- Added libuuid1 support alongside standard libuuid dependency ๐Ÿ“ฆ -- Introduced Cmd+Shift+U shortcut for tab unread toggle โŒจ๏ธ -- Enhanced keyboard navigation for marking tabs unread ๐ŸŽฏ -- Expanded Linux distribution support with smart dependencies ๐ŸŒ -- Major underlying code re-structuring for maintainability ๐Ÿงน -- Improved stall detection to allow for individual docs to stall out while not affecting the entire playbook ๐Ÿ“– (H/T @mattjay) +- Added "Nudge" messages. Short static copy to include with every interactive message sent, perhaps to remind the agent on how to work ๐Ÿ“Œ +- Addressed various resource consumption issues to reduce battery cost ๐Ÿ“‰ +- Implemented fuzzy file search in quick actions for instant navigation ๐Ÿ” +- Added "clear" command support to clean terminal shell logs ๐Ÿงน +- Simplified search highlighting by integrating into markdown pipeline โœจ +- Enhanced update checker to filter prerelease tags like -rc, -beta ๐Ÿš€ +- Fixed RPM package compatibility for OpenSUSE Tumbleweed ๐Ÿง (H/T @JOduMonT) +- Added libuuid1 support alongside standard libuuid dependency ๐Ÿ“ฆ +- Introduced Cmd+Shift+U shortcut for tab unread toggle โŒจ๏ธ +- Enhanced keyboard navigation for marking tabs unread ๐ŸŽฏ +- Expanded Linux distribution support with smart dependencies ๐ŸŒ +- Major underlying code re-structuring for maintainability ๐Ÿงน +- Improved stall detection to allow for individual docs to stall out while not affecting the entire playbook ๐Ÿ“– (H/T @mattjay) - Added option to select a static listening port for remote control ๐ŸŽฎ (H/T @b3nw) ### Previous Releases in this Series @@ -284,35 +290,40 @@ The big changes in the v0.12.x line are the following three: **Latest: v0.7.4** | Released December 12, 2025 -Minor bugfixes on top of v0.7.3: - -# Onboarding, Wizard, and Tours -- Implemented comprehensive onboarding wizard with integrated tour system ๐Ÿš€ -- Added project-understanding confidence display to wizard UI ๐ŸŽจ -- Enhanced keyboard navigation across all wizard screens โŒจ๏ธ -- Added analytics tracking for wizard and tour completion ๐Ÿ“ˆ -- Added First Run Celebration modal with confetti animation ๐ŸŽ‰ - -# UI / UX Enhancements -- Added expand-to-fullscreen button for Auto Run interface ๐Ÿ–ฅ๏ธ -- Created dedicated modal component and improved modal priority constants for expanded Auto Run view ๐Ÿ“ -- Enhanced user experience with fullscreen editing capabilities โœจ -- Fixed tab name display to correctly show full name for active tabs ๐Ÿท๏ธ -- Added performance optimizations with throttling and caching for scrolling โšก -- Implemented drag-and-drop reordering for execution queue items ๐ŸŽฏ -- Enhanced toast context with agent name for OS notifications ๐Ÿ“ข - -# Auto Run Workflow Improvements -- Created phase document generation for Auto Run workflow ๐Ÿ“„ -- Added real-time log streaming to the LogViewer component ๐Ÿ“Š - -# Application Behavior / Core Fixes -- Added validation to prevent nested worktrees inside the main repository ๐Ÿšซ -- Fixed process manager to properly emit exit events on errors ๐Ÿ”ง -- Fixed process exit handling to ensure proper cleanup ๐Ÿงน - -# Update System -- Implemented automatic update checking on application startup ๐Ÿš€ +Minor bugfixes on top of v0.7.3: + +# Onboarding, Wizard, and Tours + +- Implemented comprehensive onboarding wizard with integrated tour system ๐Ÿš€ +- Added project-understanding confidence display to wizard UI ๐ŸŽจ +- Enhanced keyboard navigation across all wizard screens โŒจ๏ธ +- Added analytics tracking for wizard and tour completion ๐Ÿ“ˆ +- Added First Run Celebration modal with confetti animation ๐ŸŽ‰ + +# UI / UX Enhancements + +- Added expand-to-fullscreen button for Auto Run interface ๐Ÿ–ฅ๏ธ +- Created dedicated modal component and improved modal priority constants for expanded Auto Run view ๐Ÿ“ +- Enhanced user experience with fullscreen editing capabilities โœจ +- Fixed tab name display to correctly show full name for active tabs ๐Ÿท๏ธ +- Added performance optimizations with throttling and caching for scrolling โšก +- Implemented drag-and-drop reordering for execution queue items ๐ŸŽฏ +- Enhanced toast context with agent name for OS notifications ๐Ÿ“ข + +# Auto Run Workflow Improvements + +- Created phase document generation for Auto Run workflow ๐Ÿ“„ +- Added real-time log streaming to the LogViewer component ๐Ÿ“Š + +# Application Behavior / Core Fixes + +- Added validation to prevent nested worktrees inside the main repository ๐Ÿšซ +- Fixed process manager to properly emit exit events on errors ๐Ÿ”ง +- Fixed process exit handling to ensure proper cleanup ๐Ÿงน + +# Update System + +- Implemented automatic update checking on application startup ๐Ÿš€ - Added settings toggle for enabling/disabling startup update checks โš™๏ธ ### Previous Releases in this Series @@ -328,38 +339,40 @@ Minor bugfixes on top of v0.7.3: **Latest: v0.6.1** | Released December 4, 2025 -In this release... -- Added recursive subfolder support for Auto Run markdown files ๐Ÿ—‚๏ธ -- Enhanced document tree display with expandable folder navigation ๐ŸŒณ -- Enabled creating documents in subfolders with path selection ๐Ÿ“ -- Improved batch runner UI with inline progress bars and loop indicators ๐Ÿ“Š -- Fixed execution queue display bug for immediate command processing ๐Ÿ› -- Added folder icons and better visual hierarchy for document browser ๐ŸŽจ -- Implemented dynamic task re-counting for batch run loop iterations ๐Ÿ”„ -- Enhanced create document modal with location selector dropdown ๐Ÿ“ -- Improved progress tracking with per-document completion visualization ๐Ÿ“ˆ -- Added support for nested folder structures in document management ๐Ÿ—๏ธ - -Plus the pre-release ALPHA... -- Template vars now set context in default autorun prompt ๐Ÿš€ -- Added Enter key support for queued message confirmation dialog โŒจ๏ธ -- Kill process capability added to System Process Monitor ๐Ÿ’€ -- Toggle markdown rendering added to Cmd+K Quick Actions ๐Ÿ“ -- Fixed cloudflared detection in packaged app environments ๐Ÿ”ง -- Added debugging logs for process exit diagnostics ๐Ÿ› -- Tab switcher shows last activity timestamps and filters by project ๐Ÿ• -- Slash commands now fill text on Tab/Enter instead of executing โšก -- Added GitHub Actions workflow for auto-assigning issues/PRs ๐Ÿค– -- Graceful handling for playbooks with missing documents implemented โœจ -- Added multi-document batch processing for Auto Run ๐Ÿš€ -- Introduced Git worktree support for parallel execution ๐ŸŒณ -- Created playbook system for saving run configurations ๐Ÿ“š -- Implemented document reset-on-completion with loop mode ๐Ÿ”„ -- Added drag-and-drop document reordering interface ๐ŸŽฏ -- Built Auto Run folder selector with file management ๐Ÿ“ -- Enhanced progress tracking with per-document metrics ๐Ÿ“Š -- Integrated PR creation after worktree completion ๐Ÿ”€ -- Added undo/redo support in document editor โ†ฉ๏ธ +In this release... + +- Added recursive subfolder support for Auto Run markdown files ๐Ÿ—‚๏ธ +- Enhanced document tree display with expandable folder navigation ๐ŸŒณ +- Enabled creating documents in subfolders with path selection ๐Ÿ“ +- Improved batch runner UI with inline progress bars and loop indicators ๐Ÿ“Š +- Fixed execution queue display bug for immediate command processing ๐Ÿ› +- Added folder icons and better visual hierarchy for document browser ๐ŸŽจ +- Implemented dynamic task re-counting for batch run loop iterations ๐Ÿ”„ +- Enhanced create document modal with location selector dropdown ๐Ÿ“ +- Improved progress tracking with per-document completion visualization ๐Ÿ“ˆ +- Added support for nested folder structures in document management ๐Ÿ—๏ธ + +Plus the pre-release ALPHA... + +- Template vars now set context in default autorun prompt ๐Ÿš€ +- Added Enter key support for queued message confirmation dialog โŒจ๏ธ +- Kill process capability added to System Process Monitor ๐Ÿ’€ +- Toggle markdown rendering added to Cmd+K Quick Actions ๐Ÿ“ +- Fixed cloudflared detection in packaged app environments ๐Ÿ”ง +- Added debugging logs for process exit diagnostics ๐Ÿ› +- Tab switcher shows last activity timestamps and filters by project ๐Ÿ• +- Slash commands now fill text on Tab/Enter instead of executing โšก +- Added GitHub Actions workflow for auto-assigning issues/PRs ๐Ÿค– +- Graceful handling for playbooks with missing documents implemented โœจ +- Added multi-document batch processing for Auto Run ๐Ÿš€ +- Introduced Git worktree support for parallel execution ๐ŸŒณ +- Created playbook system for saving run configurations ๐Ÿ“š +- Implemented document reset-on-completion with loop mode ๐Ÿ”„ +- Added drag-and-drop document reordering interface ๐ŸŽฏ +- Built Auto Run folder selector with file management ๐Ÿ“ +- Enhanced progress tracking with per-document metrics ๐Ÿ“Š +- Integrated PR creation after worktree completion ๐Ÿ”€ +- Added undo/redo support in document editor โ†ฉ๏ธ - Implemented auto-save with 5-second debounce ๐Ÿ’พ ### Previous Releases in this Series @@ -374,15 +387,15 @@ Plus the pre-release ALPHA... ### Changes -- Added "Made with Maestro" badge to README header ๐ŸŽฏ -- Redesigned app icon with darker purple color scheme ๐ŸŽจ -- Created new SVG badge for project attribution ๐Ÿท๏ธ -- Added side-by-side image diff viewer for git changes ๐Ÿ–ผ๏ธ -- Enhanced confetti animation with realistic cannon-style bursts ๐ŸŽŠ -- Fixed z-index layering for standing ovation overlay ๐Ÿ“Š -- Improved tab switcher to show all named sessions ๐Ÿ” -- Enhanced batch synopsis prompts for cleaner summaries ๐Ÿ“ -- Added binary file detection in git diff parser ๐Ÿ”ง +- Added "Made with Maestro" badge to README header ๐ŸŽฏ +- Redesigned app icon with darker purple color scheme ๐ŸŽจ +- Created new SVG badge for project attribution ๐Ÿท๏ธ +- Added side-by-side image diff viewer for git changes ๐Ÿ–ผ๏ธ +- Enhanced confetti animation with realistic cannon-style bursts ๐ŸŽŠ +- Fixed z-index layering for standing ovation overlay ๐Ÿ“Š +- Improved tab switcher to show all named sessions ๐Ÿ” +- Enhanced batch synopsis prompts for cleaner summaries ๐Ÿ“ +- Added binary file detection in git diff parser ๐Ÿ”ง - Implemented git file reading at specific refs ๐Ÿ“ ### Previous Releases in this Series @@ -397,24 +410,24 @@ Plus the pre-release ALPHA... ### Changes -- Added Tab Switcher modal for quick navigation between AI tabs ๐Ÿš€ -- Implemented @ mention file completion for AI mode references ๐Ÿ“ -- Added navigation history with back/forward through sessions and tabs โฎ๏ธ -- Introduced tab completion filters for branches, tags, and files ๐ŸŒณ -- Added unread tab indicators and filtering for better organization ๐Ÿ“ฌ -- Implemented token counting display with human-readable formatting ๐Ÿ”ข -- Added markdown rendering toggle for AI responses in terminal ๐Ÿ“ -- Removed built-in slash commands in favor of custom AI commands ๐ŸŽฏ -- Added context menu for sessions with rename, bookmark, move options ๐Ÿ–ฑ๏ธ -- Enhanced file preview with stats showing size, tokens, timestamps ๐Ÿ“Š -- Added token counting with js-tiktoken for file preview stats bar ๐Ÿ”ข -- Implemented Tab Switcher modal for fuzzy-search navigation (Opt+Cmd+T) ๐Ÿ” -- Added Save to History toggle (Cmd+S) for automatic work synopsis tracking ๐Ÿ’พ -- Enhanced tab completion with @ mentions for file references in AI prompts ๐Ÿ“Ž -- Implemented navigation history with back/forward shortcuts (Cmd+Shift+,/.) ๐Ÿ”™ -- Added git branches and tags to intelligent tab completion system ๐ŸŒฟ -- Enhanced markdown rendering with syntax highlighting and toggle view ๐Ÿ“ -- Added right-click context menus for session management and organization ๐Ÿ–ฑ๏ธ +- Added Tab Switcher modal for quick navigation between AI tabs ๐Ÿš€ +- Implemented @ mention file completion for AI mode references ๐Ÿ“ +- Added navigation history with back/forward through sessions and tabs โฎ๏ธ +- Introduced tab completion filters for branches, tags, and files ๐ŸŒณ +- Added unread tab indicators and filtering for better organization ๐Ÿ“ฌ +- Implemented token counting display with human-readable formatting ๐Ÿ”ข +- Added markdown rendering toggle for AI responses in terminal ๐Ÿ“ +- Removed built-in slash commands in favor of custom AI commands ๐ŸŽฏ +- Added context menu for sessions with rename, bookmark, move options ๐Ÿ–ฑ๏ธ +- Enhanced file preview with stats showing size, tokens, timestamps ๐Ÿ“Š +- Added token counting with js-tiktoken for file preview stats bar ๐Ÿ”ข +- Implemented Tab Switcher modal for fuzzy-search navigation (Opt+Cmd+T) ๐Ÿ” +- Added Save to History toggle (Cmd+S) for automatic work synopsis tracking ๐Ÿ’พ +- Enhanced tab completion with @ mentions for file references in AI prompts ๐Ÿ“Ž +- Implemented navigation history with back/forward shortcuts (Cmd+Shift+,/.) ๐Ÿ”™ +- Added git branches and tags to intelligent tab completion system ๐ŸŒฟ +- Enhanced markdown rendering with syntax highlighting and toggle view ๐Ÿ“ +- Added right-click context menus for session management and organization ๐Ÿ–ฑ๏ธ - Improved mobile app with better WebSocket reconnection and status badges ๐Ÿ“ฑ ### Previous Releases in this Series @@ -429,15 +442,15 @@ Plus the pre-release ALPHA... ### Changes -- Fixed tab handling requiring explicitly selected Claude session ๐Ÿ”ง -- Added auto-scroll navigation for slash command list selection โšก -- Implemented TTS audio feedback for toast notifications speak ๐Ÿ”Š -- Fixed shortcut case sensitivity using lowercase key matching ๐Ÿ”ค -- Added Cmd+Shift+J shortcut to jump to bottom instantly โฌ‡๏ธ -- Sorted shortcuts alphabetically in help modal for discovery ๐Ÿ“‘ -- Display full commit message body in git log view ๐Ÿ“ -- Added expand/collapse all buttons to process tree header ๐ŸŒณ -- Support synopsis process type in process tree parsing ๐Ÿ” +- Fixed tab handling requiring explicitly selected Claude session ๐Ÿ”ง +- Added auto-scroll navigation for slash command list selection โšก +- Implemented TTS audio feedback for toast notifications speak ๐Ÿ”Š +- Fixed shortcut case sensitivity using lowercase key matching ๐Ÿ”ค +- Added Cmd+Shift+J shortcut to jump to bottom instantly โฌ‡๏ธ +- Sorted shortcuts alphabetically in help modal for discovery ๐Ÿ“‘ +- Display full commit message body in git log view ๐Ÿ“ +- Added expand/collapse all buttons to process tree header ๐ŸŒณ +- Support synopsis process type in process tree parsing ๐Ÿ” - Renamed "No Group" to "UNGROUPED" for better clarity โœจ ### Previous Releases in this Series @@ -450,15 +463,15 @@ Plus the pre-release ALPHA... **Latest: v0.2.3** | Released November 29, 2025 -โ€ข Enhanced mobile web interface with session sync and history panel ๐Ÿ“ฑ -โ€ข Added ThinkingStatusPill showing real-time token counts and elapsed time โฑ๏ธ -โ€ข Implemented task count badges and session deduplication for batch runner ๐Ÿ“Š -โ€ข Added TTS stop control and improved voice synthesis compatibility ๐Ÿ”Š -โ€ข Created image lightbox with navigation, clipboard, and delete features ๐Ÿ–ผ๏ธ -โ€ข Fixed UI bugs in search, auto-scroll, and sidebar interactions ๐Ÿ› -โ€ข Added global Claude stats with streaming updates across projects ๐Ÿ“ˆ -โ€ข Improved markdown checkbox styling and collapsed palette hover UX โœจ -โ€ข Enhanced scratchpad with search, image paste, and attachment support ๐Ÿ” +โ€ข Enhanced mobile web interface with session sync and history panel ๐Ÿ“ฑ +โ€ข Added ThinkingStatusPill showing real-time token counts and elapsed time โฑ๏ธ +โ€ข Implemented task count badges and session deduplication for batch runner ๐Ÿ“Š +โ€ข Added TTS stop control and improved voice synthesis compatibility ๐Ÿ”Š +โ€ข Created image lightbox with navigation, clipboard, and delete features ๐Ÿ–ผ๏ธ +โ€ข Fixed UI bugs in search, auto-scroll, and sidebar interactions ๐Ÿ› +โ€ข Added global Claude stats with streaming updates across projects ๐Ÿ“ˆ +โ€ข Improved markdown checkbox styling and collapsed palette hover UX โœจ +โ€ข Enhanced scratchpad with search, image paste, and attachment support ๐Ÿ” โ€ข Added splash screen with logo and progress bar during startup ๐ŸŽจ ### Previous Releases in this Series @@ -473,15 +486,15 @@ Plus the pre-release ALPHA... **Latest: v0.1.6** | Released November 27, 2025 -โ€ข Added template variables for dynamic AI command customization ๐ŸŽฏ -โ€ข Implemented session bookmarking with star icons and dedicated section โญ -โ€ข Enhanced Git Log Viewer with smarter date formatting ๐Ÿ“… -โ€ข Improved GitHub release workflow to handle partial failures gracefully ๐Ÿ”ง -โ€ข Added collapsible template documentation in AI Commands panel ๐Ÿ“š -โ€ข Updated default commit command with session ID traceability ๐Ÿ” -โ€ข Added tag indicators for custom-named sessions visually ๐Ÿท๏ธ -โ€ข Improved Git Log search UX with better focus handling ๐ŸŽจ -โ€ข Fixed input placeholder spacing for better readability ๐Ÿ“ +โ€ข Added template variables for dynamic AI command customization ๐ŸŽฏ +โ€ข Implemented session bookmarking with star icons and dedicated section โญ +โ€ข Enhanced Git Log Viewer with smarter date formatting ๐Ÿ“… +โ€ข Improved GitHub release workflow to handle partial failures gracefully ๐Ÿ”ง +โ€ข Added collapsible template documentation in AI Commands panel ๐Ÿ“š +โ€ข Updated default commit command with session ID traceability ๐Ÿ” +โ€ข Added tag indicators for custom-named sessions visually ๐Ÿท๏ธ +โ€ข Improved Git Log search UX with better focus handling ๐ŸŽจ +โ€ข Fixed input placeholder spacing for better readability ๐Ÿ“ โ€ข Updated documentation with new features and template references ๐Ÿ“– ### Previous Releases in this Series @@ -500,6 +513,7 @@ Plus the pre-release ALPHA... All releases are available on the [GitHub Releases page](https://github.com/RunMaestro/Maestro/releases). Maestro is available for: + - **macOS** - Apple Silicon (arm64) and Intel (x64) - **Windows** - x64 - **Linux** - x64 and arm64, AppImage, deb, and rpm packages diff --git a/package-lock.json b/package-lock.json index 7482623e10..d9270c06f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "maestro", - "version": "0.15.0", + "version": "0.15.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "maestro", - "version": "0.15.0", + "version": "0.15.2", "hasInstallScript": true, "license": "AGPL 3.0", "dependencies": { @@ -35,6 +35,11 @@ "electron-store": "^8.1.0", "electron-updater": "^6.6.2", "fastify": "^4.25.2", + "i18next": "^25.8.17", + "i18next-browser-languagedetector": "^8.2.1", + "i18next-fs-backend": "^2.6.1", + "i18next-http-backend": "^3.0.2", + "i18next-resources-to-backend": "^1.2.1", "js-tiktoken": "^1.0.21", "marked": "^17.0.1", "mermaid": "^11.12.1", @@ -42,6 +47,7 @@ "qrcode": "^1.5.4", "qrcode.react": "^4.2.0", "react-diff-view": "^3.3.2", + "react-i18next": "^16.5.6", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.0", "reactflow": "^11.11.4", @@ -102,6 +108,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "tailwindcss": "^3.4.1", + "tailwindcss-rtl": "^0.9.0", "typescript": "^5.3.3", "typescript-eslint": "^8.50.1", "vite": "^5.0.11", @@ -468,9 +475,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -7131,6 +7138,15 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -8896,7 +8912,6 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -10950,6 +10965,15 @@ "dev": true, "license": "MIT" }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -11062,6 +11086,70 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/i18next": { + "version": "25.8.17", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.17.tgz", + "integrity": "sha512-vWtCttyn5bpOK4hWbRAe1ZXkA+Yzcn2OcACT+WJavtfGMcxzkfvXTLMeOU8MUhRmAySKjU4VVuKlo0sSGeBokA==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-fs-backend": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/i18next-fs-backend/-/i18next-fs-backend-2.6.1.tgz", + "integrity": "sha512-eYWTX7QT7kJ0sZyCPK6x1q+R63zvNKv2D6UdbMf15A8vNb2ZLyw4NNNZxPFwXlIv/U+oUtg8SakW6ZgJZcoqHQ==", + "license": "MIT" + }, + "node_modules/i18next-http-backend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz", + "integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==", + "license": "MIT", + "dependencies": { + "cross-fetch": "4.0.0" + } + }, + "node_modules/i18next-resources-to-backend": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/i18next-resources-to-backend/-/i18next-resources-to-backend-1.2.1.tgz", + "integrity": "sha512-okHbVA+HZ7n1/76MsfhPqDou0fptl2dAlhRDu2ideXloRRduzHsqDOznJBef+R3DFZnbvWoBW+KxJ7fnFjd6Yw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/iconv-corefoundation": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", @@ -14205,6 +14293,48 @@ "node": ">=10" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-gyp": { "version": "9.4.1", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-9.4.1.tgz", @@ -15703,6 +15833,33 @@ "react": "^18.3.1" } }, + "node_modules/react-i18next": { + "version": "16.5.6", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.6.tgz", + "integrity": "sha512-Ua7V2/efA88ido7KyK51fb8Ki8M/sRfW8LR/rZ/9ZKr2luhuTI7kwYZN5agT1rWG7aYm5G0RYE/6JR8KJoCMDw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.6.2", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -17453,6 +17610,13 @@ "node": ">=14.0.0" } }, + "node_modules/tailwindcss-rtl": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/tailwindcss-rtl/-/tailwindcss-rtl-0.9.0.tgz", + "integrity": "sha512-y7yC8QXjluDBEFMSX33tV6xMYrf0B3sa+tOB5JSQb6/G6laBU313a+Z+qxu55M1Qyn8tDMttjomsA8IsJD+k+w==", + "dev": true, + "license": "MIT" + }, "node_modules/tar": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", @@ -17993,7 +18157,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -19545,6 +19709,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/vscode-jsonrpc": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", diff --git a/package.json b/package.json index 73cbab52aa..0bf6e61089 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,10 @@ "test:integration:watch": "vitest --config vitest.integration.config.ts", "test:performance": "vitest run --config vitest.performance.config.mts", "refresh-speckit": "node scripts/refresh-speckit.mjs", - "refresh-openspec": "node scripts/refresh-openspec.mjs" + "refresh-openspec": "node scripts/refresh-openspec.mjs", + "i18n:audit": "npx tsx scripts/i18n-audit.ts", + "i18n:validate": "npx tsx scripts/i18n-validate.ts", + "i18n:size-check": "npx tsx scripts/i18n-size-check.ts" }, "build": { "npmRebuild": false, @@ -240,6 +243,11 @@ "electron-store": "^8.1.0", "electron-updater": "^6.6.2", "fastify": "^4.25.2", + "i18next": "^25.8.17", + "i18next-browser-languagedetector": "^8.2.1", + "i18next-fs-backend": "^2.6.1", + "i18next-http-backend": "^3.0.2", + "i18next-resources-to-backend": "^1.2.1", "js-tiktoken": "^1.0.21", "marked": "^17.0.1", "mermaid": "^11.12.1", @@ -247,6 +255,7 @@ "qrcode": "^1.5.4", "qrcode.react": "^4.2.0", "react-diff-view": "^3.3.2", + "react-i18next": "^16.5.6", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.0", "reactflow": "^11.11.4", @@ -304,6 +313,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "tailwindcss": "^3.4.1", + "tailwindcss-rtl": "^0.9.0", "typescript": "^5.3.3", "typescript-eslint": "^8.50.1", "vite": "^5.0.11", @@ -315,7 +325,7 @@ }, "lint-staged": { "*": [ - "prettier --write" + "prettier --write --ignore-unknown" ], "*.{js,cjs,mjs,jsx,ts,tsx,mts,cts}": [ "eslint --fix" diff --git a/scripts/i18n-audit.ts b/scripts/i18n-audit.ts new file mode 100644 index 0000000000..ec5030b4ed --- /dev/null +++ b/scripts/i18n-audit.ts @@ -0,0 +1,358 @@ +/** + * i18n Extraction Audit Script + * + * Scans .tsx files under src/renderer/components/ and src/web/ to identify + * hardcoded user-facing strings that have not yet been wrapped with i18n + * translation helpers (t(), , tNotify()). + * + * Usage: + * npx tsx scripts/i18n-audit.ts + * npx tsx scripts/i18n-audit.ts --json # JSON output + * npx tsx scripts/i18n-audit.ts --summary-only # Only show directory counts + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// โ”€โ”€ Configuration โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const ROOT = path.resolve(__dirname, '..'); + +const SCAN_DIRS = ['src/renderer/components', 'src/web']; + +/** JSX/HTML attributes that commonly contain user-facing strings */ +const USER_FACING_ATTRS = [ + 'title', + 'placeholder', + 'aria-label', + 'aria-description', + 'aria-placeholder', + 'aria-roledescription', + 'aria-valuetext', + 'label', + 'confirmLabel', + 'cancelLabel', + 'alt', + 'description', + 'tooltip', + 'helperText', + 'errorMessage', + 'successMessage', + 'emptyText', + 'loadingText', +]; + +/** Minimum string length to consider (skip single chars and empty) */ +const MIN_STRING_LENGTH = 2; + +// โ”€โ”€ Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export interface Finding { + file: string; + line: number; + type: 'jsx-text' | 'attribute' | 'prop-value'; + attribute?: string; + text: string; +} + +interface DirectorySummary { + dir: string; + fileCount: number; + findingCount: number; +} + +// โ”€โ”€ File discovery โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export function collectTsxFiles(dir: string): string[] { + const results: string[] = []; + if (!fs.existsSync(dir)) return results; + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...collectTsxFiles(fullPath)); + } else if (entry.name.endsWith('.tsx')) { + results.push(fullPath); + } + } + return results; +} + +// โ”€โ”€ Skip-list helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Returns true if the string looks like a non-user-facing value: + * CSS class, code identifier, URL, hex colour, file path, etc. + */ +export function isNonUserFacing(s: string): boolean { + const trimmed = s.trim(); + + // Too short + if (trimmed.length < MIN_STRING_LENGTH) return true; + + // Purely numeric / whitespace + if (/^\s*[\d.,]+\s*$/.test(trimmed)) return true; + + // CSS class names (space-separated tokens that look like Tailwind/utility classes) + if ( + /^[a-z0-9[\]/:._-]+(\s+[a-z0-9[\]/:._-]+)*$/i.test(trimmed) && + /[-_/[\]]/.test(trimmed) && + !/\s[A-Z]/.test(trimmed) + ) + return true; + + // Single camelCase / PascalCase identifier without spaces (likely a code reference) + if (/^[a-zA-Z][a-zA-Z0-9]*$/.test(trimmed) && trimmed.length < 20) return true; + + // snake_case identifiers without spaces + if (/^[a-z][a-z0-9_]*$/.test(trimmed)) return true; + + // kebab-case identifiers (CSS vars, data attrs, etc.) + if (/^[a-z][a-z0-9-]*$/.test(trimmed)) return true; + + // Dot-delimited keys (object paths, config keys) like "settings.general" + if (/^[a-z][a-z0-9_.]*$/i.test(trimmed) && trimmed.includes('.') && !trimmed.includes(' ')) + return true; + + // URLs and paths + if (/^(https?:\/\/|\.\/|\.\.\/|\/)/.test(trimmed)) return true; + + // Hex colours + if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return true; + + // Pure interpolation placeholder: "{{foo}}" + if (/^\{\{[^}]+\}\}$/.test(trimmed)) return true; + + // Single emoji + if (/^[\p{Emoji_Presentation}\p{Extended_Pictographic}]{1,2}$/u.test(trimmed)) return true; + + // MIME types + if (/^[a-z]+\/[a-z0-9.+-]+$/i.test(trimmed)) return true; + + // i18n namespace:key patterns (already using i18n) + if (/^[a-z]+:[a-z_]+(\.[a-z_]+)*$/i.test(trimmed)) return true; + + return false; +} + +/** + * Returns true if the surrounding line context indicates the string + * is already translated or is non-user-facing code. + */ +export function isAlreadyTranslated(line: string, matchStart: number): boolean { + const before = line.slice(0, matchStart); + + // t('...') or t("...") or i18n.t(...) + if (/\bt\(\s*$/.test(before) || /i18n\.t\(\s*$/.test(before)) return true; + + // + if (/]*k\s*=\s*$/.test(before)) return true; + + // tNotify({ titleKey: / messageKey: ) + if (/(?:titleKey|messageKey)\s*:\s*$/.test(before)) return true; + + // import/require statements + if (/^\s*(import |require\()/.test(line)) return true; + + // className / class / style / data- attributes + if (/(?:className|class|style|data-[a-z]+)\s*=\s*(?:\{[^}]*)?$/.test(before)) return true; + + // console.log / console.error / console.warn + if (/console\.(log|error|warn|info|debug)\(/.test(line)) return true; + + // TypeScript type annotations and interface definitions + if (/^\s*(type |interface |export type |export interface )/.test(line)) return true; + + return false; +} + +// โ”€โ”€ Scanning engine โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export function scanFile(filePath: string): Finding[] { + const findings: Finding[] = []; + const content = fs.readFileSync(filePath, 'utf-8'); + const lines = content.split('\n'); + const relPath = path.relative(ROOT, filePath); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineNum = i + 1; + + // Skip comment-only lines + if (/^\s*(\/\/|\/\*|\*)/.test(line)) continue; + + // โ”€โ”€ 1. Attribute strings: attr="..." or attr={'...'} โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const attrPattern = new RegExp( + `(?:${USER_FACING_ATTRS.join('|')})\\s*=\\s*(?:"([^"]+)"|\\{'([^']+)'\\}|\\{"([^"]+)"\\})`, + 'g' + ); + let attrMatch: RegExpExecArray | null; + while ((attrMatch = attrPattern.exec(line)) !== null) { + const text = attrMatch[1] ?? attrMatch[2] ?? attrMatch[3]; + if (!text) continue; + if (isNonUserFacing(text)) continue; + if (isAlreadyTranslated(line, attrMatch.index)) continue; + + // Determine which attribute name matched + const attrNameMatch = attrMatch[0].match(/^([a-zA-Z-]+)\s*=/); + const attrName = attrNameMatch?.[1] ?? 'unknown'; + + findings.push({ + file: relPath, + line: lineNum, + type: 'attribute', + attribute: attrName, + text, + }); + } + + // โ”€โ”€ 2. JSX text content: >Some text here< โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Matches text between > and < that contains word characters + const jsxTextPattern = />\s*([A-Z][^<>{]*?)\s* f.line === lineNum && f.text === text)) continue; + + findings.push({ + file: relPath, + line: lineNum, + type: 'prop-value', + attribute: toastMatch[0].match(/^(\w+)/)?.[1], + text, + }); + } + } + + return findings; +} + +// โ”€โ”€ Output formatting โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function printFindings(findings: Finding[]): void { + let currentFile = ''; + for (const f of findings) { + if (f.file !== currentFile) { + currentFile = f.file; + console.log(`\n\x1b[36m${currentFile}\x1b[0m`); + } + const attr = f.attribute ? ` [${f.attribute}]` : ''; + const typeLabel = f.type === 'jsx-text' ? 'text' : f.type === 'attribute' ? 'attr' : 'prop'; + console.log(` \x1b[33mL${f.line}\x1b[0m \x1b[2m${typeLabel}${attr}\x1b[0m ${f.text}`); + } +} + +function printSummary(findings: Finding[], scanDirs: string[]): void { + // Group by top-level directory within the scan dirs + const dirCounts = new Map; count: number }>(); + + for (const f of findings) { + // Get the component directory (2 levels deep from scan root) + const parts = f.file.split('/'); + // Find which scan dir this belongs to + let dirKey = ''; + for (const sd of scanDirs) { + if (f.file.startsWith(sd)) { + const relative = f.file.slice(sd.length + 1); + const subDir = relative.split('/')[0]; + dirKey = subDir ? `${sd}/${subDir}` : sd; + break; + } + } + if (!dirKey) dirKey = path.dirname(f.file); + + if (!dirCounts.has(dirKey)) { + dirCounts.set(dirKey, { files: new Set(), count: 0 }); + } + const entry = dirCounts.get(dirKey)!; + entry.files.add(f.file); + entry.count++; + } + + console.log('\n\x1b[1mโ”€โ”€ Summary by Directory โ”€โ”€\x1b[0m\n'); + + const sorted = [...dirCounts.entries()].sort((a, b) => b[1].count - a[1].count); + for (const [dir, { files, count }] of sorted) { + console.log(` \x1b[36m${dir}\x1b[0m โ€” ${count} strings in ${files.size} files`); + } + + const totalFiles = new Set(findings.map((f) => f.file)).size; + console.log( + `\n\x1b[1mTotal: ${findings.length} untranslated strings across ${totalFiles} files\x1b[0m` + ); +} + +// โ”€โ”€ Main โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function main(): void { + const args = process.argv.slice(2); + const jsonMode = args.includes('--json'); + const summaryOnly = args.includes('--summary-only'); + + console.log('\x1b[1mi18n Extraction Audit\x1b[0m'); + console.log(`Scanning directories: ${SCAN_DIRS.join(', ')}\n`); + + const allFindings: Finding[] = []; + let totalFiles = 0; + + for (const scanDir of SCAN_DIRS) { + const absDir = path.resolve(ROOT, scanDir); + const files = collectTsxFiles(absDir); + totalFiles += files.length; + + for (const file of files) { + const findings = scanFile(file); + allFindings.push(...findings); + } + } + + console.log(`Scanned ${totalFiles} .tsx files`); + + if (jsonMode) { + console.log(JSON.stringify({ findings: allFindings, total: allFindings.length }, null, 2)); + return; + } + + if (!summaryOnly) { + printFindings(allFindings); + } + + printSummary(allFindings, SCAN_DIRS); +} + +// Run main() only when executed directly (not imported for testing) +const isDirectRun = + process.argv[1]?.endsWith('i18n-audit.ts') || process.argv[1]?.includes('i18n-audit'); +if (isDirectRun) { + main(); +} diff --git a/scripts/i18n-size-check.ts b/scripts/i18n-size-check.ts new file mode 100644 index 0000000000..7f5648841b --- /dev/null +++ b/scripts/i18n-size-check.ts @@ -0,0 +1,351 @@ +/** + * i18n Translation Bundle Size Check + * + * CI gate that enforces size budgets on translation files and verifies + * lazy loading is correctly configured. + * + * Checks: + * 1. Individual file size โ€” no single translation JSON exceeds the per-file limit. + * 2. Total size โ€” all translation files combined stay under the total limit. + * 3. Lazy loading โ€” only English is statically bundled; other languages are + * code-split into separate locale-* chunks. + * + * Usage: + * npx tsx scripts/i18n-size-check.ts # Human-readable report + * npx tsx scripts/i18n-size-check.ts --json # Machine-readable output + * + * Exit codes: + * 0 โ€” all checks pass + * 1 โ€” one or more checks failed + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// โ”€โ”€ Configuration โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const ROOT = path.resolve(__dirname, '..'); +const LOCALES_DIR = path.join(ROOT, 'src/shared/i18n/locales'); +const RENDERER_ASSETS_DIR = path.join(ROOT, 'dist/renderer/assets'); + +const LANGUAGES = ['en', 'es', 'fr', 'de', 'zh', 'hi', 'ar', 'bn', 'pt'] as const; +const NAMESPACES = [ + 'common', + 'settings', + 'modals', + 'menus', + 'notifications', + 'accessibility', + 'shortcuts', +] as const; + +/** + * Size budgets. + * + * Per-file: 200 KB โ€” the largest namespace (modals) can reach ~160 KB for + * non-Latin scripts (Bengali, Hindi). 200 KB provides growth headroom while + * catching runaway additions. + * + * Total: 3 MB โ€” 63 files currently total ~2.0 MB. 3 MB allows organic + * growth and additional languages without requiring immediate budget bumps. + */ +const PER_FILE_LIMIT_BYTES = 200 * 1024; // 200 KB +const TOTAL_LIMIT_BYTES = 3 * 1024 * 1024; // 3 MB + +// โ”€โ”€ Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +interface FileSize { + language: string; + namespace: string; + path: string; + bytes: number; + overBudget: boolean; +} + +interface LazyLoadResult { + passed: boolean; + englishBundled: boolean; + localeChunks: string[]; + missingLanguages: string[]; +} + +interface SizeCheckReport { + files: FileSize[]; + totalBytes: number; + totalLimitBytes: number; + perFileLimitBytes: number; + overBudgetFiles: FileSize[]; + totalOverBudget: boolean; + lazyLoading: LazyLoadResult | null; + passed: boolean; +} + +// โ”€โ”€ Core checks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function checkFileSizes(): { files: FileSize[]; totalBytes: number } { + const files: FileSize[] = []; + let totalBytes = 0; + + for (const lang of LANGUAGES) { + for (const ns of NAMESPACES) { + const filePath = path.join(LOCALES_DIR, lang, `${ns}.json`); + if (!fs.existsSync(filePath)) { + // Missing file โ€” size zero, but note it + files.push({ + language: lang, + namespace: ns, + path: filePath, + bytes: 0, + overBudget: false, + }); + continue; + } + + const stat = fs.statSync(filePath); + const overBudget = stat.size > PER_FILE_LIMIT_BYTES; + files.push({ + language: lang, + namespace: ns, + path: filePath, + bytes: stat.size, + overBudget, + }); + totalBytes += stat.size; + } + } + + return { files, totalBytes }; +} + +function checkLazyLoading(): LazyLoadResult | null { + // Skip lazy loading check if build hasn't been run + if (!fs.existsSync(RENDERER_ASSETS_DIR)) { + return null; + } + + const assetFiles = fs.readdirSync(RENDERER_ASSETS_DIR); + const localeChunkPattern = /^locale-(\w+)-[\w-]+\.js$/; + const localeChunks: string[] = []; + const foundLanguages = new Set(); + + for (const file of assetFiles) { + const match = file.match(localeChunkPattern); + if (match) { + localeChunks.push(file); + foundLanguages.add(match[1]); + } + } + + // English should NOT have its own locale chunk (it's bundled statically) + const englishBundled = !foundLanguages.has('en'); + + // Every non-English language should have a locale chunk + const nonEnglishLangs = LANGUAGES.filter((l) => l !== 'en'); + const missingLanguages = nonEnglishLangs.filter((l) => !foundLanguages.has(l)); + + const passed = englishBundled && missingLanguages.length === 0; + + return { passed, englishBundled, localeChunks, missingLanguages }; +} + +function runCheck(): SizeCheckReport { + const { files, totalBytes } = checkFileSizes(); + const overBudgetFiles = files.filter((f) => f.overBudget); + const totalOverBudget = totalBytes > TOTAL_LIMIT_BYTES; + const lazyLoading = checkLazyLoading(); + + const sizesPassed = overBudgetFiles.length === 0 && !totalOverBudget; + const lazyPassed = lazyLoading === null || lazyLoading.passed; + + return { + files, + totalBytes, + totalLimitBytes: TOTAL_LIMIT_BYTES, + perFileLimitBytes: PER_FILE_LIMIT_BYTES, + overBudgetFiles, + totalOverBudget, + lazyLoading, + passed: sizesPassed && lazyPassed, + }; +} + +// โ”€โ”€ Output formatting โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const kb = bytes / 1024; + if (kb < 1024) return `${kb.toFixed(1)} KB`; + const mb = kb / 1024; + return `${mb.toFixed(2)} MB`; +} + +function makeBar(value: number, max: number, width = 20): string { + const ratio = Math.min(value / max, 1); + const filled = Math.round(ratio * width); + const empty = width - filled; + const color = ratio > 1 ? '\x1b[31m' : ratio > 0.8 ? '\x1b[33m' : '\x1b[32m'; + return `${color}[${'โ–ˆ'.repeat(filled)}${'โ–‘'.repeat(empty)}]\x1b[0m`; +} + +function printReport(report: SizeCheckReport): void { + console.log('\n\x1b[1mโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\x1b[0m'); + console.log('\x1b[1m i18n Translation Bundle Size Report\x1b[0m'); + console.log('\x1b[1mโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\x1b[0m\n'); + + // โ”€โ”€ Per-namespace size table โ”€โ”€ + console.log('\x1b[1mโ”€โ”€ Size by Namespace โ”€โ”€\x1b[0m\n'); + + // Header + const langHeader = LANGUAGES.map((l) => l.padStart(8)).join(''); + console.log(` ${'namespace'.padEnd(16)}${langHeader}`); + console.log(` ${'โ”€'.repeat(16)}${'โ”€'.repeat(LANGUAGES.length * 8)}`); + + for (const ns of NAMESPACES) { + const nsFiles = report.files.filter((f) => f.namespace === ns); + const cells = LANGUAGES.map((lang) => { + const file = nsFiles.find((f) => f.language === lang); + if (!file || file.bytes === 0) return ' - '; + const kb = (file.bytes / 1024).toFixed(0); + const color = file.overBudget ? '\x1b[31m' : ''; + const reset = file.overBudget ? '\x1b[0m' : ''; + const marker = file.overBudget ? '!' : ' '; + return `${color}${(kb + 'K').padStart(7)}${marker}${reset}`; + }).join(''); + console.log(` ${ns.padEnd(16)}${cells}`); + } + + // โ”€โ”€ Totals per language โ”€โ”€ + console.log(` ${'โ”€'.repeat(16)}${'โ”€'.repeat(LANGUAGES.length * 8)}`); + const langTotals = LANGUAGES.map((lang) => { + const total = report.files + .filter((f) => f.language === lang) + .reduce((sum, f) => sum + f.bytes, 0); + const kb = (total / 1024).toFixed(0); + return (kb + 'K').padStart(8); + }).join(''); + console.log(` ${'TOTAL'.padEnd(16)}${langTotals}`); + + // โ”€โ”€ Overall totals โ”€โ”€ + console.log(`\n\x1b[1mโ”€โ”€ Budget Summary โ”€โ”€\x1b[0m\n`); + + const totalBar = makeBar(report.totalBytes, report.totalLimitBytes); + const totalColor = report.totalOverBudget ? '\x1b[31m' : '\x1b[32m'; + console.log( + ` Total: ${totalBar} ${totalColor}${formatBytes(report.totalBytes)}\x1b[0m / ${formatBytes(report.totalLimitBytes)}` + ); + console.log(` Per-file: max ${formatBytes(report.perFileLimitBytes)}`); + console.log( + ` Files: ${report.files.length} (${LANGUAGES.length} languages ร— ${NAMESPACES.length} namespaces)` + ); + + // โ”€โ”€ Over-budget files โ”€โ”€ + if (report.overBudgetFiles.length > 0) { + console.log(`\n\x1b[31mโ”€โ”€ Over-Budget Files โ”€โ”€\x1b[0m\n`); + for (const f of report.overBudgetFiles) { + const excess = f.bytes - PER_FILE_LIMIT_BYTES; + console.log( + ` \x1b[31mโœ—\x1b[0m ${f.language}/${f.namespace}.json: ${formatBytes(f.bytes)} (${formatBytes(excess)} over limit)` + ); + } + } + + // โ”€โ”€ Lazy loading โ”€โ”€ + if (report.lazyLoading) { + console.log(`\n\x1b[1mโ”€โ”€ Lazy Loading Verification โ”€โ”€\x1b[0m\n`); + + const ll = report.lazyLoading; + + if (ll.englishBundled) { + console.log(' \x1b[32mโœ“\x1b[0m English bundled statically (no separate locale chunk)'); + } else { + console.log( + ' \x1b[31mโœ—\x1b[0m English has a separate locale chunk โ€” should be bundled statically' + ); + } + + if (ll.missingLanguages.length === 0) { + console.log(` \x1b[32mโœ“\x1b[0m ${ll.localeChunks.length} lazy-loaded locale chunks found`); + } else { + console.log( + ` \x1b[31mโœ—\x1b[0m Missing locale chunks for: ${ll.missingLanguages.join(', ')}` + ); + } + + // Show chunk sizes + if (ll.localeChunks.length > 0) { + console.log(''); + for (const chunk of ll.localeChunks.sort()) { + const chunkPath = path.join(RENDERER_ASSETS_DIR, chunk); + const size = fs.statSync(chunkPath).size; + console.log(` ${chunk.padEnd(40)} ${formatBytes(size)}`); + } + } + } else { + console.log(`\n\x1b[2mโ”€โ”€ Lazy Loading Verification โ”€โ”€\x1b[0m\n`); + console.log(' \x1b[33mโš \x1b[0m Skipped โ€” no build output found. Run `npm run build` first.'); + } + + // โ”€โ”€ Final result โ”€โ”€ + console.log(`\n\x1b[1mโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\x1b[0m`); + if (report.passed) { + console.log('\x1b[32mโœ“ All i18n bundle size checks passed!\x1b[0m\n'); + } else { + const reasons: string[] = []; + if (report.overBudgetFiles.length > 0) { + reasons.push( + `${report.overBudgetFiles.length} file(s) over ${formatBytes(PER_FILE_LIMIT_BYTES)} limit` + ); + } + if (report.totalOverBudget) { + reasons.push( + `total size ${formatBytes(report.totalBytes)} exceeds ${formatBytes(TOTAL_LIMIT_BYTES)} budget` + ); + } + if (report.lazyLoading && !report.lazyLoading.passed) { + reasons.push('lazy loading misconfigured'); + } + console.log(`\x1b[31mโœ— Failed: ${reasons.join('; ')}\x1b[0m\n`); + } +} + +// โ”€โ”€ Main โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function main(): void { + const args = process.argv.slice(2); + const jsonMode = args.includes('--json'); + + const report = runCheck(); + + if (jsonMode) { + // Strip full paths for cleaner JSON โ€” use relative paths + const cleanFiles = report.files.map((f) => ({ + ...f, + path: `${f.language}/${f.namespace}.json`, + })); + const cleanOverBudget = report.overBudgetFiles.map((f) => ({ + ...f, + path: `${f.language}/${f.namespace}.json`, + })); + console.log( + JSON.stringify({ ...report, files: cleanFiles, overBudgetFiles: cleanOverBudget }, null, 2) + ); + } else { + printReport(report); + } + + if (!report.passed) { + process.exit(1); + } +} + +// Run main() only when executed directly +const isDirectRun = + process.argv[1]?.endsWith('i18n-size-check.ts') || process.argv[1]?.includes('i18n-size-check'); +if (isDirectRun) { + main(); +} + +export { checkFileSizes, checkLazyLoading, runCheck, PER_FILE_LIMIT_BYTES, TOTAL_LIMIT_BYTES }; diff --git a/scripts/i18n-validate.ts b/scripts/i18n-validate.ts new file mode 100644 index 0000000000..350164dca5 --- /dev/null +++ b/scripts/i18n-validate.ts @@ -0,0 +1,633 @@ +/** + * i18n Translation Validation Script + * + * Validates all translation files against English (source of truth). + * Checks for: missing keys, orphaned keys, interpolation variable mismatches, + * pluralization completeness, and JSON syntax errors. + * + * Handles both i18next plural styles: + * - v4 CLDR: key_one, key_other (+ _zero, _two, _few, _many for Arabic) + * - v3 legacy: key, key_plural + * + * Usage: + * npx tsx scripts/i18n-validate.ts + * npx tsx scripts/i18n-validate.ts --json # Machine-readable output + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// โ”€โ”€ Configuration โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const ROOT = path.resolve(__dirname, '..'); +const LOCALES_DIR = path.join(ROOT, 'src/shared/i18n/locales'); + +const LANGUAGES = ['en', 'es', 'fr', 'de', 'zh', 'hi', 'ar', 'bn', 'pt'] as const; +type Language = (typeof LANGUAGES)[number]; + +const NAMESPACES = [ + 'common', + 'settings', + 'modals', + 'menus', + 'notifications', + 'accessibility', + 'shortcuts', +] as const; + +/** CLDR plural suffixes used by i18next v4 */ +const CLDR_SUFFIXES = ['_zero', '_one', '_two', '_few', '_many', '_other'] as const; + +/** All recognized plural suffixes (CLDR + legacy _plural) */ +const ALL_PLURAL_SUFFIXES = [...CLDR_SUFFIXES, '_plural'] as const; + +/** + * Required CLDR plural forms per language (cardinal rules). + * i18next v21+ uses Intl.PluralRules under the hood. + */ +const REQUIRED_PLURAL_FORMS: Record = { + en: ['one', 'other'], + es: ['one', 'other'], + fr: ['one', 'other'], + de: ['one', 'other'], + zh: ['other'], + hi: ['one', 'other'], + ar: ['zero', 'one', 'two', 'few', 'many', 'other'], + bn: ['one', 'other'], + pt: ['one', 'other'], +}; + +/** + * Plural forms where interpolation variables may be omitted intentionally. + * + * _zero: "no items" โ€” count/total are meaningless when quantity is zero. + * _one: "one item" โ€” count is expressed as a word, not a number. + * _two: "two items" โ€” count is expressed as a word (Arabic dual form). + * + * Only {{count}} and count-like variables (e.g. {{total}}) are exempt. + * Other variables (e.g. {{name}}) must still be present. + */ +const IMPLICIT_COUNT_FORMS = new Set(['zero', 'one', 'two']); +const COUNT_LIKE_VARS = new Set(['count', 'total']); + +// โ”€โ”€ Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +interface Issue { + type: 'missing' | 'orphaned' | 'interpolation' | 'plural' | 'syntax'; + namespace: string; + key: string; + detail: string; +} + +interface LanguageReport { + language: string; + totalEnglishKeys: number; + presentKeys: number; + completionPercent: number; + issues: Issue[]; + syntaxErrors: string[]; +} + +type PluralStyle = 'v4' | 'v3'; + +interface PluralStem { + stem: string; + style: PluralStyle; + /** The English forms present (e.g. ['one', 'other'] or ['base', 'plural']) */ + enForms: string[]; +} + +// โ”€โ”€ Utility functions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** Flatten a nested JSON object into a Map of dot-notated keys โ†’ string values */ +function flattenKeys(obj: Record, prefix = ''): Map { + const result = new Map(); + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + for (const [k, v] of flattenKeys(value as Record, fullKey)) { + result.set(k, v); + } + } else { + result.set(fullKey, String(value)); + } + } + return result; +} + +/** Extract {{var}} interpolation variable names from a translation value */ +function extractInterpolationVars(value: string): Set { + const vars = new Set(); + const regex = /\{\{(\w+)\}\}/g; + let match: RegExpExecArray | null; + while ((match = regex.exec(value)) !== null) { + vars.add(match[1]); + } + return vars; +} + +/** Get the last segment of a dot-notated key */ +function lastSegment(key: string): string { + return key.includes('.') ? key.split('.').pop()! : key; +} + +/** + * Detect confirmed plural stems from English keys. + * + * A stem is confirmed plural only if English has 2+ forms for it: + * - v4: stem_one + stem_other (and possibly _zero, _two, _few, _many) + * - v3: stem (base) + stem_plural + * + * This prevents false positives like "meta_enter_to_send_other" where + * "_other" means "other platforms", not the plural "other" category. + */ +function detectPluralStems(enKeys: Map): Map { + // Collect CLDR-suffix candidates: stem โ†’ set of forms + const cldrCandidates = new Map>(); + for (const key of enKeys.keys()) { + const seg = lastSegment(key); + for (const suffix of CLDR_SUFFIXES) { + if (seg.endsWith(suffix)) { + const stem = key.slice(0, key.length - suffix.length); + if (!cldrCandidates.has(stem)) cldrCandidates.set(stem, new Set()); + cldrCandidates.get(stem)!.add(suffix.slice(1)); + break; + } + } + } + + const result = new Map(); + + // v4 stems: must have 2+ CLDR forms in English + for (const [stem, forms] of cldrCandidates) { + if (forms.size >= 2) { + result.set(stem, { stem, style: 'v4', enForms: [...forms] }); + } + } + + // v3 stems: base key + key_plural both exist, and stem not already detected as v4 + for (const key of enKeys.keys()) { + const seg = lastSegment(key); + if (seg.endsWith('_plural')) { + const stem = key.slice(0, key.length - '_plural'.length); + if (enKeys.has(stem) && !result.has(stem)) { + result.set(stem, { stem, style: 'v3', enForms: ['base', 'plural'] }); + } + } + } + + return result; +} + +/** + * Get all keys that belong to a plural stem (in any language). + * For v4: stem_zero, stem_one, ... stem_other + * For v3: stem, stem_plural + */ +function getPluralKeys(stem: string, style: PluralStyle): string[] { + if (style === 'v4') { + return CLDR_SUFFIXES.map((s) => `${stem}${s}`); + } else { + return [stem, `${stem}_plural`]; + } +} + +// โ”€โ”€ Core validation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function loadNamespace( + lang: string, + ns: string +): { data: Map; syntaxError: string | null } { + const filePath = path.join(LOCALES_DIR, lang, `${ns}.json`); + if (!fs.existsSync(filePath)) { + return { data: new Map(), syntaxError: `File not found: ${filePath}` }; + } + + const raw = fs.readFileSync(filePath, 'utf-8'); + try { + const parsed = JSON.parse(raw); + return { data: flattenKeys(parsed), syntaxError: null }; + } catch (e) { + return { + data: new Map(), + syntaxError: `JSON syntax error in ${lang}/${ns}.json: ${(e as Error).message}`, + }; + } +} + +function validateLanguage( + lang: Language, + englishData: Map> +): LanguageReport { + const issues: Issue[] = []; + const syntaxErrors: string[] = []; + let totalEnglishKeys = 0; + let presentKeys = 0; + + for (const ns of NAMESPACES) { + const enKeys = englishData.get(ns)!; + const { data: langKeys, syntaxError } = loadNamespace(lang, ns); + + if (syntaxError) { + syntaxErrors.push(syntaxError); + continue; + } + + // Detect plural stems from English + const enPluralStems = detectPluralStems(enKeys); + + // Build set of all English keys that belong to a plural stem + const enPluralKeySet = new Set(); + for (const [stem, info] of enPluralStems) { + for (const key of getPluralKeys(stem, info.style)) { + enPluralKeySet.add(key); + } + } + + // Build set of all language keys that belong to a known plural stem + // (including CLDR expansions of v3 stems) + const langPluralKeySet = new Set(); + for (const [stem] of enPluralStems) { + // Both v3 and v4 keys for this stem + for (const s of CLDR_SUFFIXES) { + langPluralKeySet.add(`${stem}${s}`); + } + langPluralKeySet.add(stem); + langPluralKeySet.add(`${stem}_plural`); + } + + // Also detect plural stems unique to this language (Arabic may add new ones) + const langOnlyPluralStems = new Map>(); + for (const key of langKeys.keys()) { + const seg = lastSegment(key); + for (const suffix of ALL_PLURAL_SUFFIXES) { + if (seg.endsWith(suffix)) { + const stem = key.slice(0, key.length - suffix.length); + if (!enPluralStems.has(stem)) { + if (!langOnlyPluralStems.has(stem)) langOnlyPluralStems.set(stem, new Set()); + langOnlyPluralStems.get(stem)!.add(suffix.slice(1)); + } + langPluralKeySet.add(key); + break; + } + } + } + + // Detect "language-expanded plurals": English has a non-plural key with + // {{count}} and the language expanded it into proper CLDR plural forms. + // This is valid i18next behavior โ€” the CLDR forms take priority when + // count is passed. Don't flag these as missing/orphaned. + const langExpandedStems = new Set(); + for (const [stem, forms] of langOnlyPluralStems) { + if (forms.size >= 2) { + const enBaseValue = enKeys.get(stem); + if (enBaseValue && extractInterpolationVars(enBaseValue).has('count')) { + langExpandedStems.add(stem); + // Add all CLDR keys for this stem to the plural key set + for (const s of CLDR_SUFFIXES) { + langPluralKeySet.add(`${stem}${s}`); + } + langPluralKeySet.add(stem); + } + } + } + + // โ”€โ”€ 1. Check non-plural keys: missing and orphaned โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + for (const key of enKeys.keys()) { + if (enPluralKeySet.has(key)) continue; // handled in plural section + + totalEnglishKeys++; + + // If the language expanded this key into CLDR plural forms, count it as present + if (langExpandedStems.has(key)) { + presentKeys++; + continue; + } + + if (langKeys.has(key)) { + presentKeys++; + } else { + issues.push({ + type: 'missing', + namespace: ns, + key, + detail: `Missing translation for "${key}"`, + }); + } + } + + // Orphaned non-plural keys + for (const key of langKeys.keys()) { + if (langPluralKeySet.has(key)) continue; + if (!enKeys.has(key)) { + issues.push({ + type: 'orphaned', + namespace: ns, + key, + detail: `Orphaned key "${key}" โ€” exists in ${lang} but not in English`, + }); + } + } + + // โ”€โ”€ 2. Pluralization completeness โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + for (const [stem, info] of enPluralStems) { + totalEnglishKeys++; // count each plural stem as one logical key + + const requiredForms = REQUIRED_PLURAL_FORMS[lang]; + const presentForms: string[] = []; + const missingForms: string[] = []; + + // For v3-style stems, language can use EITHER v3 or v4 (CLDR) style + const hasV3 = langKeys.has(stem) && langKeys.has(`${stem}_plural`); + const hasCLDR = requiredForms.some((form) => langKeys.has(`${stem}_${form}`)); + + if (info.style === 'v3' && hasV3 && !hasCLDR) { + // Language uses v3 style โ€” that's fine + presentKeys++; + } else { + // Check CLDR forms + for (const form of requiredForms) { + const pluralKey = `${stem}_${form}`; + if (langKeys.has(pluralKey)) { + presentForms.push(form); + } else { + missingForms.push(form); + } + } + + if (presentForms.length > 0) { + presentKeys++; + } + + if (missingForms.length > 0) { + issues.push({ + type: 'plural', + namespace: ns, + key: `${stem}_*`, + detail: `Missing plural form(s): ${missingForms.map((f) => `_${f}`).join(', ')} (has: ${presentForms.map((f) => `_${f}`).join(', ') || 'none'})`, + }); + } + } + } + + // Orphaned plural stems (exist in language but not English) + for (const [stem, forms] of langOnlyPluralStems) { + if (forms.size >= 2) { + // Skip language-expanded plurals (valid CLDR expansion of English base key) + if (langExpandedStems.has(stem)) continue; + + const orphanedKeys = [...langKeys.keys()] + .filter((k) => { + const seg = lastSegment(k); + return ALL_PLURAL_SUFFIXES.some( + (s) => seg.endsWith(s) && k.slice(0, k.length - s.length) === stem + ); + }) + .join(', '); + issues.push({ + type: 'orphaned', + namespace: ns, + key: `${stem}_*`, + detail: `Orphaned plural stem "${stem}" โ€” exists in ${lang} but not in English (keys: ${orphanedKeys})`, + }); + } + } + + // โ”€โ”€ 3. Interpolation variable checks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + for (const [key, enValue] of enKeys) { + if (enPluralKeySet.has(key)) continue; // handled below + + const langValue = langKeys.get(key); + if (!langValue) continue; + + const enVars = extractInterpolationVars(enValue); + const langVars = extractInterpolationVars(langValue); + + if (enVars.size === 0 && langVars.size === 0) continue; + + for (const v of enVars) { + if (!langVars.has(v)) { + issues.push({ + type: 'interpolation', + namespace: ns, + key, + detail: `Missing interpolation variable "{{${v}}}" in ${lang} translation`, + }); + } + } + + for (const v of langVars) { + if (!enVars.has(v)) { + issues.push({ + type: 'interpolation', + namespace: ns, + key, + detail: `Extra interpolation variable "{{${v}}}" in ${lang} โ€” not in English source`, + }); + } + } + } + + // Interpolation checks for plural keys + for (const [stem, info] of enPluralStems) { + // Get reference interpolation vars from any English form + let enVars = new Set(); + if (info.style === 'v3') { + const baseVal = enKeys.get(stem); + if (baseVal) enVars = extractInterpolationVars(baseVal); + } else { + for (const suffix of CLDR_SUFFIXES) { + const val = enKeys.get(`${stem}${suffix}`); + if (val) { + enVars = extractInterpolationVars(val); + break; + } + } + } + + if (enVars.size === 0) continue; + + // Check all language plural forms + const keysToCheck = [ + ...CLDR_SUFFIXES.map((s) => ({ key: `${stem}${s}`, form: s.slice(1) })), + { key: stem, form: 'base' }, + { key: `${stem}_plural`, form: 'plural' }, + ]; + + for (const { key, form } of keysToCheck) { + const langValue = langKeys.get(key); + if (!langValue) continue; + + const langVars = extractInterpolationVars(langValue); + + for (const v of enVars) { + if (!langVars.has(v)) { + // Skip count-like variables for _zero, _one, _two forms + // where the quantity is expressed as a word (e.g. Arabic) + if (COUNT_LIKE_VARS.has(v) && IMPLICIT_COUNT_FORMS.has(form)) continue; + + issues.push({ + type: 'interpolation', + namespace: ns, + key, + detail: `Missing interpolation variable "{{${v}}}" in ${lang} plural form (_${form})`, + }); + } + } + } + } + } + + const completionPercent = + totalEnglishKeys > 0 ? Math.round((presentKeys / totalEnglishKeys) * 1000) / 10 : 100; + + return { + language: lang, + totalEnglishKeys, + presentKeys, + completionPercent, + issues, + syntaxErrors, + }; +} + +// โ”€โ”€ Output formatting โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function printReport(reports: LanguageReport[]): void { + let totalIssues = 0; + + console.log('\n\x1b[1mโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\x1b[0m'); + console.log('\x1b[1m i18n Translation Validation Report\x1b[0m'); + console.log('\x1b[1mโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\x1b[0m\n'); + + // Summary table + console.log('\x1b[1mโ”€โ”€ Completion Summary โ”€โ”€\x1b[0m\n'); + for (const report of reports) { + const bar = makeProgressBar(report.completionPercent); + const color = + report.completionPercent === 100 + ? '\x1b[32m' + : report.completionPercent >= 90 + ? '\x1b[33m' + : '\x1b[31m'; + const issueCount = report.issues.length + report.syntaxErrors.length; + totalIssues += issueCount; + const issueStr = + issueCount > 0 ? ` \x1b[31m${issueCount} issue(s)\x1b[0m` : ' \x1b[32m\u2713\x1b[0m'; + console.log( + ` ${report.language.padEnd(4)} ${bar} ${color}${report.completionPercent.toFixed(1)}%\x1b[0m (${report.presentKeys}/${report.totalEnglishKeys})${issueStr}` + ); + } + + // Per-language details + for (const report of reports) { + if (report.issues.length === 0 && report.syntaxErrors.length === 0) continue; + + console.log(`\n\x1b[1m\u2500\u2500 ${report.language} \u2500\u2500\x1b[0m`); + + for (const err of report.syntaxErrors) { + console.log(` \x1b[31m[SYNTAX]\x1b[0m ${err}`); + } + + // Group issues by type + const byType = new Map(); + for (const issue of report.issues) { + const list = byType.get(issue.type) || []; + list.push(issue); + byType.set(issue.type, list); + } + + for (const [type, typeIssues] of byType) { + const label = type.toUpperCase(); + const color = + type === 'missing' + ? '\x1b[31m' + : type === 'orphaned' + ? '\x1b[33m' + : type === 'interpolation' + ? '\x1b[35m' + : type === 'plural' + ? '\x1b[36m' + : '\x1b[31m'; + for (const issue of typeIssues) { + console.log( + ` ${color}[${label}]\x1b[0m \x1b[2m${issue.namespace}:\x1b[0m ${issue.detail}` + ); + } + } + } + + // Final summary + console.log( + `\n\x1b[1m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\x1b[0m` + ); + if (totalIssues === 0) { + console.log('\x1b[32m\u2713 All translations valid!\x1b[0m\n'); + } else { + console.log( + `\x1b[31m\u2717 ${totalIssues} total issue(s) found across all languages.\x1b[0m\n` + ); + } +} + +function makeProgressBar(percent: number): string { + const width = 20; + const filled = Math.round((percent / 100) * width); + const empty = width - filled; + return '[' + '\u2588'.repeat(filled) + '\u2591'.repeat(empty) + ']'; +} + +// โ”€โ”€ Main โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function main(): void { + const args = process.argv.slice(2); + const jsonMode = args.includes('--json'); + + // Load English as source of truth + const englishData = new Map>(); + for (const ns of NAMESPACES) { + const { data, syntaxError } = loadNamespace('en', ns); + if (syntaxError) { + console.error(`\x1b[31mFATAL: ${syntaxError}\x1b[0m`); + process.exit(1); + } + englishData.set(ns, data); + } + + // Validate each non-English language + const reports: LanguageReport[] = []; + for (const lang of LANGUAGES) { + if (lang === 'en') continue; + reports.push(validateLanguage(lang, englishData)); + } + + if (jsonMode) { + console.log(JSON.stringify({ reports }, null, 2)); + } else { + printReport(reports); + } + + // Exit with error code if issues found + const totalIssues = reports.reduce((sum, r) => sum + r.issues.length + r.syntaxErrors.length, 0); + if (totalIssues > 0) { + process.exit(1); + } +} + +// Run main() only when executed directly +const isDirectRun = + process.argv[1]?.endsWith('i18n-validate.ts') || process.argv[1]?.includes('i18n-validate'); +if (isDirectRun) { + main(); +} + +export { + flattenKeys, + extractInterpolationVars, + detectPluralStems, + validateLanguage, + loadNamespace, +}; diff --git a/src/__tests__/i18n/i18n-completeness.test.ts b/src/__tests__/i18n/i18n-completeness.test.ts new file mode 100644 index 0000000000..28eb13c838 --- /dev/null +++ b/src/__tests__/i18n/i18n-completeness.test.ts @@ -0,0 +1,108 @@ +/** + * i18n Completeness Tests + * + * Runs the same validation assertions as scripts/i18n-validate.ts + * so that CI fails if translations are incomplete, have orphaned keys, + * or are missing interpolation variables. + */ + +import { describe, it, expect } from 'vitest'; +import { + flattenKeys, + extractInterpolationVars, + loadNamespace, + validateLanguage, +} from '../../../scripts/i18n-validate'; + +const LANGUAGES = ['en', 'es', 'fr', 'de', 'zh', 'hi', 'ar', 'bn', 'pt'] as const; +type Language = (typeof LANGUAGES)[number]; + +const NAMESPACES = [ + 'common', + 'settings', + 'modals', + 'menus', + 'notifications', + 'accessibility', + 'shortcuts', +] as const; + +// Load English data once +function loadEnglishData(): Map> { + const englishData = new Map>(); + for (const ns of NAMESPACES) { + const { data, syntaxError } = loadNamespace('en', ns); + if (syntaxError) throw new Error(syntaxError); + englishData.set(ns, data); + } + return englishData; +} + +describe('i18n Translation Completeness', () => { + const englishData = loadEnglishData(); + + describe('English source files', () => { + it('loads all namespace files without syntax errors', () => { + for (const ns of NAMESPACES) { + const { syntaxError } = loadNamespace('en', ns); + expect(syntaxError).toBeNull(); + } + }); + + it('has a non-trivial number of keys', () => { + let totalKeys = 0; + for (const [, keys] of englishData) { + totalKeys += keys.size; + } + // We know there are ~2984 keys; ensure at least 2000 exist + expect(totalKeys).toBeGreaterThan(2000); + }); + }); + + describe.each(LANGUAGES.filter((l) => l !== 'en'))('%s translation', (lang) => { + it('has no syntax errors in any namespace', () => { + for (const ns of NAMESPACES) { + const { syntaxError } = loadNamespace(lang, ns); + expect(syntaxError, `Syntax error in ${lang}/${ns}.json`).toBeNull(); + } + }); + + it('passes full validation with 0 issues', () => { + const report = validateLanguage(lang as Language, englishData); + const issueDetails = report.issues + .map((i) => `[${i.type}] ${i.namespace}:${i.key} โ€” ${i.detail}`) + .join('\n'); + expect( + report.issues.length, + `${lang} has ${report.issues.length} issue(s):\n${issueDetails}` + ).toBe(0); + expect(report.syntaxErrors.length).toBe(0); + }); + + it('has 100% completion', () => { + const report = validateLanguage(lang as Language, englishData); + expect(report.completionPercent).toBe(100); + }); + }); + + describe('utility functions', () => { + it('flattenKeys handles nested objects', () => { + const result = flattenKeys({ + a: { b: { c: 'hello' } }, + d: 'world', + }); + expect(result.get('a.b.c')).toBe('hello'); + expect(result.get('d')).toBe('world'); + }); + + it('extractInterpolationVars finds all variables', () => { + const vars = extractInterpolationVars('Hello {{name}}, you have {{count}} items'); + expect(vars).toEqual(new Set(['name', 'count'])); + }); + + it('extractInterpolationVars returns empty set for plain text', () => { + const vars = extractInterpolationVars('Hello world'); + expect(vars.size).toBe(0); + }); + }); +}); diff --git a/src/__tests__/i18n/i18n-config.test.ts b/src/__tests__/i18n/i18n-config.test.ts new file mode 100644 index 0000000000..7f384833b8 --- /dev/null +++ b/src/__tests__/i18n/i18n-config.test.ts @@ -0,0 +1,310 @@ +/** + * i18n Configuration Tests + * + * Verifies that all 9 supported languages load successfully, + * fallback to English works, and namespace loading works correctly. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import { + SUPPORTED_LANGUAGES, + I18N_NAMESPACES, + LANGUAGE_NATIVE_NAMES, + RTL_LANGUAGES, +} from '../../shared/i18n/config'; + +// Import all English resources for bundled init +import commonEn from '../../shared/i18n/locales/en/common.json'; +import settingsEn from '../../shared/i18n/locales/en/settings.json'; +import modalsEn from '../../shared/i18n/locales/en/modals.json'; +import menusEn from '../../shared/i18n/locales/en/menus.json'; +import notificationsEn from '../../shared/i18n/locales/en/notifications.json'; +import accessibilityEn from '../../shared/i18n/locales/en/accessibility.json'; +import shortcutsEn from '../../shared/i18n/locales/en/shortcuts.json'; + +import commonEs from '../../shared/i18n/locales/es/common.json'; +import settingsEs from '../../shared/i18n/locales/es/settings.json'; +import modalsEs from '../../shared/i18n/locales/es/modals.json'; +import menusEs from '../../shared/i18n/locales/es/menus.json'; +import notificationsEs from '../../shared/i18n/locales/es/notifications.json'; +import accessibilityEs from '../../shared/i18n/locales/es/accessibility.json'; +import shortcutsEs from '../../shared/i18n/locales/es/shortcuts.json'; + +import commonFr from '../../shared/i18n/locales/fr/common.json'; +import settingsFr from '../../shared/i18n/locales/fr/settings.json'; +import modalsFr from '../../shared/i18n/locales/fr/modals.json'; +import menusFr from '../../shared/i18n/locales/fr/menus.json'; +import notificationsFr from '../../shared/i18n/locales/fr/notifications.json'; +import accessibilityFr from '../../shared/i18n/locales/fr/accessibility.json'; +import shortcutsFr from '../../shared/i18n/locales/fr/shortcuts.json'; + +import commonDe from '../../shared/i18n/locales/de/common.json'; +import settingsDe from '../../shared/i18n/locales/de/settings.json'; +import modalsDe from '../../shared/i18n/locales/de/modals.json'; +import menusDe from '../../shared/i18n/locales/de/menus.json'; +import notificationsDe from '../../shared/i18n/locales/de/notifications.json'; +import accessibilityDe from '../../shared/i18n/locales/de/accessibility.json'; +import shortcutsDe from '../../shared/i18n/locales/de/shortcuts.json'; + +import commonZh from '../../shared/i18n/locales/zh/common.json'; +import settingsZh from '../../shared/i18n/locales/zh/settings.json'; +import modalsZh from '../../shared/i18n/locales/zh/modals.json'; +import menusZh from '../../shared/i18n/locales/zh/menus.json'; +import notificationsZh from '../../shared/i18n/locales/zh/notifications.json'; +import accessibilityZh from '../../shared/i18n/locales/zh/accessibility.json'; +import shortcutsZh from '../../shared/i18n/locales/zh/shortcuts.json'; + +import commonHi from '../../shared/i18n/locales/hi/common.json'; +import settingsHi from '../../shared/i18n/locales/hi/settings.json'; +import modalsHi from '../../shared/i18n/locales/hi/modals.json'; +import menusHi from '../../shared/i18n/locales/hi/menus.json'; +import notificationsHi from '../../shared/i18n/locales/hi/notifications.json'; +import accessibilityHi from '../../shared/i18n/locales/hi/accessibility.json'; +import shortcutsHi from '../../shared/i18n/locales/hi/shortcuts.json'; + +import commonAr from '../../shared/i18n/locales/ar/common.json'; +import settingsAr from '../../shared/i18n/locales/ar/settings.json'; +import modalsAr from '../../shared/i18n/locales/ar/modals.json'; +import menusAr from '../../shared/i18n/locales/ar/menus.json'; +import notificationsAr from '../../shared/i18n/locales/ar/notifications.json'; +import accessibilityAr from '../../shared/i18n/locales/ar/accessibility.json'; +import shortcutsAr from '../../shared/i18n/locales/ar/shortcuts.json'; + +import commonBn from '../../shared/i18n/locales/bn/common.json'; +import settingsBn from '../../shared/i18n/locales/bn/settings.json'; +import modalsBn from '../../shared/i18n/locales/bn/modals.json'; +import menusBn from '../../shared/i18n/locales/bn/menus.json'; +import notificationsBn from '../../shared/i18n/locales/bn/notifications.json'; +import accessibilityBn from '../../shared/i18n/locales/bn/accessibility.json'; +import shortcutsBn from '../../shared/i18n/locales/bn/shortcuts.json'; + +import commonPt from '../../shared/i18n/locales/pt/common.json'; +import settingsPt from '../../shared/i18n/locales/pt/settings.json'; +import modalsPt from '../../shared/i18n/locales/pt/modals.json'; +import menusPt from '../../shared/i18n/locales/pt/menus.json'; +import notificationsPt from '../../shared/i18n/locales/pt/notifications.json'; +import accessibilityPt from '../../shared/i18n/locales/pt/accessibility.json'; +import shortcutsPt from '../../shared/i18n/locales/pt/shortcuts.json'; + +const allResources = { + en: { + common: commonEn, + settings: settingsEn, + modals: modalsEn, + menus: menusEn, + notifications: notificationsEn, + accessibility: accessibilityEn, + shortcuts: shortcutsEn, + }, + es: { + common: commonEs, + settings: settingsEs, + modals: modalsEs, + menus: menusEs, + notifications: notificationsEs, + accessibility: accessibilityEs, + shortcuts: shortcutsEs, + }, + fr: { + common: commonFr, + settings: settingsFr, + modals: modalsFr, + menus: menusFr, + notifications: notificationsFr, + accessibility: accessibilityFr, + shortcuts: shortcutsFr, + }, + de: { + common: commonDe, + settings: settingsDe, + modals: modalsDe, + menus: menusDe, + notifications: notificationsDe, + accessibility: accessibilityDe, + shortcuts: shortcutsDe, + }, + zh: { + common: commonZh, + settings: settingsZh, + modals: modalsZh, + menus: menusZh, + notifications: notificationsZh, + accessibility: accessibilityZh, + shortcuts: shortcutsZh, + }, + hi: { + common: commonHi, + settings: settingsHi, + modals: modalsHi, + menus: menusHi, + notifications: notificationsHi, + accessibility: accessibilityHi, + shortcuts: shortcutsHi, + }, + ar: { + common: commonAr, + settings: settingsAr, + modals: modalsAr, + menus: menusAr, + notifications: notificationsAr, + accessibility: accessibilityAr, + shortcuts: shortcutsAr, + }, + bn: { + common: commonBn, + settings: settingsBn, + modals: modalsBn, + menus: menusBn, + notifications: notificationsBn, + accessibility: accessibilityBn, + shortcuts: shortcutsBn, + }, + pt: { + common: commonPt, + settings: settingsPt, + modals: modalsPt, + menus: menusPt, + notifications: notificationsPt, + accessibility: accessibilityPt, + shortcuts: shortcutsPt, + }, +}; + +// Create a dedicated i18n instance for these tests so we don't conflict +// with the global mock in setup.ts +const testI18n = i18n.createInstance(); + +beforeAll(async () => { + await testI18n.use(initReactI18next).init({ + resources: allResources, + fallbackLng: 'en', + supportedLngs: [...SUPPORTED_LANGUAGES], + ns: [...I18N_NAMESPACES], + defaultNS: 'common', + interpolation: { escapeValue: false }, + react: { useSuspense: false }, + }); +}); + +describe('i18n Configuration', () => { + describe('supported languages', () => { + it('supports exactly 9 languages', () => { + expect(SUPPORTED_LANGUAGES).toHaveLength(9); + }); + + it('includes all expected language codes', () => { + const expected = ['en', 'es', 'fr', 'de', 'zh', 'hi', 'ar', 'bn', 'pt']; + expect([...SUPPORTED_LANGUAGES]).toEqual(expected); + }); + + it('has native names for all supported languages', () => { + for (const lang of SUPPORTED_LANGUAGES) { + expect(LANGUAGE_NATIVE_NAMES[lang]).toBeDefined(); + expect(LANGUAGE_NATIVE_NAMES[lang].length).toBeGreaterThan(0); + } + }); + + it('identifies Arabic as the only RTL language', () => { + expect(RTL_LANGUAGES).toEqual(['ar']); + }); + }); + + describe('language loading', () => { + it.each([...SUPPORTED_LANGUAGES])('loads %s language successfully', (lang) => { + expect(testI18n.hasResourceBundle(lang, 'common')).toBe(true); + }); + + it.each([...SUPPORTED_LANGUAGES])('has all namespaces for %s', (lang) => { + for (const ns of I18N_NAMESPACES) { + expect(testI18n.hasResourceBundle(lang, ns)).toBe(true); + } + }); + }); + + describe('namespace loading', () => { + it('has exactly 7 namespaces', () => { + expect(I18N_NAMESPACES).toHaveLength(7); + }); + + it('includes all expected namespaces', () => { + const expected = [ + 'common', + 'settings', + 'modals', + 'menus', + 'notifications', + 'accessibility', + 'shortcuts', + ]; + expect([...I18N_NAMESPACES]).toEqual(expected); + }); + + it('defaults to common namespace', () => { + // t('save') without namespace prefix should resolve from common + const result = testI18n.t('save'); + expect(result).toBe('Save'); + }); + + it('resolves namespaced keys with colon syntax', () => { + const result = testI18n.t('common:save'); + expect(result).toBe('Save'); + }); + }); + + describe('fallback behavior', () => { + it('falls back to English for missing keys', async () => { + await testI18n.changeLanguage('es'); + // 'save' exists in Spanish, but let's test that English fallback works + // by checking a key โ€” the fallback mechanism is what matters + const result = testI18n.t('save'); + expect(result).toBeTruthy(); + expect(result).not.toBe('save'); // Should not return the raw key + }); + + it('returns English text when current language has no translation', async () => { + await testI18n.changeLanguage('en'); + const enValue = testI18n.t('save'); + + // Add a resource bundle for a fake language with missing keys + testI18n.addResourceBundle('xx', 'common', {}, true, true); + await testI18n.changeLanguage('xx'); + const xxValue = testI18n.t('save'); + + // Should fall back to English + expect(xxValue).toBe(enValue); + + // Clean up + await testI18n.changeLanguage('en'); + }); + + it('switches between languages correctly', async () => { + await testI18n.changeLanguage('en'); + expect(testI18n.t('save')).toBe('Save'); + + await testI18n.changeLanguage('es'); + expect(testI18n.t('save')).not.toBe('Save'); + expect(testI18n.t('save')).toBeTruthy(); + + // Switch back + await testI18n.changeLanguage('en'); + expect(testI18n.t('save')).toBe('Save'); + }); + }); + + describe('interpolation', () => { + it('handles {{variable}} interpolation', async () => { + await testI18n.changeLanguage('en'); + const result = testI18n.t('items_count_other', { count: 5 }); + expect(result).toBe('5 items'); + }); + + it('handles interpolation in non-English languages', async () => { + await testI18n.changeLanguage('es'); + const result = testI18n.t('items_count_other', { count: 5 }); + expect(result).toContain('5'); + await testI18n.changeLanguage('en'); + }); + }); +}); diff --git a/src/__tests__/i18n/i18n-formatters.test.ts b/src/__tests__/i18n/i18n-formatters.test.ts new file mode 100644 index 0000000000..fa45d2b1fd --- /dev/null +++ b/src/__tests__/i18n/i18n-formatters.test.ts @@ -0,0 +1,189 @@ +/** + * i18n Formatters Tests + * + * Verifies that formatRelativeTime(), formatSize(), formatCost(), and formatTokens() + * produce correct locale-aware output for spot-checked languages. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { formatSize, formatCost, formatTokens, formatRelativeTime } from '../../shared/formatters'; + +describe('i18n Formatters', () => { + describe('formatSize', () => { + it('formats bytes without decimals', () => { + expect(formatSize(500, 'en')).toBe('500 B'); + }); + + it('formats kilobytes in English', () => { + expect(formatSize(1536, 'en')).toBe('1.5 KB'); + }); + + it('formats megabytes in English', () => { + expect(formatSize(2621440, 'en')).toBe('2.5 MB'); + }); + + it('uses comma as decimal separator for German locale', () => { + const result = formatSize(1536, 'de'); + // German uses comma: "1,5 KB" + expect(result).toContain('1,5'); + expect(result).toContain('KB'); + }); + + it('uses comma as decimal separator for French locale', () => { + const result = formatSize(1536, 'fr'); + // French uses comma: "1,5 KB" + expect(result).toContain('1,5'); + expect(result).toContain('KB'); + }); + + it('formats correctly for Chinese locale', () => { + const result = formatSize(1536, 'zh'); + expect(result).toContain('1.5'); + expect(result).toContain('KB'); + }); + + it('formats correctly for Arabic locale', () => { + const result = formatSize(1536, 'ar'); + // Arabic may use different numeral systems; just verify it contains the unit + expect(result).toContain('KB'); + }); + }); + + describe('formatCost', () => { + it('formats zero cost in English', () => { + const result = formatCost(0, 'en'); + expect(result).toContain('0.00'); + }); + + it('formats normal cost in English', () => { + const result = formatCost(1.23, 'en'); + expect(result).toContain('1.23'); + }); + + it('formats sub-penny amounts with < prefix', () => { + const result = formatCost(0.005, 'en'); + expect(result).toContain('<'); + expect(result).toContain('0.01'); + }); + + it('formats cost in German locale with comma separator', () => { + const result = formatCost(1.23, 'de'); + // German typically formats as "1,23 $" + expect(result).toContain('1,23'); + }); + + it('formats cost in French locale', () => { + const result = formatCost(1.23, 'fr'); + // French uses comma and may put currency after number + expect(result).toContain('1,23'); + }); + + it('formats cost in Chinese locale', () => { + const result = formatCost(1.23, 'zh'); + // Chinese uses period as decimal separator + expect(result).toContain('1.23'); + }); + }); + + describe('formatTokens', () => { + it('returns raw number for small token counts', () => { + expect(formatTokens(500, 'en')).toBe('500'); + }); + + it('formats large token counts with ~ prefix in English', () => { + const result = formatTokens(1500, 'en'); + expect(result).toMatch(/^~/); + expect(result).toMatch(/[12]K/i); + }); + + it('formats tokens in German locale', () => { + const result = formatTokens(1500, 'de'); + expect(result).toMatch(/^~/); + }); + + it('formats tokens in Chinese locale', () => { + const result = formatTokens(1500, 'zh'); + expect(result).toMatch(/^~/); + }); + + it('formats tokens in Arabic locale', () => { + const result = formatTokens(1500, 'ar'); + expect(result).toMatch(/^~/); + }); + }); + + describe('formatRelativeTime', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-13T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('formats "now" for recent timestamps in English', () => { + const result = formatRelativeTime(Date.now() - 10000, 'en'); // 10 seconds ago + // Should be something like "this second" or "now" + expect(result).toBeTruthy(); + expect(typeof result).toBe('string'); + }); + + it('formats minutes ago in English', () => { + const fiveMinutesAgo = Date.now() - 5 * 60 * 1000; + const result = formatRelativeTime(fiveMinutesAgo, 'en'); + expect(result).toContain('5'); + expect(result.toLowerCase()).toContain('minute'); + }); + + it('formats minutes ago in Spanish', () => { + const fiveMinutesAgo = Date.now() - 5 * 60 * 1000; + const result = formatRelativeTime(fiveMinutesAgo, 'es'); + expect(result).toContain('5'); + // Spanish: "hace 5 minutos" + expect(result.toLowerCase()).toContain('minuto'); + }); + + it('formats hours ago in English', () => { + const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000; + const result = formatRelativeTime(twoHoursAgo, 'en'); + expect(result).toContain('2'); + expect(result.toLowerCase()).toContain('hour'); + }); + + it('formats hours ago in Chinese', () => { + const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000; + const result = formatRelativeTime(twoHoursAgo, 'zh'); + expect(result).toBeTruthy(); + // Chinese: "2ๅฐๆ—ถๅ‰" + expect(result).toContain('2'); + }); + + it('formats days ago in German', () => { + const threeDaysAgo = Date.now() - 3 * 24 * 60 * 60 * 1000; + const result = formatRelativeTime(threeDaysAgo, 'de'); + expect(result).toBeTruthy(); + expect(result).toContain('3'); + }); + + it('falls back to formatted date for older timestamps', () => { + const twoWeeksAgo = Date.now() - 14 * 24 * 60 * 60 * 1000; + const result = formatRelativeTime(twoWeeksAgo, 'en'); + // Should be a date string like "Feb 27" + expect(result).toBeTruthy(); + expect(typeof result).toBe('string'); + }); + + it('accepts Date objects', () => { + const date = new Date(Date.now() - 5 * 60 * 1000); + const result = formatRelativeTime(date, 'en'); + expect(result).toContain('5'); + }); + + it('accepts ISO date strings', () => { + const isoString = new Date(Date.now() - 5 * 60 * 1000).toISOString(); + const result = formatRelativeTime(isoString, 'en'); + expect(result).toContain('5'); + }); + }); +}); diff --git a/src/__tests__/i18n/i18n-locale-audit.test.ts b/src/__tests__/i18n/i18n-locale-audit.test.ts new file mode 100644 index 0000000000..e4a686adfe --- /dev/null +++ b/src/__tests__/i18n/i18n-locale-audit.test.ts @@ -0,0 +1,104 @@ +/** + * i18n Locale Audit Tests + * + * Codebase guardrail: verifies that all toLocaleDateString, toLocaleTimeString, + * and date-related toLocaleString calls pass an explicit locale (typically + * getActiveLocale()) rather than relying on browser defaults or using empty + * arrays / undefined. + * + * Also verifies that document.documentElement.lang is set by the i18n system + * so that Intl APIs resolve the correct locale. + */ + +import { describe, it, expect } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; + +// Recursively collect all .ts/.tsx files under a directory +function collectFiles(dir: string, extensions: string[]): string[] { + const results: string[] = []; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory() && entry.name !== 'node_modules' && entry.name !== '__tests__') { + results.push(...collectFiles(fullPath, extensions)); + } else if (entry.isFile() && extensions.some((ext) => entry.name.endsWith(ext))) { + results.push(fullPath); + } + } + return results; +} + +const RENDERER_DIR = path.resolve(__dirname, '../../renderer'); + +// Patterns that indicate a missing or incorrect locale argument +const BAD_LOCALE_PATTERNS = [ + // toLocaleDateString with empty array or undefined + { pattern: /\.toLocaleDateString\(\[\]/g, desc: 'toLocaleDateString([])' }, + { pattern: /\.toLocaleDateString\(undefined/g, desc: 'toLocaleDateString(undefined)' }, + { pattern: /\.toLocaleDateString\(\)/g, desc: 'toLocaleDateString() with no arguments' }, + // toLocaleTimeString with empty array or undefined + { pattern: /\.toLocaleTimeString\(\[\]/g, desc: 'toLocaleTimeString([])' }, + { pattern: /\.toLocaleTimeString\(undefined/g, desc: 'toLocaleTimeString(undefined)' }, + { pattern: /\.toLocaleTimeString\(\)/g, desc: 'toLocaleTimeString() with no arguments' }, + // toLocaleString on dates with empty array or undefined (but not on numbers) + { pattern: /\.toLocaleString\(\[\]/g, desc: 'toLocaleString([])' }, + { pattern: /\.toLocaleString\(undefined/g, desc: 'toLocaleString(undefined)' }, +]; + +describe('i18n Locale Audit', () => { + const rendererFiles = collectFiles(RENDERER_DIR, ['.ts', '.tsx']); + + describe('no hardcoded or missing locale arguments in date/time formatting', () => { + for (const { pattern, desc } of BAD_LOCALE_PATTERNS) { + it(`should have no ${desc} calls in renderer`, () => { + const violations: string[] = []; + for (const filePath of rendererFiles) { + const content = fs.readFileSync(filePath, 'utf-8'); + const matches = content.match(pattern); + if (matches) { + const relativePath = path.relative(RENDERER_DIR, filePath); + violations.push(`${relativePath}: ${matches.length} occurrence(s)`); + } + } + expect(violations, `Found ${desc} in:\n${violations.join('\n')}`).toHaveLength(0); + }); + } + }); + + describe('document.documentElement.lang is set by i18n system', () => { + it('should set document.documentElement.lang in settingsStore', () => { + const settingsStorePath = path.resolve(RENDERER_DIR, 'stores/settingsStore.ts'); + const content = fs.readFileSync(settingsStorePath, 'utf-8'); + expect(content).toContain('document.documentElement.lang'); + }); + }); + + describe('getActiveLocale is used for explicit locale passing', () => { + it('should import getActiveLocale in files that call toLocaleDateString with locale', () => { + // Spot-check key files that were updated + const keyFiles = [ + 'components/History/ActivityGraph.tsx', + 'components/GroupChatHistoryPanel.tsx', + 'components/BatchRunnerModal.tsx', + 'components/TerminalOutput.tsx', + 'components/GitLogViewer.tsx', + ]; + + for (const relPath of keyFiles) { + const filePath = path.resolve(RENDERER_DIR, relPath); + if (fs.existsSync(filePath)) { + const content = fs.readFileSync(filePath, 'utf-8'); + const hasLocaleDateCalls = + content.includes('.toLocaleDateString(') || content.includes('.toLocaleTimeString('); + if (hasLocaleDateCalls) { + expect( + content, + `${relPath} uses date formatting but doesn't import getActiveLocale` + ).toContain('getActiveLocale'); + } + } + } + }); + }); +}); diff --git a/src/__tests__/i18n/i18n-pluralization.test.ts b/src/__tests__/i18n/i18n-pluralization.test.ts new file mode 100644 index 0000000000..e40e08660f --- /dev/null +++ b/src/__tests__/i18n/i18n-pluralization.test.ts @@ -0,0 +1,186 @@ +/** + * i18n Pluralization Tests + * + * Verifies pluralization works correctly for: + * - English (2 forms: one, other) + * - French (singular at 0 and 1) + * - Arabic (6 CLDR forms: zero, one, two, few, many, other) + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; + +import commonEn from '../../shared/i18n/locales/en/common.json'; +import commonFr from '../../shared/i18n/locales/fr/common.json'; +import commonAr from '../../shared/i18n/locales/ar/common.json'; + +const testI18n = i18n.createInstance(); + +beforeAll(async () => { + await testI18n.use(initReactI18next).init({ + resources: { + en: { common: commonEn }, + fr: { common: commonFr }, + ar: { common: commonAr }, + }, + fallbackLng: 'en', + supportedLngs: ['en', 'fr', 'ar'], + ns: ['common'], + defaultNS: 'common', + interpolation: { escapeValue: false }, + react: { useSuspense: false }, + }); +}); + +describe('i18n Pluralization', () => { + describe('English (2 forms: one, other)', () => { + beforeAll(async () => { + await testI18n.changeLanguage('en'); + }); + + it('uses singular form for count=1', () => { + const result = testI18n.t('items_count', { count: 1 }); + expect(result).toBe('1 item'); + }); + + it('uses plural form for count=0', () => { + const result = testI18n.t('items_count', { count: 0 }); + expect(result).toBe('0 items'); + }); + + it('uses plural form for count=5', () => { + const result = testI18n.t('items_count', { count: 5 }); + expect(result).toBe('5 items'); + }); + + it('uses plural form for count=100', () => { + const result = testI18n.t('items_count', { count: 100 }); + expect(result).toBe('100 items'); + }); + + it('handles agents_running pluralization', () => { + expect(testI18n.t('agents_running', { count: 1 })).toBe('1 agent running'); + expect(testI18n.t('agents_running', { count: 3 })).toBe('3 agents running'); + }); + }); + + describe('French (singular at 0 and 1)', () => { + beforeAll(async () => { + await testI18n.changeLanguage('fr'); + }); + + it('uses singular form for count=0 (French treats 0 as singular)', () => { + const result = testI18n.t('items_count', { count: 0 }); + // French CLDR: 0 is "one" category + expect(result).toBeTruthy(); + expect(typeof result).toBe('string'); + }); + + it('uses singular form for count=1', () => { + const result = testI18n.t('items_count', { count: 1 }); + expect(result).toBeTruthy(); + expect(result).toContain('1'); + }); + + it('uses plural form for count=2', () => { + const result = testI18n.t('items_count', { count: 2 }); + expect(result).toBeTruthy(); + expect(result).toContain('2'); + }); + + it('uses plural form for count=5', () => { + const result = testI18n.t('items_count', { count: 5 }); + expect(result).toBeTruthy(); + expect(result).toContain('5'); + }); + + it('singular and plural forms are different for count>1', () => { + const singular = testI18n.t('items_count', { count: 1 }); + const plural = testI18n.t('items_count', { count: 5 }); + // The text around the number should differ (รฉlรฉment vs รฉlรฉments) + const singularWithoutNum = singular.replace(/\d+/, ''); + const pluralWithoutNum = plural.replace(/\d+/, ''); + expect(singularWithoutNum).not.toBe(pluralWithoutNum); + }); + }); + + describe('Arabic (6 CLDR forms)', () => { + beforeAll(async () => { + await testI18n.changeLanguage('ar'); + }); + + it('uses zero form for count=0', () => { + const result = testI18n.t('items_count', { count: 0 }); + expect(result).toBeTruthy(); + expect(typeof result).toBe('string'); + // Arabic zero form: "ู„ุง ุนู†ุงุตุฑ" + expect(result.length).toBeGreaterThan(0); + }); + + it('uses one form for count=1', () => { + const result = testI18n.t('items_count', { count: 1 }); + expect(result).toBeTruthy(); + // Arabic one form: "ุนู†ุตุฑ ูˆุงุญุฏ" + expect(result.length).toBeGreaterThan(0); + }); + + it('uses two form for count=2', () => { + const result = testI18n.t('items_count', { count: 2 }); + expect(result).toBeTruthy(); + // Arabic two form (dual): "ุนู†ุตุฑุงู†" + expect(result.length).toBeGreaterThan(0); + }); + + it('uses few form for count=3', () => { + const result = testI18n.t('items_count', { count: 3 }); + expect(result).toBeTruthy(); + expect(result).toContain('3'); + // Arabic few form (3-10): "3 ุนู†ุงุตุฑ" + }); + + it('uses many form for count=11', () => { + const result = testI18n.t('items_count', { count: 11 }); + expect(result).toBeTruthy(); + expect(result).toContain('11'); + // Arabic many form (11-99): "11 ุนู†ุตุฑู‹ุง" + }); + + it('uses other form for count=100', () => { + const result = testI18n.t('items_count', { count: 100 }); + expect(result).toBeTruthy(); + expect(result).toContain('100'); + // Arabic other form (100+): "100 ุนู†ุตุฑ" + }); + + it('all 6 Arabic plural forms produce different results where applicable', () => { + const zero = testI18n.t('items_count', { count: 0 }); + const one = testI18n.t('items_count', { count: 1 }); + const two = testI18n.t('items_count', { count: 2 }); + const few = testI18n.t('items_count', { count: 3 }); + const many = testI18n.t('items_count', { count: 11 }); + const other = testI18n.t('items_count', { count: 100 }); + + // Each form should produce a non-empty result + const forms = [zero, one, two, few, many, other]; + for (const form of forms) { + expect(form).toBeTruthy(); + expect(form.length).toBeGreaterThan(0); + } + + // At least the zero, one, two forms should be distinct from each other + expect(zero).not.toBe(one); + expect(one).not.toBe(two); + expect(zero).not.toBe(two); + }); + + it('handles agents_running with all 6 forms', () => { + const results = [0, 1, 2, 3, 11, 100].map((count) => testI18n.t('agents_running', { count })); + // All should produce non-empty strings + for (const result of results) { + expect(result).toBeTruthy(); + expect(result.length).toBeGreaterThan(0); + } + }); + }); +}); diff --git a/src/__tests__/i18n/i18n-rtl-coordinates.test.ts b/src/__tests__/i18n/i18n-rtl-coordinates.test.ts new file mode 100644 index 0000000000..6b181f8820 --- /dev/null +++ b/src/__tests__/i18n/i18n-rtl-coordinates.test.ts @@ -0,0 +1,166 @@ +/** + * i18n RTL Coordinate Utility Tests + * + * Verifies that the RTL-aware coordinate helpers in + * src/web/utils/rtlCoordinates.ts produce correct values for + * both LTR and RTL layout directions. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { + getDirectionalDelta, + getDirectionalTranslateX, + getDirectionalOffsetLeft, + getLogicalSide, +} from '../../web/utils/rtlCoordinates'; + +describe('RTL Coordinate Utilities', () => { + describe('getDirectionalDelta', () => { + it('returns positive delta for rightward swipe in LTR', () => { + // Swiping from x=100 to x=200 (rightward = forward in LTR) + expect(getDirectionalDelta(100, 200, 'ltr')).toBe(100); + }); + + it('returns negative delta for leftward swipe in LTR', () => { + // Swiping from x=200 to x=100 (leftward = backward in LTR) + expect(getDirectionalDelta(200, 100, 'ltr')).toBe(-100); + }); + + it('returns positive delta for leftward swipe in RTL', () => { + // Swiping from x=200 to x=100 (leftward = forward in RTL) + expect(getDirectionalDelta(200, 100, 'rtl')).toBe(100); + }); + + it('returns negative delta for rightward swipe in RTL', () => { + // Swiping from x=100 to x=200 (rightward = backward in RTL) + expect(getDirectionalDelta(100, 200, 'rtl')).toBe(-100); + }); + + it('returns zero for no movement in both directions', () => { + expect(getDirectionalDelta(150, 150, 'ltr')).toBe(0); + // RTL negation of 0 yields -0; both are == 0 + expect(getDirectionalDelta(150, 150, 'rtl')).toEqual(-0); + }); + }); + + describe('getDirectionalTranslateX', () => { + it('returns positive translateX in LTR', () => { + expect(getDirectionalTranslateX(50, 'ltr')).toBe('translateX(50px)'); + }); + + it('returns negated translateX in RTL', () => { + expect(getDirectionalTranslateX(50, 'rtl')).toBe('translateX(-50px)'); + }); + + it('handles zero offset in both directions', () => { + expect(getDirectionalTranslateX(0, 'ltr')).toBe('translateX(0px)'); + expect(getDirectionalTranslateX(0, 'rtl')).toBe('translateX(0px)'); + }); + + it('handles negative offset in LTR', () => { + expect(getDirectionalTranslateX(-30, 'ltr')).toBe('translateX(-30px)'); + }); + + it('double-negates negative offset in RTL', () => { + // -(-30) = 30 + expect(getDirectionalTranslateX(-30, 'rtl')).toBe('translateX(30px)'); + }); + }); + + describe('getDirectionalOffsetLeft', () => { + function makeElement( + offsetLeft: number, + offsetWidth: number, + parentWidth: number + ): HTMLElement { + const parent = { + offsetWidth: parentWidth, + } as HTMLElement; + + const element = { + offsetLeft, + offsetWidth, + offsetParent: parent, + } as unknown as HTMLElement; + + return element; + } + + it('returns offsetLeft directly in LTR', () => { + const el = makeElement(40, 100, 500); + expect(getDirectionalOffsetLeft(el, 'ltr')).toBe(40); + }); + + it('returns right-edge offset in RTL', () => { + // parentWidth(500) - offsetLeft(40) - elementWidth(100) = 360 + const el = makeElement(40, 100, 500); + expect(getDirectionalOffsetLeft(el, 'rtl')).toBe(360); + }); + + it('returns 0 for element flush with start edge in LTR', () => { + const el = makeElement(0, 100, 500); + expect(getDirectionalOffsetLeft(el, 'ltr')).toBe(0); + }); + + it('returns 0 for element flush with start edge in RTL', () => { + // Element at far right: offsetLeft = 400, width = 100, parent = 500 + // 500 - 400 - 100 = 0 + const el = makeElement(400, 100, 500); + expect(getDirectionalOffsetLeft(el, 'rtl')).toBe(0); + }); + + it('handles element with no offsetParent by using documentElement', () => { + const element = { + offsetLeft: 10, + offsetWidth: 50, + offsetParent: null, + } as unknown as HTMLElement; + + // Falls back to document.documentElement.offsetWidth + const docWidth = document.documentElement.offsetWidth; + expect(getDirectionalOffsetLeft(element, 'rtl')).toBe(docWidth - 10 - 50); + }); + }); + + describe('getLogicalSide', () => { + it('maps start to left in LTR', () => { + expect(getLogicalSide('start', 'ltr')).toBe('left'); + }); + + it('maps end to right in LTR', () => { + expect(getLogicalSide('end', 'ltr')).toBe('right'); + }); + + it('maps start to right in RTL', () => { + expect(getLogicalSide('start', 'rtl')).toBe('right'); + }); + + it('maps end to left in RTL', () => { + expect(getLogicalSide('end', 'rtl')).toBe('left'); + }); + }); + + describe('useDirection (document.dir integration)', () => { + beforeEach(() => { + document.documentElement.dir = ''; + }); + + it('reads ltr when dir is unset', () => { + // getSnapshotDir falls back to 'ltr' for empty/unrecognized values + const dir = document.documentElement.dir; + expect(dir === 'rtl' ? 'rtl' : 'ltr').toBe('ltr'); + }); + + it('reads rtl when dir is set to rtl', () => { + document.documentElement.dir = 'rtl'; + const dir = document.documentElement.dir; + expect(dir === 'rtl' ? 'rtl' : 'ltr').toBe('rtl'); + }); + + it('reads ltr when dir is explicitly set to ltr', () => { + document.documentElement.dir = 'ltr'; + const dir = document.documentElement.dir; + expect(dir === 'rtl' ? 'rtl' : 'ltr').toBe('ltr'); + }); + }); +}); diff --git a/src/__tests__/i18n/i18n-rtl-touch.test.ts b/src/__tests__/i18n/i18n-rtl-touch.test.ts new file mode 100644 index 0000000000..7d8b701d47 --- /dev/null +++ b/src/__tests__/i18n/i18n-rtl-touch.test.ts @@ -0,0 +1,381 @@ +/** + * i18n RTL Integration Tests โ€” Touch Gestures and Dynamic Positioning + * + * Validates that RTL-aware coordinate utilities, toggle switch positioning, + * and context menu anchoring all work correctly and coherently when the + * document direction is set to RTL. + * + * These are integration-level tests that verify cross-cutting RTL behavior + * rather than testing individual utilities in isolation. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { useRef } from 'react'; +import { + getDirectionalDelta, + getDirectionalTranslateX, + getLogicalSide, +} from '../../web/utils/rtlCoordinates'; +import { useContextMenuPosition } from '../../renderer/hooks/ui/useContextMenuPosition'; +import { ToggleSwitch } from '../../renderer/components/ui/ToggleSwitch'; +import type { Theme } from '../../renderer/types'; + +const mockTheme: Theme = { + id: 'test-theme', + name: 'Test Theme', + mode: 'dark', + colors: { + bgMain: '#1a1a1a', + bgSidebar: '#242424', + bgActivity: '#2a2a2a', + textMain: '#ffffff', + textDim: '#888888', + accent: '#3b82f6', + accentForeground: '#ffffff', + border: '#333333', + error: '#ef4444', + success: '#22c55e', + warning: '#f59e0b', + cursor: '#ffffff', + terminalBg: '#1a1a1a', + }, +}; + +describe('RTL Touch and Positioning Integration', () => { + const originalDir = document.documentElement.dir; + const originalInnerWidth = window.innerWidth; + const originalInnerHeight = window.innerHeight; + const originalGetBCR = Element.prototype.getBoundingClientRect; + + afterEach(() => { + document.documentElement.dir = originalDir; + Object.defineProperty(window, 'innerWidth', { value: originalInnerWidth, configurable: true }); + Object.defineProperty(window, 'innerHeight', { + value: originalInnerHeight, + configurable: true, + }); + Element.prototype.getBoundingClientRect = originalGetBCR; + }); + + describe('getDirectionalDelta โ€” swipe direction normalization', () => { + it('returns positive delta for forward swipe in LTR (rightward)', () => { + expect(getDirectionalDelta(100, 250, 'ltr')).toBe(150); + }); + + it('returns negative delta for backward swipe in LTR (leftward)', () => { + expect(getDirectionalDelta(250, 100, 'ltr')).toBe(-150); + }); + + it('returns positive delta for forward swipe in RTL (leftward)', () => { + expect(getDirectionalDelta(250, 100, 'rtl')).toBe(150); + }); + + it('returns negative delta for backward swipe in RTL (rightward)', () => { + expect(getDirectionalDelta(100, 250, 'rtl')).toBe(-150); + }); + + it('produces symmetric results: same physical swipe gives opposite logical deltas', () => { + const startX = 200; + const endX = 350; + const ltrDelta = getDirectionalDelta(startX, endX, 'ltr'); + const rtlDelta = getDirectionalDelta(startX, endX, 'rtl'); + + expect(ltrDelta).toBe(150); + expect(rtlDelta).toBe(-150); + expect(ltrDelta + rtlDelta).toBe(0); + }); + }); + + describe('getDirectionalTranslateX โ€” RTL-inverted transforms', () => { + it('returns positive translateX in LTR', () => { + expect(getDirectionalTranslateX(100, 'ltr')).toBe('translateX(100px)'); + }); + + it('returns negated translateX in RTL', () => { + expect(getDirectionalTranslateX(100, 'rtl')).toBe('translateX(-100px)'); + }); + + it('zero offset is unaffected by direction', () => { + expect(getDirectionalTranslateX(0, 'ltr')).toBe('translateX(0px)'); + expect(getDirectionalTranslateX(0, 'rtl')).toBe('translateX(0px)'); + }); + + it('negative offset becomes positive in RTL (double negation)', () => { + expect(getDirectionalTranslateX(-50, 'rtl')).toBe('translateX(50px)'); + }); + + it('swipe delta + transform produce coherent dismiss animation', () => { + // Simulate: user swipes forward 200px โ†’ dismiss animation + const delta = getDirectionalDelta(100, 300, 'ltr'); + const transform = getDirectionalTranslateX(delta, 'ltr'); + expect(transform).toBe('translateX(200px)'); + + // Same physical gesture in RTL + const rtlDelta = getDirectionalDelta(300, 100, 'rtl'); + const rtlTransform = getDirectionalTranslateX(rtlDelta, 'rtl'); + // Forward swipe (leftward in RTL) โ†’ positive delta โ†’ negated to visual left + expect(rtlDelta).toBe(200); + expect(rtlTransform).toBe('translateX(-200px)'); + }); + }); + + describe('getLogicalSide โ€” logical-to-physical side mapping', () => { + it('maps start โ†’ left in LTR', () => { + expect(getLogicalSide('start', 'ltr')).toBe('left'); + }); + + it('maps start โ†’ right in RTL', () => { + expect(getLogicalSide('start', 'rtl')).toBe('right'); + }); + + it('maps end โ†’ right in LTR', () => { + expect(getLogicalSide('end', 'ltr')).toBe('right'); + }); + + it('maps end โ†’ left in RTL', () => { + expect(getLogicalSide('end', 'rtl')).toBe('left'); + }); + + it('start and end always produce opposite physical sides', () => { + const ltrStart = getLogicalSide('start', 'ltr'); + const ltrEnd = getLogicalSide('end', 'ltr'); + expect(ltrStart).not.toBe(ltrEnd); + + const rtlStart = getLogicalSide('start', 'rtl'); + const rtlEnd = getLogicalSide('end', 'rtl'); + expect(rtlStart).not.toBe(rtlEnd); + }); + }); + + describe('ToggleSwitch โ€” RTL-mirrored knob positioning', () => { + it('uses insetInlineStart (not translateX) for auto RTL mirroring', () => { + const { container } = render( + React.createElement(ToggleSwitch, { + checked: true, + onChange: () => {}, + theme: mockTheme, + size: 'md', + }) + ); + + const knob = container.querySelector('span') as HTMLElement; + expect(knob.style.insetInlineStart).toBe('22px'); + // Must not use translateX โ€” it doesn't auto-mirror + expect(knob.style.transform).toBe(''); + }); + + it('positions knob at offPos when unchecked', () => { + const { container } = render( + React.createElement(ToggleSwitch, { + checked: false, + onChange: () => {}, + theme: mockTheme, + size: 'lg', + }) + ); + + const knob = container.querySelector('span') as HTMLElement; + expect(knob.style.insetInlineStart).toBe('4px'); + }); + + it('positions knob at onPos when checked', () => { + const { container } = render( + React.createElement(ToggleSwitch, { + checked: true, + onChange: () => {}, + theme: mockTheme, + size: 'lg', + }) + ); + + const knob = container.querySelector('span') as HTMLElement; + expect(knob.style.insetInlineStart).toBe('26px'); + }); + + it('transitions inset-inline-start for smooth knob animation', () => { + const { container } = render( + React.createElement(ToggleSwitch, { + checked: false, + onChange: () => {}, + theme: mockTheme, + }) + ); + + const knob = container.querySelector('span') as HTMLElement; + expect(knob.style.transition).toContain('inset-inline-start'); + }); + + it('renders all three size presets with correct positioning', () => { + const sizes = [ + { size: 'sm' as const, off: '2px', on: '18px' }, + { size: 'md' as const, off: '2px', on: '22px' }, + { size: 'lg' as const, off: '4px', on: '26px' }, + ]; + + for (const { size, off, on } of sizes) { + const { container: uncheckedContainer } = render( + React.createElement(ToggleSwitch, { + checked: false, + onChange: () => {}, + theme: mockTheme, + size, + }) + ); + expect( + (uncheckedContainer.querySelector('span') as HTMLElement).style.insetInlineStart + ).toBe(off); + + const { container: checkedContainer } = render( + React.createElement(ToggleSwitch, { + checked: true, + onChange: () => {}, + theme: mockTheme, + size, + }) + ); + expect((checkedContainer.querySelector('span') as HTMLElement).style.insetInlineStart).toBe( + on + ); + } + }); + }); + + describe('Context menu positioning โ€” RTL anchor adjustment', () => { + function setupViewport(width: number, height: number) { + Object.defineProperty(window, 'innerWidth', { value: width, configurable: true }); + Object.defineProperty(window, 'innerHeight', { value: height, configurable: true }); + } + + function mockMenuSize(width: number, height: number) { + Element.prototype.getBoundingClientRect = function () { + return { + width, + height, + top: 0, + left: 0, + right: width, + bottom: height, + x: 0, + y: 0, + toJSON: () => ({}), + }; + }; + } + + it('in LTR, menu anchors from left edge of click point', () => { + document.documentElement.dir = 'ltr'; + setupViewport(1024, 768); + mockMenuSize(200, 150); + + const el = document.createElement('div'); + document.body.appendChild(el); + + const { result } = renderHook(() => { + const ref = useRef(el); + return useContextMenuPosition(ref, 400, 300); + }); + + // LTR: left = clickX = 400 + expect(result.current.left).toBe(400); + expect(result.current.top).toBe(300); + expect(result.current.ready).toBe(true); + + document.body.removeChild(el); + }); + + it('in RTL, menu anchors from right edge of click point (left = x - menuWidth)', () => { + document.documentElement.dir = 'rtl'; + setupViewport(1024, 768); + mockMenuSize(200, 150); + + const el = document.createElement('div'); + document.body.appendChild(el); + + const { result } = renderHook(() => { + const ref = useRef(el); + return useContextMenuPosition(ref, 400, 300); + }); + + // RTL: left = x - width = 400 - 200 = 200 + expect(result.current.left).toBe(200); + expect(result.current.top).toBe(300); + expect(result.current.ready).toBe(true); + + document.body.removeChild(el); + }); + + it('in RTL, clamps to padding when menu would overflow left edge', () => { + document.documentElement.dir = 'rtl'; + setupViewport(800, 600); + mockMenuSize(200, 120); + + const el = document.createElement('div'); + document.body.appendChild(el); + + const { result } = renderHook(() => { + const ref = useRef(el); + // Click at x=80: anchor = 80 - 200 = -120, clamped to padding(8) + return useContextMenuPosition(ref, 80, 200); + }); + + expect(result.current.left).toBe(8); + expect(result.current.ready).toBe(true); + + document.body.removeChild(el); + }); + + it('in RTL, clamps to right edge when click is near viewport right', () => { + document.documentElement.dir = 'rtl'; + setupViewport(800, 600); + mockMenuSize(200, 120); + + const el = document.createElement('div'); + document.body.appendChild(el); + + const { result } = renderHook(() => { + const ref = useRef(el); + // Click at x=790: anchor = 790 - 200 = 590, maxLeft = 800 - 200 - 8 = 592 + return useContextMenuPosition(ref, 790, 200); + }); + + // 590 < 592, so left = 590 + expect(result.current.left).toBe(590); + expect(result.current.ready).toBe(true); + + document.body.removeChild(el); + }); + + it('same click point produces different left values in LTR vs RTL', () => { + setupViewport(1024, 768); + mockMenuSize(180, 120); + + const el = document.createElement('div'); + document.body.appendChild(el); + + // LTR + document.documentElement.dir = 'ltr'; + const { result: ltrResult } = renderHook(() => { + const ref = useRef(el); + return useContextMenuPosition(ref, 500, 300); + }); + + // RTL + document.documentElement.dir = 'rtl'; + const { result: rtlResult } = renderHook(() => { + const ref = useRef(el); + return useContextMenuPosition(ref, 500, 300); + }); + + // LTR: left = 500, RTL: left = 500 - 180 = 320 + expect(ltrResult.current.left).toBe(500); + expect(rtlResult.current.left).toBe(320); + // Top is unaffected by direction + expect(ltrResult.current.top).toBe(rtlResult.current.top); + + document.body.removeChild(el); + }); + }); +}); diff --git a/src/__tests__/i18n/i18n-rtl.test.ts b/src/__tests__/i18n/i18n-rtl.test.ts new file mode 100644 index 0000000000..9daf308328 --- /dev/null +++ b/src/__tests__/i18n/i18n-rtl.test.ts @@ -0,0 +1,119 @@ +/** + * i18n RTL Tests + * + * Verifies that the document direction attributes update correctly + * when switching to/from Arabic (RTL) and other languages (LTR). + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { isRtlLanguage } from '../../renderer/components/shared/DirectionProvider'; +import { RTL_LANGUAGES, SUPPORTED_LANGUAGES } from '../../shared/i18n/config'; + +describe('i18n RTL Support', () => { + describe('isRtlLanguage', () => { + it('identifies Arabic as RTL', () => { + expect(isRtlLanguage('ar')).toBe(true); + }); + + it('identifies English as LTR', () => { + expect(isRtlLanguage('en')).toBe(false); + }); + + it('identifies all non-Arabic supported languages as LTR', () => { + const ltrLanguages = SUPPORTED_LANGUAGES.filter((l) => l !== 'ar'); + for (const lang of ltrLanguages) { + expect(isRtlLanguage(lang)).toBe(false); + } + }); + + it('treats unknown languages as LTR', () => { + expect(isRtlLanguage('xx')).toBe(false); + expect(isRtlLanguage('ja')).toBe(false); + }); + }); + + describe('RTL_LANGUAGES constant', () => { + it('contains only Arabic', () => { + expect(RTL_LANGUAGES).toEqual(['ar']); + }); + + it('is a subset of SUPPORTED_LANGUAGES', () => { + for (const lang of RTL_LANGUAGES) { + expect(SUPPORTED_LANGUAGES).toContain(lang); + } + }); + }); + + describe('document direction attributes', () => { + beforeEach(() => { + // Reset document attributes + document.documentElement.dir = ''; + document.documentElement.lang = ''; + document.documentElement.removeAttribute('data-dir'); + document.documentElement.style.removeProperty('--dir-start'); + document.documentElement.style.removeProperty('--dir-end'); + }); + + it('sets RTL attributes for Arabic', () => { + const rtl = isRtlLanguage('ar'); + const dir = rtl ? 'rtl' : 'ltr'; + const root = document.documentElement; + + root.lang = 'ar'; + root.dir = dir; + root.setAttribute('data-dir', dir); + root.style.setProperty('--dir-start', rtl ? 'right' : 'left'); + root.style.setProperty('--dir-end', rtl ? 'left' : 'right'); + + expect(root.dir).toBe('rtl'); + expect(root.lang).toBe('ar'); + expect(root.getAttribute('data-dir')).toBe('rtl'); + expect(root.style.getPropertyValue('--dir-start')).toBe('right'); + expect(root.style.getPropertyValue('--dir-end')).toBe('left'); + }); + + it('sets LTR attributes for English', () => { + const rtl = isRtlLanguage('en'); + const dir = rtl ? 'rtl' : 'ltr'; + const root = document.documentElement; + + root.lang = 'en'; + root.dir = dir; + root.setAttribute('data-dir', dir); + root.style.setProperty('--dir-start', rtl ? 'right' : 'left'); + root.style.setProperty('--dir-end', rtl ? 'left' : 'right'); + + expect(root.dir).toBe('ltr'); + expect(root.lang).toBe('en'); + expect(root.getAttribute('data-dir')).toBe('ltr'); + expect(root.style.getPropertyValue('--dir-start')).toBe('left'); + expect(root.style.getPropertyValue('--dir-end')).toBe('right'); + }); + + it('switches from RTL to LTR correctly', () => { + const root = document.documentElement; + + // Set Arabic (RTL) + root.dir = 'rtl'; + root.lang = 'ar'; + root.setAttribute('data-dir', 'rtl'); + root.style.setProperty('--dir-start', 'right'); + root.style.setProperty('--dir-end', 'left'); + + expect(root.dir).toBe('rtl'); + + // Switch to English (LTR) + root.dir = 'ltr'; + root.lang = 'en'; + root.setAttribute('data-dir', 'ltr'); + root.style.setProperty('--dir-start', 'left'); + root.style.setProperty('--dir-end', 'right'); + + expect(root.dir).toBe('ltr'); + expect(root.lang).toBe('en'); + expect(root.getAttribute('data-dir')).toBe('ltr'); + expect(root.style.getPropertyValue('--dir-start')).toBe('left'); + expect(root.style.getPropertyValue('--dir-end')).toBe('right'); + }); + }); +}); diff --git a/src/__tests__/i18n/i18n-size-check.test.ts b/src/__tests__/i18n/i18n-size-check.test.ts new file mode 100644 index 0000000000..88f7ea0a42 --- /dev/null +++ b/src/__tests__/i18n/i18n-size-check.test.ts @@ -0,0 +1,153 @@ +/** + * i18n Bundle Size Check Tests + * + * Runs the same size budget assertions as scripts/i18n-size-check.ts + * so that CI fails if translation files exceed size limits. + * Also validates the lazy loading configuration in source code. + */ + +import { describe, it, expect } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + checkFileSizes, + checkLazyLoading, + runCheck, + PER_FILE_LIMIT_BYTES, + TOTAL_LIMIT_BYTES, +} from '../../../scripts/i18n-size-check'; + +const LANGUAGES = ['en', 'es', 'fr', 'de', 'zh', 'hi', 'ar', 'bn', 'pt'] as const; +const NAMESPACES = [ + 'common', + 'settings', + 'modals', + 'menus', + 'notifications', + 'accessibility', + 'shortcuts', +] as const; + +const LOCALES_DIR = path.resolve(__dirname, '../../../src/shared/i18n/locales'); + +describe('i18n Bundle Size Check', () => { + describe('file size budgets', () => { + it('finds all 63 translation files (9 languages ร— 7 namespaces)', () => { + const { files } = checkFileSizes(); + expect(files).toHaveLength(LANGUAGES.length * NAMESPACES.length); + }); + + it('every translation file exists and is non-empty', () => { + for (const lang of LANGUAGES) { + for (const ns of NAMESPACES) { + const filePath = path.join(LOCALES_DIR, lang, `${ns}.json`); + expect(fs.existsSync(filePath), `${lang}/${ns}.json should exist`).toBe(true); + const stat = fs.statSync(filePath); + expect(stat.size, `${lang}/${ns}.json should be non-empty`).toBeGreaterThan(0); + } + } + }); + + it('no individual file exceeds the per-file limit', () => { + const { files } = checkFileSizes(); + const overBudget = files.filter((f) => f.overBudget); + if (overBudget.length > 0) { + const details = overBudget + .map( + (f) => + `${f.language}/${f.namespace}.json: ${(f.bytes / 1024).toFixed(1)} KB (limit: ${(PER_FILE_LIMIT_BYTES / 1024).toFixed(0)} KB)` + ) + .join('\n'); + expect.fail(`Over-budget files:\n${details}`); + } + }); + + it('total size of all translation files is under budget', () => { + const { totalBytes } = checkFileSizes(); + expect( + totalBytes, + `Total ${(totalBytes / 1024 / 1024).toFixed(2)} MB exceeds budget of ${(TOTAL_LIMIT_BYTES / 1024 / 1024).toFixed(0)} MB` + ).toBeLessThanOrEqual(TOTAL_LIMIT_BYTES); + }); + + it('English files are smaller than or equal to non-Latin script files', () => { + // Sanity check: non-Latin scripts (Bengali, Hindi, Arabic) should be + // larger than English due to multi-byte characters. If English is larger, + // something may be wrong with the translations. + const { files } = checkFileSizes(); + for (const ns of NAMESPACES) { + const enFile = files.find((f) => f.language === 'en' && f.namespace === ns); + const bnFile = files.find((f) => f.language === 'bn' && f.namespace === ns); + if (enFile && bnFile && bnFile.bytes > 0) { + expect( + enFile.bytes, + `en/${ns}.json should not be larger than bn/${ns}.json` + ).toBeLessThanOrEqual(bnFile.bytes); + } + } + }); + }); + + describe('lazy loading configuration', () => { + it('only English is statically imported in i18n config', () => { + // Verify the renderer config only bundles English statically + const configPath = path.resolve(__dirname, '../../../src/shared/i18n/config.ts'); + const configContent = fs.readFileSync(configPath, 'utf-8'); + + // English imports should be present + expect(configContent).toContain("from './locales/en/common.json'"); + + // Non-English static imports should NOT be present + for (const lang of LANGUAGES) { + if (lang === 'en') continue; + expect( + configContent, + `config.ts should not statically import ${lang} translations` + ).not.toMatch(new RegExp(`from\\s+['"]\\.\/locales\\/${lang}\\/`)); + } + }); + + it('uses dynamic import for non-English languages', () => { + const configPath = path.resolve(__dirname, '../../../src/shared/i18n/config.ts'); + const configContent = fs.readFileSync(configPath, 'utf-8'); + + // Should use resourcesToBackend with dynamic import + expect(configContent).toContain('resourcesToBackend'); + expect(configContent).toMatch(/import\(`\.\/locales\/\$\{/); + }); + + it('detects lazy-loaded locale chunks in build output (if built)', () => { + const result = checkLazyLoading(); + if (result === null) { + // No build output โ€” skip (CI will have build output) + return; + } + + expect(result.englishBundled, 'English should be bundled statically').toBe(true); + expect( + result.missingLanguages, + `Missing locale chunks for: ${result.missingLanguages.join(', ')}` + ).toHaveLength(0); + expect(result.localeChunks.length).toBe(LANGUAGES.length - 1); // All except English + }); + }); + + describe('overall check', () => { + it('runCheck() passes with current translation files', () => { + const report = runCheck(); + if (!report.passed) { + const reasons: string[] = []; + if (report.overBudgetFiles.length > 0) { + reasons.push(`${report.overBudgetFiles.length} file(s) over per-file limit`); + } + if (report.totalOverBudget) { + reasons.push('total size over budget'); + } + if (report.lazyLoading && !report.lazyLoading.passed) { + reasons.push('lazy loading check failed'); + } + expect.fail(`Size check failed: ${reasons.join('; ')}`); + } + }); + }); +}); diff --git a/src/__tests__/i18n/i18n-smoke.test.ts b/src/__tests__/i18n/i18n-smoke.test.ts new file mode 100644 index 0000000000..4b983f96cd --- /dev/null +++ b/src/__tests__/i18n/i18n-smoke.test.ts @@ -0,0 +1,767 @@ +/** + * i18n End-to-End Smoke Test + * + * Verifies all 9 supported languages work correctly across key UI areas: + * (1) Settings modal keys render without overflow (string length checks) + * (2) Hamburger menu items display correctly + * (3) Command palette / Quick Actions labels translate + * (4) Toast notification text translates + * (5) Date/number formatting matches locale conventions + * (6) Arabic RTL layout: direction, CSS properties, bidirectional text + * (7) ARIA labels translate for screen reader accessibility + * (8) Switching back to English restores all text + */ + +import { describe, it, expect, beforeAll, beforeEach, vi, afterEach } from 'vitest'; +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import { + SUPPORTED_LANGUAGES, + I18N_NAMESPACES, + LANGUAGE_NATIVE_NAMES, + RTL_LANGUAGES, +} from '../../shared/i18n/config'; +import type { SupportedLanguage } from '../../shared/i18n/config'; +import { isRtlLanguage } from '../../renderer/components/shared/DirectionProvider'; +import { formatSize, formatCost, formatTokens, formatRelativeTime } from '../../shared/formatters'; + +// Import all language resources +import commonEn from '../../shared/i18n/locales/en/common.json'; +import settingsEn from '../../shared/i18n/locales/en/settings.json'; +import modalsEn from '../../shared/i18n/locales/en/modals.json'; +import menusEn from '../../shared/i18n/locales/en/menus.json'; +import notificationsEn from '../../shared/i18n/locales/en/notifications.json'; +import accessibilityEn from '../../shared/i18n/locales/en/accessibility.json'; +import shortcutsEn from '../../shared/i18n/locales/en/shortcuts.json'; + +import commonEs from '../../shared/i18n/locales/es/common.json'; +import settingsEs from '../../shared/i18n/locales/es/settings.json'; +import modalsEs from '../../shared/i18n/locales/es/modals.json'; +import menusEs from '../../shared/i18n/locales/es/menus.json'; +import notificationsEs from '../../shared/i18n/locales/es/notifications.json'; +import accessibilityEs from '../../shared/i18n/locales/es/accessibility.json'; +import shortcutsEs from '../../shared/i18n/locales/es/shortcuts.json'; + +import commonFr from '../../shared/i18n/locales/fr/common.json'; +import settingsFr from '../../shared/i18n/locales/fr/settings.json'; +import modalsFr from '../../shared/i18n/locales/fr/modals.json'; +import menusFr from '../../shared/i18n/locales/fr/menus.json'; +import notificationsFr from '../../shared/i18n/locales/fr/notifications.json'; +import accessibilityFr from '../../shared/i18n/locales/fr/accessibility.json'; +import shortcutsFr from '../../shared/i18n/locales/fr/shortcuts.json'; + +import commonDe from '../../shared/i18n/locales/de/common.json'; +import settingsDe from '../../shared/i18n/locales/de/settings.json'; +import modalsDe from '../../shared/i18n/locales/de/modals.json'; +import menusDe from '../../shared/i18n/locales/de/menus.json'; +import notificationsDe from '../../shared/i18n/locales/de/notifications.json'; +import accessibilityDe from '../../shared/i18n/locales/de/accessibility.json'; +import shortcutsDe from '../../shared/i18n/locales/de/shortcuts.json'; + +import commonZh from '../../shared/i18n/locales/zh/common.json'; +import settingsZh from '../../shared/i18n/locales/zh/settings.json'; +import modalsZh from '../../shared/i18n/locales/zh/modals.json'; +import menusZh from '../../shared/i18n/locales/zh/menus.json'; +import notificationsZh from '../../shared/i18n/locales/zh/notifications.json'; +import accessibilityZh from '../../shared/i18n/locales/zh/accessibility.json'; +import shortcutsZh from '../../shared/i18n/locales/zh/shortcuts.json'; + +import commonHi from '../../shared/i18n/locales/hi/common.json'; +import settingsHi from '../../shared/i18n/locales/hi/settings.json'; +import modalsHi from '../../shared/i18n/locales/hi/modals.json'; +import menusHi from '../../shared/i18n/locales/hi/menus.json'; +import notificationsHi from '../../shared/i18n/locales/hi/notifications.json'; +import accessibilityHi from '../../shared/i18n/locales/hi/accessibility.json'; +import shortcutsHi from '../../shared/i18n/locales/hi/shortcuts.json'; + +import commonAr from '../../shared/i18n/locales/ar/common.json'; +import settingsAr from '../../shared/i18n/locales/ar/settings.json'; +import modalsAr from '../../shared/i18n/locales/ar/modals.json'; +import menusAr from '../../shared/i18n/locales/ar/menus.json'; +import notificationsAr from '../../shared/i18n/locales/ar/notifications.json'; +import accessibilityAr from '../../shared/i18n/locales/ar/accessibility.json'; +import shortcutsAr from '../../shared/i18n/locales/ar/shortcuts.json'; + +import commonBn from '../../shared/i18n/locales/bn/common.json'; +import settingsBn from '../../shared/i18n/locales/bn/settings.json'; +import modalsBn from '../../shared/i18n/locales/bn/modals.json'; +import menusBn from '../../shared/i18n/locales/bn/menus.json'; +import notificationsBn from '../../shared/i18n/locales/bn/notifications.json'; +import accessibilityBn from '../../shared/i18n/locales/bn/accessibility.json'; +import shortcutsBn from '../../shared/i18n/locales/bn/shortcuts.json'; + +import commonPt from '../../shared/i18n/locales/pt/common.json'; +import settingsPt from '../../shared/i18n/locales/pt/settings.json'; +import modalsPt from '../../shared/i18n/locales/pt/modals.json'; +import menusPt from '../../shared/i18n/locales/pt/menus.json'; +import notificationsPt from '../../shared/i18n/locales/pt/notifications.json'; +import accessibilityPt from '../../shared/i18n/locales/pt/accessibility.json'; +import shortcutsPt from '../../shared/i18n/locales/pt/shortcuts.json'; + +const allResources: Record> = { + en: { + common: commonEn, + settings: settingsEn, + modals: modalsEn, + menus: menusEn, + notifications: notificationsEn, + accessibility: accessibilityEn, + shortcuts: shortcutsEn, + }, + es: { + common: commonEs, + settings: settingsEs, + modals: modalsEs, + menus: menusEs, + notifications: notificationsEs, + accessibility: accessibilityEs, + shortcuts: shortcutsEs, + }, + fr: { + common: commonFr, + settings: settingsFr, + modals: modalsFr, + menus: menusFr, + notifications: notificationsFr, + accessibility: accessibilityFr, + shortcuts: shortcutsFr, + }, + de: { + common: commonDe, + settings: settingsDe, + modals: modalsDe, + menus: menusDe, + notifications: notificationsDe, + accessibility: accessibilityDe, + shortcuts: shortcutsDe, + }, + zh: { + common: commonZh, + settings: settingsZh, + modals: modalsZh, + menus: menusZh, + notifications: notificationsZh, + accessibility: accessibilityZh, + shortcuts: shortcutsZh, + }, + hi: { + common: commonHi, + settings: settingsHi, + modals: modalsHi, + menus: menusHi, + notifications: notificationsHi, + accessibility: accessibilityHi, + shortcuts: shortcutsHi, + }, + ar: { + common: commonAr, + settings: settingsAr, + modals: modalsAr, + menus: menusAr, + notifications: notificationsAr, + accessibility: accessibilityAr, + shortcuts: shortcutsAr, + }, + bn: { + common: commonBn, + settings: settingsBn, + modals: modalsBn, + menus: menusBn, + notifications: notificationsBn, + accessibility: accessibilityBn, + shortcuts: shortcutsBn, + }, + pt: { + common: commonPt, + settings: settingsPt, + modals: modalsPt, + menus: menusPt, + notifications: notificationsPt, + accessibility: accessibilityPt, + shortcuts: shortcutsPt, + }, +}; + +// Create a dedicated i18n instance for smoke tests +const smokeI18n = i18n.createInstance(); + +beforeAll(async () => { + await smokeI18n.use(initReactI18next).init({ + resources: allResources, + fallbackLng: 'en', + supportedLngs: [...SUPPORTED_LANGUAGES], + ns: [...I18N_NAMESPACES], + defaultNS: 'common', + interpolation: { escapeValue: false }, + react: { useSuspense: false }, + }); +}); + +/** + * Helper: get a translated value from the smoke i18n instance. + * Uses namespace:key syntax. + */ +function t(key: string, options?: Record): string { + return (smokeI18n.t as (key: string, opts?: Record) => string)(key, options); +} + +// ============================================================================ +// (1) Settings modal โ€” key labels should not be excessively long (overflow proxy) +// ============================================================================ +describe('i18n Smoke: Settings modal rendering', () => { + // Settings tab labels should be short enough for tab UI (< 30 chars) + const settingsTabKeys = [ + 'settings:tabs.general', + 'settings:tabs.display', + 'settings:tabs.llm', + 'settings:tabs.shortcuts', + 'settings:tabs.themes', + 'settings:tabs.notifications', + 'settings:tabs.ai_commands', + 'settings:tabs.ssh_hosts', + 'settings:tabs.encore_features', + ]; + + // Settings section headers should be < 80 chars + const settingsSectionKeys = [ + 'settings:general.title', + 'settings:general.theme_label', + 'settings:general.language_label', + 'settings:general.language_description', + 'settings:general.shell_header', + 'settings:general.env_vars_header', + 'settings:general.log_level_header', + 'settings:general.input_behavior_header', + ]; + + it.each([...SUPPORTED_LANGUAGES])( + 'tab labels in %s are not truncated (< 30 chars)', + async (lang) => { + await smokeI18n.changeLanguage(lang); + for (const key of settingsTabKeys) { + const value = t(key); + expect(value).toBeTruthy(); + expect(value).not.toBe(key); // Resolved, not raw key + expect( + value.length, + `${lang}:${key} = "${value}" (${value.length} chars) exceeds 30-char tab limit` + ).toBeLessThanOrEqual(30); + } + } + ); + + it.each([...SUPPORTED_LANGUAGES])( + 'section headers in %s are reasonable length (< 80 chars)', + async (lang) => { + await smokeI18n.changeLanguage(lang); + for (const key of settingsSectionKeys) { + const value = t(key); + expect(value).toBeTruthy(); + expect(value).not.toBe(key); + expect( + value.length, + `${lang}:${key} = "${value}" (${value.length} chars) exceeds 80-char header limit` + ).toBeLessThanOrEqual(80); + } + } + ); +}); + +// ============================================================================ +// (2) Hamburger menu items display correctly +// ============================================================================ +describe('i18n Smoke: Hamburger menu items', () => { + const hamburgerKeys = [ + 'menus:hamburger.new_agent', + 'menus:hamburger.new_group_chat', + 'menus:hamburger.wizard', + 'menus:hamburger.command_palette', + 'menus:hamburger.tour', + 'menus:hamburger.keyboard_shortcuts', + 'menus:hamburger.settings', + 'menus:hamburger.system_logs', + 'menus:hamburger.process_monitor', + 'menus:hamburger.usage_dashboard', + 'menus:hamburger.symphony', + 'menus:hamburger.director_notes', + 'menus:hamburger.website', + 'menus:hamburger.documentation', + 'menus:hamburger.check_updates', + 'menus:hamburger.about', + 'menus:hamburger.language', + 'menus:hamburger.quit', + ]; + + it.each([...SUPPORTED_LANGUAGES])('all hamburger menu items translate in %s', async (lang) => { + await smokeI18n.changeLanguage(lang); + for (const key of hamburgerKeys) { + const value = t(key); + expect(value, `${lang} missing ${key}`).toBeTruthy(); + expect(value, `${lang}:${key} returned raw key`).not.toBe(key); + expect(value, `${lang}:${key} returned raw key path`).not.toBe(key.split(':')[1]); + } + }); + + it.each([...SUPPORTED_LANGUAGES])( + 'hamburger labels in %s are reasonable length (< 50 chars)', + async (lang) => { + await smokeI18n.changeLanguage(lang); + for (const key of hamburgerKeys) { + const value = t(key); + expect( + value.length, + `${lang}:${key} = "${value}" (${value.length} chars) may overflow menu` + ).toBeLessThanOrEqual(50); + } + } + ); + + it.each([...SUPPORTED_LANGUAGES])('hamburger descriptions in %s translate', async (lang) => { + await smokeI18n.changeLanguage(lang); + const descKeys = [ + 'menus:hamburger.new_agent_desc', + 'menus:hamburger.wizard_desc', + 'menus:hamburger.command_palette_desc', + 'menus:hamburger.settings_desc', + 'menus:hamburger.about_desc', + ]; + for (const key of descKeys) { + const value = t(key); + expect(value, `${lang} missing ${key}`).toBeTruthy(); + expect(value).not.toBe(key); + } + }); +}); + +// ============================================================================ +// (3) Command palette / Quick Actions search labels +// ============================================================================ +describe('i18n Smoke: Command palette labels', () => { + // These are the common action keys used in QuickActionsModal + const paletteKeys = [ + 'common:search', + 'common:settings', + 'common:help', + 'common:save', + 'common:cancel', + 'common:close', + 'common:delete', + 'common:create', + 'common:open', + 'common:refresh', + ]; + + it.each([...SUPPORTED_LANGUAGES])('common action labels translate in %s', async (lang) => { + await smokeI18n.changeLanguage(lang); + for (const key of paletteKeys) { + const value = t(key); + expect(value, `${lang} missing ${key}`).toBeTruthy(); + expect(value, `${lang}:${key} returned raw key`).not.toBe(key.split(':')[1]); + } + }); + + it.each([...SUPPORTED_LANGUAGES])( + 'translated action labels in %s are non-empty strings', + async (lang) => { + await smokeI18n.changeLanguage(lang); + for (const key of paletteKeys) { + const value = t(key); + expect(typeof value).toBe('string'); + expect(value.trim().length).toBeGreaterThan(0); + } + } + ); +}); + +// ============================================================================ +// (4) Toast notifications display translated text +// ============================================================================ +describe('i18n Smoke: Toast notification text', () => { + const notificationKeys = [ + 'notifications:task.completed_title', + 'notifications:task.failed_title', + 'notifications:connection.lost_title', + 'notifications:connection.restored_title', + 'notifications:worktree.discovered_title', + 'notifications:autorun.started_title', + 'notifications:autorun.complete_title', + ]; + + it.each([...SUPPORTED_LANGUAGES])('notification titles translate in %s', async (lang) => { + await smokeI18n.changeLanguage(lang); + for (const key of notificationKeys) { + const value = t(key); + expect(value, `${lang} missing ${key}`).toBeTruthy(); + expect(value).not.toBe(key); + expect(value).not.toBe(key.split(':')[1]); + } + }); + + it.each([...SUPPORTED_LANGUAGES])( + 'notification messages with interpolation work in %s', + async (lang) => { + await smokeI18n.changeLanguage(lang); + + const completedMsg = t('notifications:task.completed_message', { + agent: 'Claude', + duration: '5m', + }); + expect(completedMsg).toContain('Claude'); + expect(completedMsg).toContain('5m'); + + const failedMsg = t('notifications:task.failed_message', { agent: 'Codex' }); + expect(failedMsg).toContain('Codex'); + } + ); + + it.each([...SUPPORTED_LANGUAGES])('pluralized notification messages work in %s', async (lang) => { + await smokeI18n.changeLanguage(lang); + + const singular = t('notifications:worktree.discovered_message', { count: 1 }); + const plural = t('notifications:worktree.discovered_message', { count: 5 }); + + expect(singular).toBeTruthy(); + expect(plural).toBeTruthy(); + // Some languages (e.g., Arabic) use word forms for count=1 instead of the digit "1" + // Just verify singular and plural produce different non-empty strings + if (lang !== 'ar') { + expect(singular).toContain('1'); + } + expect(plural).toContain('5'); + }); +}); + +// ============================================================================ +// (5) Date/number formatting matches locale conventions +// ============================================================================ +describe('i18n Smoke: Locale-aware formatting', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-13T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // Locales that use comma as decimal separator + const commaDecimalLocales: SupportedLanguage[] = ['de', 'fr', 'es', 'pt']; + // Locales that use period as decimal separator + const periodDecimalLocales: SupportedLanguage[] = ['en', 'zh']; + + it.each(commaDecimalLocales)('formatSize uses comma separator for %s', (locale) => { + const result = formatSize(1536, locale); + expect(result).toContain('1,5'); + expect(result).toContain('KB'); + }); + + it.each(periodDecimalLocales)('formatSize uses period separator for %s', (locale) => { + const result = formatSize(1536, locale); + expect(result).toContain('1.5'); + expect(result).toContain('KB'); + }); + + it.each([...SUPPORTED_LANGUAGES])('formatCost produces valid output for %s', (locale) => { + const result = formatCost(42.99, locale); + expect(result).toBeTruthy(); + expect(typeof result).toBe('string'); + // Some locales (Bengali, Arabic) use non-Western numeral systems via Intl.NumberFormat. + // Verify the output is non-empty and contains currency-related content. + expect(result.length).toBeGreaterThan(0); + // For locales using Western Arabic numerals, check digit presence + const westernNumeralLocales = ['en', 'es', 'fr', 'de', 'zh', 'pt']; + if (westernNumeralLocales.includes(locale)) { + expect(result).toMatch(/42/); + expect(result).toMatch(/99/); + } + }); + + it.each([...SUPPORTED_LANGUAGES])( + 'formatTokens produces valid compact output for %s', + (locale) => { + const result = formatTokens(2500, locale); + expect(result).toBeTruthy(); + expect(result).toMatch(/^~/); // Approximate prefix + } + ); + + it.each([...SUPPORTED_LANGUAGES])('formatRelativeTime produces valid output for %s', (locale) => { + const fiveMinutesAgo = Date.now() - 5 * 60 * 1000; + const result = formatRelativeTime(fiveMinutesAgo, locale); + expect(result).toBeTruthy(); + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + // Should contain the number 5 (in some numeral system) + // Arabic may use Eastern Arabic numerals, so just check non-empty + }); + + it.each([...SUPPORTED_LANGUAGES])('formatRelativeTime handles hours for %s', (locale) => { + const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000; + const result = formatRelativeTime(twoHoursAgo, locale); + expect(result).toBeTruthy(); + expect(result.length).toBeGreaterThan(0); + }); + + it.each([...SUPPORTED_LANGUAGES])('formatRelativeTime handles old dates for %s', (locale) => { + const twoWeeksAgo = Date.now() - 14 * 24 * 60 * 60 * 1000; + const result = formatRelativeTime(twoWeeksAgo, locale); + expect(result).toBeTruthy(); + // Should be a formatted date (not a relative time) + expect(result.length).toBeGreaterThan(0); + }); +}); + +// ============================================================================ +// (6) Arabic RTL layout +// ============================================================================ +describe('i18n Smoke: Arabic RTL layout', () => { + beforeEach(() => { + // Reset document attributes + document.documentElement.dir = ''; + document.documentElement.lang = ''; + document.documentElement.removeAttribute('data-dir'); + document.documentElement.style.removeProperty('--dir-start'); + document.documentElement.style.removeProperty('--dir-end'); + }); + + it('Arabic is correctly identified as RTL', () => { + expect(isRtlLanguage('ar')).toBe(true); + expect(RTL_LANGUAGES).toContain('ar'); + }); + + it('all non-Arabic languages are LTR', () => { + for (const lang of SUPPORTED_LANGUAGES) { + if (lang === 'ar') continue; + expect(isRtlLanguage(lang)).toBe(false); + } + }); + + it('RTL direction attributes set correctly for Arabic', () => { + const root = document.documentElement; + const rtl = isRtlLanguage('ar'); + const dir = rtl ? 'rtl' : 'ltr'; + + root.lang = 'ar'; + root.dir = dir; + root.setAttribute('data-dir', dir); + root.style.setProperty('--dir-start', rtl ? 'right' : 'left'); + root.style.setProperty('--dir-end', rtl ? 'left' : 'right'); + + // Sidebar should be on the right in RTL + expect(root.dir).toBe('rtl'); + expect(root.getAttribute('data-dir')).toBe('rtl'); + expect(root.style.getPropertyValue('--dir-start')).toBe('right'); + expect(root.style.getPropertyValue('--dir-end')).toBe('left'); + }); + + it('Arabic translations contain Arabic script characters', async () => { + await smokeI18n.changeLanguage('ar'); + const arabicSave = t('common:save'); + const arabicSettings = t('menus:hamburger.settings'); + const arabicSearch = t('common:search'); + + // Arabic Unicode range: \u0600-\u06FF (Arabic block) + const arabicPattern = /[\u0600-\u06FF]/; + expect(arabicSave).toMatch(arabicPattern); + expect(arabicSettings).toMatch(arabicPattern); + expect(arabicSearch).toMatch(arabicPattern); + }); + + it('Arabic handles bidirectional text with English product names', async () => { + await smokeI18n.changeLanguage('ar'); + + // Keys that embed "Maestro" (English) within Arabic text + const aboutMaestro = t('menus:hamburger.about'); + const quitMaestro = t('menus:hamburger.quit'); + + // Should contain both Arabic text and the English product name + expect(aboutMaestro).toBeTruthy(); + expect(quitMaestro).toBeTruthy(); + // Both should contain "Maestro" (possibly with bidi markers) + expect(aboutMaestro.replace(/[\u200E\u200F]/g, '')).toContain('Maestro'); + expect(quitMaestro.replace(/[\u200E\u200F]/g, '')).toContain('Maestro'); + }); + + it('Arabic text alignment CSS properties are set correctly', () => { + const root = document.documentElement; + root.dir = 'rtl'; + root.style.setProperty('--dir-start', 'right'); + root.style.setProperty('--dir-end', 'left'); + + // In RTL, text-align: start resolves to right + expect(root.style.getPropertyValue('--dir-start')).toBe('right'); + expect(root.style.getPropertyValue('--dir-end')).toBe('left'); + }); +}); + +// ============================================================================ +// (7) Screen reader ARIA labels translate +// ============================================================================ +describe('i18n Smoke: Translated ARIA labels', () => { + const ariaKeys = [ + 'accessibility:sidebar.toggle_button', + 'accessibility:sidebar.agent_list', + 'accessibility:main_panel.output_region', + 'accessibility:main_panel.input_field', + ]; + + it.each([...SUPPORTED_LANGUAGES])('core ARIA labels translate in %s', async (lang) => { + await smokeI18n.changeLanguage(lang); + for (const key of ariaKeys) { + const value = t(key); + expect(value, `${lang} missing ARIA label ${key}`).toBeTruthy(); + expect(value).not.toBe(key); + expect(value).not.toBe(key.split(':')[1]); + } + }); + + it('English ARIA labels are plain English', async () => { + await smokeI18n.changeLanguage('en'); + expect(t('accessibility:sidebar.toggle_button')).toBe('Toggle left panel'); + expect(t('accessibility:sidebar.agent_list')).toBe('Agent list'); + expect(t('accessibility:main_panel.output_region')).toBe('AI output region'); + expect(t('accessibility:main_panel.input_field')).toBe('Message input'); + }); + + it('non-Latin script languages produce non-Latin ARIA labels', async () => { + // Arabic + await smokeI18n.changeLanguage('ar'); + const arToggle = t('accessibility:sidebar.toggle_button'); + expect(arToggle).toMatch(/[\u0600-\u06FF]/); + + // Chinese + await smokeI18n.changeLanguage('zh'); + const zhToggle = t('accessibility:sidebar.toggle_button'); + expect(zhToggle).toMatch(/[\u4E00-\u9FFF\u3400-\u4DBF]/); + + // Hindi + await smokeI18n.changeLanguage('hi'); + const hiToggle = t('accessibility:sidebar.toggle_button'); + expect(hiToggle).toMatch(/[\u0900-\u097F]/); + + // Bengali + await smokeI18n.changeLanguage('bn'); + const bnToggle = t('accessibility:sidebar.toggle_button'); + expect(bnToggle).toMatch(/[\u0980-\u09FF]/); + }); + + it('ARIA labels with interpolation work across languages', async () => { + const mobileCardKey = 'accessibility:mobile.session_card'; + + for (const lang of SUPPORTED_LANGUAGES) { + await smokeI18n.changeLanguage(lang); + const value = t(mobileCardKey, { + name: 'TestAgent', + status: 'ready', + mode: 'AI', + }); + expect(value, `${lang}: interpolated ARIA label should contain agent name`).toContain( + 'TestAgent' + ); + } + }); +}); + +// ============================================================================ +// (8) Language switching โ€” restoring to English +// ============================================================================ +describe('i18n Smoke: Language switching round-trip', () => { + it('switching to each language and back to English restores original text', async () => { + // Capture English values + await smokeI18n.changeLanguage('en'); + const enSave = t('common:save'); + const enSettings = t('menus:hamburger.settings'); + const enGeneral = t('settings:tabs.general'); + const enTaskComplete = t('notifications:task.completed_title'); + const enToggle = t('accessibility:sidebar.toggle_button'); + + expect(enSave).toBe('Save'); + expect(enSettings).toBe('Settings'); + expect(enGeneral).toBe('General'); + + for (const lang of SUPPORTED_LANGUAGES) { + if (lang === 'en') continue; + + // Switch to non-English language + await smokeI18n.changeLanguage(lang); + const foreignSave = t('common:save'); + + // Non-English languages should have a different translation for "Save" + // (except coincidental matches for single-word cognates are unlikely) + expect(foreignSave).toBeTruthy(); + + // Switch back to English + await smokeI18n.changeLanguage('en'); + expect(t('common:save')).toBe(enSave); + expect(t('menus:hamburger.settings')).toBe(enSettings); + expect(t('settings:tabs.general')).toBe(enGeneral); + expect(t('notifications:task.completed_title')).toBe(enTaskComplete); + expect(t('accessibility:sidebar.toggle_button')).toBe(enToggle); + } + }); + + it('language switching updates i18n instance language property', async () => { + for (const lang of SUPPORTED_LANGUAGES) { + await smokeI18n.changeLanguage(lang); + expect(smokeI18n.language).toBe(lang); + } + }); + + it('RTL direction toggles correctly when switching Arabic โ†” English', () => { + const root = document.documentElement; + + // Switch to Arabic + const arRtl = isRtlLanguage('ar'); + root.dir = arRtl ? 'rtl' : 'ltr'; + root.lang = 'ar'; + root.setAttribute('data-dir', root.dir); + root.style.setProperty('--dir-start', arRtl ? 'right' : 'left'); + root.style.setProperty('--dir-end', arRtl ? 'left' : 'right'); + + expect(root.dir).toBe('rtl'); + + // Switch back to English + const enRtl = isRtlLanguage('en'); + root.dir = enRtl ? 'rtl' : 'ltr'; + root.lang = 'en'; + root.setAttribute('data-dir', root.dir); + root.style.setProperty('--dir-start', enRtl ? 'right' : 'left'); + root.style.setProperty('--dir-end', enRtl ? 'left' : 'right'); + + expect(root.dir).toBe('ltr'); + expect(root.lang).toBe('en'); + expect(root.getAttribute('data-dir')).toBe('ltr'); + expect(root.style.getPropertyValue('--dir-start')).toBe('left'); + expect(root.style.getPropertyValue('--dir-end')).toBe('right'); + }); +}); + +// ============================================================================ +// Cross-cutting: every language has translated native name +// ============================================================================ +describe('i18n Smoke: Language metadata', () => { + it('every supported language has a native name', () => { + for (const lang of SUPPORTED_LANGUAGES) { + const name = LANGUAGE_NATIVE_NAMES[lang]; + expect(name).toBeTruthy(); + expect(name.length).toBeGreaterThan(0); + } + }); + + it('native names are unique per language', () => { + const names = Object.values(LANGUAGE_NATIVE_NAMES); + const uniqueNames = new Set(names); + expect(uniqueNames.size).toBe(names.length); + }); + + it('non-Latin languages have native names in their own script', () => { + // Chinese + expect(LANGUAGE_NATIVE_NAMES['zh']).toMatch(/[\u4E00-\u9FFF]/); + // Hindi + expect(LANGUAGE_NATIVE_NAMES['hi']).toMatch(/[\u0900-\u097F]/); + // Arabic + expect(LANGUAGE_NATIVE_NAMES['ar']).toMatch(/[\u0600-\u06FF]/); + // Bengali + expect(LANGUAGE_NATIVE_NAMES['bn']).toMatch(/[\u0980-\u09FF]/); + }); +}); diff --git a/src/__tests__/i18n/i18n-theme-integration.test.ts b/src/__tests__/i18n/i18n-theme-integration.test.ts new file mode 100644 index 0000000000..130d515d22 --- /dev/null +++ b/src/__tests__/i18n/i18n-theme-integration.test.ts @@ -0,0 +1,388 @@ +/** + * i18n โ†” Theme Integration Smoke Tests + * + * Regression-prevention suite verifying that the theme system and i18n system + * evolve independently without breaking each other. Covers: + * + * 1. Theme change does not reset language + * 2. Language change does not reset theme + * 3. Custom theme colors persist across language switch + * 4. ThemeTab renders mode labels in the active language + * 5. CSS custom properties contain both theme colors and RTL direction props + * 6. Mermaid cache key is language-independent + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { THEMES } from '../../shared/themes'; +import type { ThemeColors } from '../../shared/theme-types'; + +// Mock the i18n config module so setLanguage doesn't require a fully +// initialized i18next instance. +vi.mock('../../shared/i18n/config', () => ({ + default: { + changeLanguage: vi.fn().mockResolvedValue(undefined), + language: 'en', + }, + LANGUAGE_STORAGE_KEY: 'maestro-language', + RTL_LANGUAGES: ['ar'], + SUPPORTED_LANGUAGES: ['en', 'es', 'fr', 'de', 'zh', 'hi', 'ar', 'bn', 'pt'], +})); + +describe('i18n โ†” Theme Integration Smoke Tests', () => { + let useSettingsStore: any; + let loadAllSettings: any; + let settingsSetCalls: Array<{ key: string; value: unknown }>; + + beforeEach(async () => { + settingsSetCalls = []; + vi.mocked(window.maestro.settings.set).mockImplementation( + async (key: string, value: unknown) => { + settingsSetCalls.push({ key, value }); + return undefined; + } + ); + + const mod = await import('../../renderer/stores/settingsStore'); + useSettingsStore = mod.useSettingsStore; + loadAllSettings = mod.loadAllSettings; + + // Reset store to known defaults + useSettingsStore.setState({ + activeThemeId: 'dracula', + language: 'en', + customThemeColors: THEMES.dracula.colors, + settingsLoaded: false, + }); + + // Reset document element + const root = document.documentElement; + root.dir = ''; + root.lang = ''; + root.removeAttribute('data-dir'); + root.style.removeProperty('--accent-color'); + root.style.removeProperty('--highlight-color'); + root.style.removeProperty('--dir-start'); + root.style.removeProperty('--dir-end'); + root.style.removeProperty('--rtl-sign'); + }); + + // ----------------------------------------------------------------------- + // 1. Theme change does not reset language + // ----------------------------------------------------------------------- + describe('theme change does not reset language', () => { + it('switching theme preserves language in store state', () => { + useSettingsStore.setState({ language: 'es' }); + const store = useSettingsStore.getState(); + store.setActiveThemeId('nord' as any); + + expect(useSettingsStore.getState().language).toBe('es'); + }); + + it('switching theme does not persist language key', () => { + useSettingsStore.setState({ language: 'fr' }); + useSettingsStore.getState().setActiveThemeId('monokai' as any); + + const langCalls = settingsSetCalls.filter((c) => c.key === 'language'); + expect(langCalls).toHaveLength(0); + }); + }); + + // ----------------------------------------------------------------------- + // 2. Language change does not reset theme + // ----------------------------------------------------------------------- + describe('language change does not reset theme', () => { + it('switching language preserves activeThemeId in store state', () => { + useSettingsStore.setState({ activeThemeId: 'nord' as any }); + useSettingsStore.getState().setLanguage('de'); + + expect(useSettingsStore.getState().activeThemeId).toBe('nord'); + }); + + it('switching language does not persist activeThemeId key', () => { + useSettingsStore.setState({ activeThemeId: 'tokyo-night' as any }); + useSettingsStore.getState().setLanguage('zh'); + + const themeCalls = settingsSetCalls.filter((c) => c.key === 'activeThemeId'); + expect(themeCalls).toHaveLength(0); + }); + }); + + // ----------------------------------------------------------------------- + // 3. Custom theme colors persist across language switch + // ----------------------------------------------------------------------- + describe('custom theme colors persist across language switch', () => { + const customColors: ThemeColors = { + bgMain: '#111111', + bgSidebar: '#222222', + bgActivity: '#333333', + border: '#444444', + textMain: '#eeeeee', + textDim: '#999999', + accent: '#ff00ff', + accentDim: 'rgba(255, 0, 255, 0.2)', + accentText: '#00ffff', + accentForeground: '#111111', + success: '#00ff00', + warning: '#ffaa00', + error: '#ff0000', + }; + + it('custom theme colors survive language switch in Zustand state', () => { + useSettingsStore.setState({ + activeThemeId: 'custom' as any, + customThemeColors: customColors, + }); + + // Switch language + useSettingsStore.getState().setLanguage('ar'); + + const state = useSettingsStore.getState(); + expect(state.activeThemeId).toBe('custom'); + expect(state.customThemeColors).toEqual(customColors); + }); + + it('language switch does not trigger customThemeColors persistence', () => { + useSettingsStore.setState({ + activeThemeId: 'custom' as any, + customThemeColors: customColors, + }); + + useSettingsStore.getState().setLanguage('hi'); + + const colorCalls = settingsSetCalls.filter((c) => c.key === 'customThemeColors'); + expect(colorCalls).toHaveLength(0); + }); + + it('custom colors persist through loadAllSettings with non-English language', async () => { + vi.mocked(window.maestro.settings.getAll).mockResolvedValueOnce({ + activeThemeId: 'custom', + language: 'ar', + customThemeColors: customColors, + }); + + await loadAllSettings(); + + const state = useSettingsStore.getState(); + expect(state.activeThemeId).toBe('custom'); + expect(state.language).toBe('ar'); + expect(state.customThemeColors).toEqual(customColors); + }); + }); + + // ----------------------------------------------------------------------- + // 4. ThemeTab renders mode labels in the active language + // ----------------------------------------------------------------------- + describe('ThemeTab mode labels use i18n keys', () => { + it('settings.json contains all three mode label keys', async () => { + // Dynamically import settings.json to verify the keys exist + const settingsEn = await import('../../shared/i18n/locales/en/settings.json'); + const themes = (settingsEn as any).default?.themes ?? (settingsEn as any).themes; + + expect(themes.dark_mode).toBe('Dark Mode'); + expect(themes.light_mode).toBe('Light Mode'); + expect(themes.vibe_mode).toBe('Vibe Mode'); + }); + + it('all non-English locales have theme mode label keys', async () => { + const locales = ['es', 'fr', 'de', 'zh', 'hi', 'ar', 'bn', 'pt']; + + for (const locale of locales) { + const mod = await import(`../../shared/i18n/locales/${locale}/settings.json`); + const settings = mod.default ?? mod; + const themes = settings.themes; + + expect(themes, `${locale} missing themes section`).toBeDefined(); + expect(themes.dark_mode, `${locale} missing dark_mode`).toBeTruthy(); + expect(themes.light_mode, `${locale} missing light_mode`).toBeTruthy(); + expect(themes.vibe_mode, `${locale} missing vibe_mode`).toBeTruthy(); + } + }); + + it('mode labels differ from English in at least one non-English locale', async () => { + const settingsEn = await import('../../shared/i18n/locales/en/settings.json'); + const enThemes = (settingsEn as any).default?.themes ?? (settingsEn as any).themes; + + // Check Spanish as a representative non-English locale + const settingsEs = await import('../../shared/i18n/locales/es/settings.json'); + const esThemes = (settingsEs as any).default?.themes ?? (settingsEs as any).themes; + + // At least one label should be translated (not identical to English) + const anyTranslated = + esThemes.dark_mode !== enThemes.dark_mode || + esThemes.light_mode !== enThemes.light_mode || + esThemes.vibe_mode !== enThemes.vibe_mode; + + expect(anyTranslated).toBe(true); + }); + }); + + // ----------------------------------------------------------------------- + // 5. CSS custom properties contain both theme colors and RTL direction + // props simultaneously + // ----------------------------------------------------------------------- + describe('CSS custom properties coexist: theme + RTL', () => { + it('theme and RTL property sets have zero overlap', () => { + const themeProps = ['--accent-color', '--highlight-color']; + const rtlProps = ['--dir-start', '--dir-end', '--rtl-sign']; + + const overlap = themeProps.filter((p) => rtlProps.includes(p)); + expect(overlap).toHaveLength(0); + }); + + it('simultaneous theme + Arabic RTL properties all readable on :root', () => { + const root = document.documentElement; + + // Simulate useThemeStyles setting theme colors + root.style.setProperty('--accent-color', '#bd93f9'); + root.style.setProperty('--highlight-color', '#bd93f9'); + + // Simulate DirectionProvider setting RTL for Arabic + root.dir = 'rtl'; + root.lang = 'ar'; + root.setAttribute('data-dir', 'rtl'); + root.style.setProperty('--dir-start', 'right'); + root.style.setProperty('--dir-end', 'left'); + + // All five CSS custom properties coexist + expect(root.style.getPropertyValue('--accent-color')).toBe('#bd93f9'); + expect(root.style.getPropertyValue('--highlight-color')).toBe('#bd93f9'); + expect(root.style.getPropertyValue('--dir-start')).toBe('right'); + expect(root.style.getPropertyValue('--dir-end')).toBe('left'); + expect(root.dir).toBe('rtl'); + expect(root.lang).toBe('ar'); + }); + + it('re-applying theme colors does not clear RTL properties', () => { + const root = document.documentElement; + + // Set RTL first + root.style.setProperty('--dir-start', 'right'); + root.style.setProperty('--dir-end', 'left'); + + // Re-apply theme (simulating theme switch) + root.style.setProperty('--accent-color', '#88c0d0'); + root.style.setProperty('--highlight-color', '#88c0d0'); + + expect(root.style.getPropertyValue('--dir-start')).toBe('right'); + expect(root.style.getPropertyValue('--dir-end')).toBe('left'); + }); + + it('switching from RTL to LTR does not clear theme properties', () => { + const root = document.documentElement; + + // Set theme + RTL + root.style.setProperty('--accent-color', '#ff79c6'); + root.style.setProperty('--highlight-color', '#ff79c6'); + root.dir = 'rtl'; + root.style.setProperty('--dir-start', 'right'); + root.style.setProperty('--dir-end', 'left'); + + // Switch back to LTR (Arabic โ†’ English) + root.dir = 'ltr'; + root.style.setProperty('--dir-start', 'left'); + root.style.setProperty('--dir-end', 'right'); + + // Theme colors remain + expect(root.style.getPropertyValue('--accent-color')).toBe('#ff79c6'); + expect(root.style.getPropertyValue('--highlight-color')).toBe('#ff79c6'); + }); + }); + + // ----------------------------------------------------------------------- + // 6. Mermaid cache key is language-independent + // ----------------------------------------------------------------------- + describe('Mermaid cache key is language-independent', () => { + it('theme.name is a proper noun string that does not change with language', () => { + // All 17 theme names are English proper nouns / brand names + const themeNames = Object.values(THEMES).map((t) => t.name); + + for (const name of themeNames) { + expect(typeof name).toBe('string'); + expect(name.length).toBeGreaterThan(0); + // Theme names should NOT contain i18n key patterns (namespace:key.path) + expect(name).not.toMatch(/^[a-z]+:/); + expect(name).not.toMatch(/\./); + } + }); + + it('theme.name remains stable across simulated language switches', () => { + // MermaidRenderer uses `theme.name` as a cache key. + // Switching languages must not change theme.name values. + const draculaName = THEMES.dracula.name; + const nordName = THEMES.nord.name; + + // Simulate language switches (these should be no-ops for theme names) + useSettingsStore.getState().setLanguage('es'); + expect(THEMES.dracula.name).toBe(draculaName); + expect(THEMES.nord.name).toBe(nordName); + + useSettingsStore.getState().setLanguage('ar'); + expect(THEMES.dracula.name).toBe(draculaName); + expect(THEMES.nord.name).toBe(nordName); + + useSettingsStore.getState().setLanguage('zh'); + expect(THEMES.dracula.name).toBe(draculaName); + expect(THEMES.nord.name).toBe(nordName); + }); + + it('theme IDs are stable English strings usable as cache keys', () => { + const themeIds = Object.keys(THEMES); + + expect(themeIds.length).toBeGreaterThanOrEqual(17); + + for (const id of themeIds) { + // IDs should be lowercase kebab-case English strings + expect(id).toMatch(/^[a-z0-9-]+$/); + } + }); + + it('different themes produce different cache keys', () => { + const names = Object.values(THEMES).map((t) => t.name); + const uniqueNames = new Set(names); + + // Every theme should have a unique name (cache key) + expect(uniqueNames.size).toBe(names.length); + }); + }); + + // ----------------------------------------------------------------------- + // Combined rapid-switching regression + // ----------------------------------------------------------------------- + describe('rapid interleaved theme + language switching', () => { + it('preserves final values after rapid alternation', () => { + const store = useSettingsStore.getState(); + + store.setActiveThemeId('dracula' as any); + store.setLanguage('es'); + store.setActiveThemeId('nord' as any); + store.setLanguage('ar'); + store.setActiveThemeId('github-light' as any); + store.setLanguage('de'); + store.setActiveThemeId('tokyo-night' as any); + store.setLanguage('zh'); + + const final = useSettingsStore.getState(); + expect(final.activeThemeId).toBe('tokyo-night'); + expect(final.language).toBe('zh'); + }); + + it('each setting type has independent persistence calls', () => { + const store = useSettingsStore.getState(); + + store.setActiveThemeId('nord' as any); + store.setLanguage('fr'); + store.setActiveThemeId('dracula' as any); + store.setLanguage('de'); + + const themeCalls = settingsSetCalls.filter((c) => c.key === 'activeThemeId'); + const langCalls = settingsSetCalls.filter((c) => c.key === 'language'); + + expect(themeCalls).toHaveLength(2); + expect(langCalls).toHaveLength(2); + + // No cross-contamination + expect(themeCalls.every((c) => typeof c.value === 'string')).toBe(true); + expect(langCalls.every((c) => typeof c.value === 'string')).toBe(true); + }); + }); +}); diff --git a/src/__tests__/i18n/i18n-theme-interaction.test.ts b/src/__tests__/i18n/i18n-theme-interaction.test.ts new file mode 100644 index 0000000000..dcf7bef5a8 --- /dev/null +++ b/src/__tests__/i18n/i18n-theme-interaction.test.ts @@ -0,0 +1,266 @@ +/** + * i18n โ†” Theme Interaction Tests + * + * Verifies that theme switching and language switching do not conflict. + * Both settings use independent per-key persistence via window.maestro.settings.set(), + * so changing one must never clobber the other. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Mock the i18n config module so setLanguage's i18n.changeLanguage() doesn't +// require a fully initialized i18next instance with resource stores. +vi.mock('../../shared/i18n/config', () => ({ + default: { + changeLanguage: vi.fn().mockResolvedValue(undefined), + language: 'en', + }, + LANGUAGE_STORAGE_KEY: 'maestro-language', + RTL_LANGUAGES: ['ar'], + SUPPORTED_LANGUAGES: ['en', 'es', 'fr', 'de', 'zh', 'hi', 'ar', 'bn', 'pt'], +})); + +describe('i18n โ†” Theme Interaction', () => { + let settingsSetCalls: Array<{ key: string; value: unknown }>; + let useSettingsStore: any; + let loadAllSettings: any; + + beforeEach(async () => { + settingsSetCalls = []; + // Track all settings.set calls to verify per-key persistence + vi.mocked(window.maestro.settings.set).mockImplementation( + async (key: string, value: unknown) => { + settingsSetCalls.push({ key, value }); + return undefined; + } + ); + + // Import the store (cached module, same instance across tests) + const mod = await import('../../renderer/stores/settingsStore'); + useSettingsStore = mod.useSettingsStore; + loadAllSettings = mod.loadAllSettings; + + // Reset store to defaults before each test + useSettingsStore.setState({ + activeThemeId: 'dracula', + language: 'en', + settingsLoaded: false, + }); + }); + + describe('per-key persistence isolation', () => { + it('setActiveThemeId persists only the activeThemeId key', () => { + const store = useSettingsStore.getState(); + store.setActiveThemeId('nord' as any); + + const themeSetCalls = settingsSetCalls.filter((c: any) => c.key === 'activeThemeId'); + expect(themeSetCalls).toHaveLength(1); + expect(themeSetCalls[0].value).toBe('nord'); + + // language was NOT touched + const langSetCalls = settingsSetCalls.filter((c: any) => c.key === 'language'); + expect(langSetCalls).toHaveLength(0); + }); + + it('setLanguage persists only the language key', () => { + const store = useSettingsStore.getState(); + store.setLanguage('es'); + + const langSetCalls = settingsSetCalls.filter((c: any) => c.key === 'language'); + expect(langSetCalls).toHaveLength(1); + expect(langSetCalls[0].value).toBe('es'); + + // activeThemeId was NOT touched + const themeSetCalls = settingsSetCalls.filter((c: any) => c.key === 'activeThemeId'); + expect(themeSetCalls).toHaveLength(0); + }); + }); + + describe('theme change does not reset language', () => { + it('switching theme preserves language in Zustand state', () => { + useSettingsStore.setState({ language: 'es' }); + + const store = useSettingsStore.getState(); + store.setActiveThemeId('nord' as any); + + expect(useSettingsStore.getState().language).toBe('es'); + }); + }); + + describe('language change does not reset theme', () => { + it('switching language preserves activeThemeId in Zustand state', () => { + useSettingsStore.setState({ activeThemeId: 'nord' as any }); + + const store = useSettingsStore.getState(); + store.setLanguage('fr'); + + expect(useSettingsStore.getState().activeThemeId).toBe('nord'); + }); + }); + + describe('rapid switching does not cause race conditions', () => { + it('alternating theme and language changes preserves both final values', () => { + const store = useSettingsStore.getState(); + + // Rapid interleaved switching + store.setActiveThemeId('dracula' as any); + store.setLanguage('es'); + store.setActiveThemeId('nord' as any); + store.setLanguage('fr'); + store.setActiveThemeId('tokyo-night' as any); + store.setLanguage('de'); + + const finalState = useSettingsStore.getState(); + expect(finalState.activeThemeId).toBe('tokyo-night'); + expect(finalState.language).toBe('de'); + + // Each setting should have been persisted independently + const themeSetCalls = settingsSetCalls.filter((c: any) => c.key === 'activeThemeId'); + const langSetCalls = settingsSetCalls.filter((c: any) => c.key === 'language'); + expect(themeSetCalls).toHaveLength(3); + expect(langSetCalls).toHaveLength(3); + }); + }); + + describe('loadAllSettings restores both settings independently', () => { + it('loads Spanish + Nord theme from persisted store without conflict', async () => { + vi.mocked(window.maestro.settings.getAll).mockResolvedValueOnce({ + activeThemeId: 'nord', + language: 'es', + }); + + await loadAllSettings(); + + const state = useSettingsStore.getState(); + expect(state.activeThemeId).toBe('nord'); + expect(state.language).toBe('es'); + expect(state.settingsLoaded).toBe(true); + }); + + it('loads Arabic + custom theme from persisted store', async () => { + vi.mocked(window.maestro.settings.getAll).mockResolvedValueOnce({ + activeThemeId: 'custom', + language: 'ar', + }); + + await loadAllSettings(); + + const state = useSettingsStore.getState(); + expect(state.activeThemeId).toBe('custom'); + expect(state.language).toBe('ar'); + }); + }); + + describe('document attributes are independent of theme', () => { + it('theme change does not modify document direction attributes', () => { + const root = document.documentElement; + + // Set Arabic RTL direction (simulating language switch) + root.dir = 'rtl'; + root.lang = 'ar'; + root.setAttribute('data-dir', 'rtl'); + root.style.setProperty('--dir-start', 'right'); + root.style.setProperty('--dir-end', 'left'); + + // Now switch theme + const store = useSettingsStore.getState(); + store.setActiveThemeId('dracula' as any); + + // RTL direction attributes must be untouched + expect(root.dir).toBe('rtl'); + expect(root.lang).toBe('ar'); + expect(root.getAttribute('data-dir')).toBe('rtl'); + expect(root.style.getPropertyValue('--dir-start')).toBe('right'); + expect(root.style.getPropertyValue('--dir-end')).toBe('left'); + }); + }); + + describe('CSS custom property coexistence (theme โ†” RTL)', () => { + it('theme and RTL properties have no name collisions', () => { + // Theme properties set by useThemeStyles + const themeProps = ['--accent-color', '--highlight-color']; + // RTL properties set by DirectionProvider and index.css + const rtlProps = ['--dir-start', '--dir-end', '--rtl-sign']; + + const overlap = themeProps.filter((p) => rtlProps.includes(p)); + expect(overlap).toHaveLength(0); + }); + + it('applying theme colors does not clear RTL properties', () => { + const root = document.documentElement; + + // Simulate DirectionProvider setting RTL properties + root.style.setProperty('--dir-start', 'right'); + root.style.setProperty('--dir-end', 'left'); + + // Simulate useThemeStyles re-applying theme colors + root.style.setProperty('--accent-color', '#88c0d0'); + root.style.setProperty('--highlight-color', '#88c0d0'); + + // RTL properties must still be present + expect(root.style.getPropertyValue('--dir-start')).toBe('right'); + expect(root.style.getPropertyValue('--dir-end')).toBe('left'); + }); + + it('applying RTL direction does not clear theme properties', () => { + const root = document.documentElement; + + // Simulate useThemeStyles setting theme colors + root.style.setProperty('--accent-color', '#ff79c6'); + root.style.setProperty('--highlight-color', '#ff79c6'); + + // Simulate DirectionProvider switching to RTL + root.dir = 'rtl'; + root.setAttribute('data-dir', 'rtl'); + root.style.setProperty('--dir-start', 'right'); + root.style.setProperty('--dir-end', 'left'); + + // Theme properties must still be present + expect(root.style.getPropertyValue('--accent-color')).toBe('#ff79c6'); + expect(root.style.getPropertyValue('--highlight-color')).toBe('#ff79c6'); + }); + + it('both theme and RTL properties coexist after Arabic + theme switch', () => { + const root = document.documentElement; + + // Full RTL setup (Arabic) + root.lang = 'ar'; + root.dir = 'rtl'; + root.setAttribute('data-dir', 'rtl'); + root.style.setProperty('--dir-start', 'right'); + root.style.setProperty('--dir-end', 'left'); + + // Full theme setup (Nord-like accent) + root.style.setProperty('--accent-color', '#88c0d0'); + root.style.setProperty('--highlight-color', '#88c0d0'); + + // All properties coexist + expect(root.style.getPropertyValue('--accent-color')).toBe('#88c0d0'); + expect(root.style.getPropertyValue('--highlight-color')).toBe('#88c0d0'); + expect(root.style.getPropertyValue('--dir-start')).toBe('right'); + expect(root.style.getPropertyValue('--dir-end')).toBe('left'); + expect(root.dir).toBe('rtl'); + }); + }); + + describe('scrollbar styling is direction-independent', () => { + it('scrollbar CSS uses color variables, not directional properties', () => { + // The scrollbar thumb styling references --highlight-color and --accent-color + // which are pure color values with no directional component. + // This test documents the invariant: scrollbar color is theme-driven, + // scrollbar position is browser-driven (auto-flips in RTL in Chromium). + const root = document.documentElement; + + // Set RTL + theme + root.dir = 'rtl'; + root.style.setProperty('--accent-color', '#ff79c6'); + root.style.setProperty('--highlight-color', '#ff79c6'); + + // Accent color is a color value, not a position โ€” works identically in RTL + const accent = root.style.getPropertyValue('--accent-color'); + expect(accent).toBe('#ff79c6'); + // No directional substring should appear in the color value + expect(accent).not.toMatch(/left|right|start|end/); + }); + }); +}); diff --git a/src/__tests__/i18n/i18n-web-theme-integration.test.ts b/src/__tests__/i18n/i18n-web-theme-integration.test.ts new file mode 100644 index 0000000000..1891f29b71 --- /dev/null +++ b/src/__tests__/i18n/i18n-web-theme-integration.test.ts @@ -0,0 +1,280 @@ +/** + * i18n โ†” Web ThemeProvider Integration Tests + * + * Verifies that the web client's ThemeProvider and i18n language/direction + * systems are independent and do not interfere with each other. + * + * Covers: + * 1. Web CSS custom properties (--maestro-*) don't assume LTR layout + * 2. Theme properties and RTL direction properties coexist on :root + * 3. applyLanguageDirection sets correct attributes without touching theme + * 4. injectCSSProperties doesn't clear direction properties + * 5. Language broadcast message type is handled correctly + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { + generateCSSProperties, + generateCSSString, + injectCSSProperties, + removeCSSProperties, + THEME_CSS_PROPERTIES, +} from '../../web/utils/cssCustomProperties'; +import { RTL_LANGUAGES, SUPPORTED_LANGUAGES } from '../../shared/i18n/config'; +import type { Theme } from '../../shared/theme-types'; + +/** Test theme matching the Dracula defaults used by ThemeProvider */ +const testDarkTheme: Theme = { + id: 'dracula', + name: 'Dracula', + mode: 'dark', + colors: { + bgMain: '#0b0b0d', + bgSidebar: '#111113', + bgActivity: '#1c1c1f', + border: '#27272a', + textMain: '#e4e4e7', + textDim: '#a1a1aa', + accent: '#6366f1', + accentDim: 'rgba(99, 102, 241, 0.2)', + accentText: '#a5b4fc', + accentForeground: '#0b0b0d', + success: '#22c55e', + warning: '#eab308', + error: '#ef4444', + }, +}; + +const testLightTheme: Theme = { + id: 'github-light', + name: 'GitHub', + mode: 'light', + colors: { + bgMain: '#ffffff', + bgSidebar: '#f6f8fa', + bgActivity: '#eff2f5', + border: '#d0d7de', + textMain: '#24292f', + textDim: '#57606a', + accent: '#0969da', + accentDim: 'rgba(9, 105, 218, 0.1)', + accentText: '#0969da', + accentForeground: '#ffffff', + success: '#1a7f37', + warning: '#9a6700', + error: '#cf222e', + }, +}; + +/** + * Simulates the applyLanguageDirection function from web/App.tsx. + * Duplicated here to test the logic independently of React. + */ +function applyLanguageDirection(language: string): void { + const isRtl = (RTL_LANGUAGES as readonly string[]).includes(language); + const dir = isRtl ? 'rtl' : 'ltr'; + + document.documentElement.dir = dir; + document.documentElement.lang = language; + document.documentElement.setAttribute('data-dir', dir); + document.documentElement.style.setProperty('--dir-start', isRtl ? 'right' : 'left'); + document.documentElement.style.setProperty('--dir-end', isRtl ? 'left' : 'right'); +} + +describe('Web ThemeProvider i18n Compatibility', () => { + beforeEach(() => { + // Reset document attributes + const root = document.documentElement; + root.dir = ''; + root.lang = ''; + root.removeAttribute('data-dir'); + root.style.removeProperty('--dir-start'); + root.style.removeProperty('--dir-end'); + // Clean up injected style elements + removeCSSProperties(); + }); + + describe('CSS custom properties are layout-agnostic', () => { + it('generated property names use --maestro- prefix, not directional names', () => { + const props = generateCSSProperties(testDarkTheme); + const propNames = Object.keys(props); + + for (const name of propNames) { + expect(name).toMatch(/^--maestro-/); + // No directional suffixes like -left, -right, -start, -end + expect(name).not.toMatch(/-(left|right|start|end)$/); + } + }); + + it('generated property values are colors and mode, not directional', () => { + const props = generateCSSProperties(testDarkTheme); + const values = Object.values(props); + + for (const value of values) { + // Values should be hex colors, rgba(), or mode strings + expect(value).not.toMatch(/^(left|right|ltr|rtl)$/); + } + }); + + it('THEME_CSS_PROPERTIES list has no overlap with RTL properties', () => { + const rtlProps = ['--dir-start', '--dir-end', '--rtl-sign']; + const overlap = THEME_CSS_PROPERTIES.filter((p) => rtlProps.includes(p)); + expect(overlap).toHaveLength(0); + }); + + it('generateCSSString targets :root by default, not [dir]', () => { + const css = generateCSSString(testDarkTheme); + expect(css).toMatch(/^:root \{/); + expect(css).not.toMatch(/\[dir/); + }); + }); + + describe('applyLanguageDirection sets correct attributes', () => { + it('sets LTR attributes for English', () => { + applyLanguageDirection('en'); + const root = document.documentElement; + expect(root.dir).toBe('ltr'); + expect(root.lang).toBe('en'); + expect(root.getAttribute('data-dir')).toBe('ltr'); + expect(root.style.getPropertyValue('--dir-start')).toBe('left'); + expect(root.style.getPropertyValue('--dir-end')).toBe('right'); + }); + + it('sets RTL attributes for Arabic', () => { + applyLanguageDirection('ar'); + const root = document.documentElement; + expect(root.dir).toBe('rtl'); + expect(root.lang).toBe('ar'); + expect(root.getAttribute('data-dir')).toBe('rtl'); + expect(root.style.getPropertyValue('--dir-start')).toBe('right'); + expect(root.style.getPropertyValue('--dir-end')).toBe('left'); + }); + + it('sets LTR for all non-Arabic supported languages', () => { + const ltrLanguages = SUPPORTED_LANGUAGES.filter((l) => l !== 'ar'); + for (const lang of ltrLanguages) { + applyLanguageDirection(lang); + expect(document.documentElement.dir).toBe('ltr'); + } + }); + }); + + describe('theme and direction properties coexist', () => { + it('injectCSSProperties does not clear direction properties', () => { + const root = document.documentElement; + + // Set direction properties first (simulating Arabic language switch) + applyLanguageDirection('ar'); + + // Now inject theme CSS properties + injectCSSProperties(testDarkTheme); + + // Direction properties must still be present + expect(root.dir).toBe('rtl'); + expect(root.lang).toBe('ar'); + expect(root.getAttribute('data-dir')).toBe('rtl'); + expect(root.style.getPropertyValue('--dir-start')).toBe('right'); + expect(root.style.getPropertyValue('--dir-end')).toBe('left'); + }); + + it('applyLanguageDirection does not clear theme style element', () => { + // Inject theme first + injectCSSProperties(testDarkTheme); + + // Verify theme style element exists + const styleEl = document.getElementById('maestro-theme-css-properties'); + expect(styleEl).not.toBeNull(); + + // Apply language direction + applyLanguageDirection('ar'); + + // Theme style element must still exist with content + const styleElAfter = document.getElementById('maestro-theme-css-properties'); + expect(styleElAfter).not.toBeNull(); + expect(styleElAfter!.textContent).toContain('--maestro-bg-main'); + expect(styleElAfter!.textContent).toContain('--maestro-accent'); + }); + + it('theme switch does not alter direction attributes', () => { + const root = document.documentElement; + + // Set Arabic direction + applyLanguageDirection('ar'); + + // Switch theme from dark to light + injectCSSProperties(testDarkTheme); + injectCSSProperties(testLightTheme); + + // Direction attributes untouched + expect(root.dir).toBe('rtl'); + expect(root.lang).toBe('ar'); + expect(root.style.getPropertyValue('--dir-start')).toBe('right'); + }); + + it('language switch does not alter theme CSS variables', () => { + // Inject dark theme + injectCSSProperties(testDarkTheme); + + // Switch language from English to Arabic + applyLanguageDirection('en'); + applyLanguageDirection('ar'); + + // Theme style element still has dark theme values + const styleEl = document.getElementById('maestro-theme-css-properties'); + expect(styleEl!.textContent).toContain(testDarkTheme.colors.bgMain); + expect(styleEl!.textContent).toContain(testDarkTheme.colors.accent); + }); + }); + + describe('web ThemeProvider uses style element injection (not inline styles)', () => { + it('injectCSSProperties creates a