From 65a5d0fd877a1225414b44c78b13551c62ac0503 Mon Sep 17 00:00:00 2001 From: oncleweynom <140509299+oncleweynom@users.noreply.github.com> Date: Sat, 30 May 2026 18:43:57 +0000 Subject: [PATCH] fix(#654, #655): i18n graceful fallback + staging E2E Playwright project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add src/lib/i18n.ts with locale resolution chain (exact → subtag → en), missing-key warn in dev / silent in prod, and registerTranslations helper - Add frontend/I18N_GUIDE.md documenting the i18n approach - Add src/lib/__tests__/i18n.test.ts covering all fallback scenarios - Add 'staging' Playwright project activated via STAGING_URL env var - Add .github/workflows/e2e-staging.yml that runs on merge to main only --- .github/workflows/e2e-staging.yml | 49 ++++++++++ frontend/I18N_GUIDE.md | 74 +++++++++++++++ frontend/playwright.config.ts | 40 ++++++-- frontend/src/lib/__tests__/i18n.test.ts | 121 ++++++++++++++++++++++++ frontend/src/lib/i18n.ts | 95 +++++++++++++++++++ 5 files changed, 373 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/e2e-staging.yml create mode 100644 frontend/I18N_GUIDE.md create mode 100644 frontend/src/lib/__tests__/i18n.test.ts create mode 100644 frontend/src/lib/i18n.ts diff --git a/.github/workflows/e2e-staging.yml b/.github/workflows/e2e-staging.yml new file mode 100644 index 00000000..47ec5aeb --- /dev/null +++ b/.github/workflows/e2e-staging.yml @@ -0,0 +1,49 @@ +name: Staging E2E Tests + +# Runs only on direct pushes to main (i.e. after a PR is merged). +# Not triggered on pull_request to avoid running against staging on every PR. +on: + push: + branches: + - main + paths: + - 'frontend/**' + +jobs: + e2e-staging: + name: Playwright — Staging + runs-on: ubuntu-latest + + defaults: + run: + working-directory: frontend + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Run staging E2E suite + run: npx playwright test --project=staging + env: + STAGING_URL: ${{ secrets.STAGING_URL }} + CI: 'true' + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-staging-report + path: frontend/playwright-report/ + retention-days: 14 diff --git a/frontend/I18N_GUIDE.md b/frontend/I18N_GUIDE.md new file mode 100644 index 00000000..fd4a8c4c --- /dev/null +++ b/frontend/I18N_GUIDE.md @@ -0,0 +1,74 @@ +# i18n Guide + +## Overview + +`src/lib/i18n.ts` provides a lightweight, dependency-free i18n utility for the PredictIQ frontend. + +## Locale Resolution + +When a locale is requested the library resolves it in this order: + +1. **Exact match** — `pt-BR` → uses `pt-BR` bundle if registered. +2. **Language subtag** — `pt-BR` → falls back to `pt` bundle. +3. **Hard fallback** — always falls back to `en`. + +No errors are thrown for unsupported locales; the caller always receives a string. + +## Usage + +```ts +import { t, detectLocale, resolveLocale } from '@/lib/i18n'; + +// Auto-detect from navigator.language +const label = t('nav.features'); + +// Explicit locale +const label = t('nav.features', 'fr'); + +// Resolve what locale would actually be used +const locale = resolveLocale('zh-TW'); // → 'zh' or 'en' +``` + +## Adding a New Locale + +```ts +import { registerTranslations } from '@/lib/i18n'; + +registerTranslations('fr', { + 'nav.features': 'Fonctionnalités', + 'hero.title': 'Marchés de prédiction décentralisés', +}); +``` + +Call `registerTranslations` before any `t()` calls for that locale (e.g. in a layout component or route loader). + +## Missing Keys + +| Environment | Behaviour | +|-------------|-----------| +| `development` | `console.warn` + returns the key string | +| `production` | returns the key string silently | + +This means the UI never crashes or renders `undefined` — worst case it shows the raw key, which is a visible signal during development. + +## Adding Translation Keys + +All keys live in the `translations` object in `i18n.ts`. Use dot-notation namespacing: + +``` +nav.* Navigation labels +hero.* Hero section copy +newsletter.* Newsletter form copy +form.* Generic form validation messages +``` + +## Testing + +Unit tests live in `src/lib/__tests__/i18n.test.ts` and cover: + +- Exact locale match +- Language-subtag fallback (`pt-BR` → `pt`) +- Unknown locale fallback to `en` +- Missing key warning in development +- Missing key silent in production +- `registerTranslations` merging diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index c58a34c2..ecc4e8b6 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -1,5 +1,7 @@ import { defineConfig, devices } from '@playwright/test'; +const isStaging = !!process.env.STAGING_URL; + export default defineConfig({ testDir: './e2e', fullyParallel: true, @@ -19,35 +21,61 @@ export default defineConfig({ video: 'retain-on-failure', }, projects: [ + // ------------------------------------------------------------------ + // Local / PR projects (default) + // ------------------------------------------------------------------ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, + testIgnore: isStaging ? '**' : undefined, }, { name: 'firefox', use: { ...devices['Desktop Firefox'] }, + testIgnore: isStaging ? '**' : undefined, }, { name: 'webkit', use: { ...devices['Desktop Safari'] }, + testIgnore: isStaging ? '**' : undefined, }, { name: 'mobile-chrome', use: { ...devices['Pixel 5'] }, + testIgnore: isStaging ? '**' : undefined, }, { name: 'mobile-safari', use: { ...devices['iPhone 12'] }, + testIgnore: isStaging ? '**' : undefined, }, { name: 'tablet', use: { ...devices['iPad Pro'] }, + testIgnore: isStaging ? '**' : undefined, + }, + + // ------------------------------------------------------------------ + // Staging project — activated when STAGING_URL is set. + // Runs against a real API; no local web server is started. + // ------------------------------------------------------------------ + { + name: 'staging', + use: { + ...devices['Desktop Chrome'], + baseURL: process.env.STAGING_URL, + }, + testIgnore: isStaging ? undefined : '**', }, ], - webServer: { - command: 'npm run dev', - url: 'http://localhost:3000', - reuseExistingServer: !process.env.CI, - timeout: 120000, - }, + + // Only start the local dev server when NOT running against staging. + webServer: isStaging + ? undefined + : { + command: 'npm run dev', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, }); diff --git a/frontend/src/lib/__tests__/i18n.test.ts b/frontend/src/lib/__tests__/i18n.test.ts new file mode 100644 index 00000000..5114415b --- /dev/null +++ b/frontend/src/lib/__tests__/i18n.test.ts @@ -0,0 +1,121 @@ +import { resolveLocale, t, registerTranslations, translations } from '../i18n'; + +// --------------------------------------------------------------------------- +// resolveLocale +// --------------------------------------------------------------------------- +describe('resolveLocale', () => { + it('returns the locale when it is supported', () => { + expect(resolveLocale('en')).toBe('en'); + }); + + it('falls back to language subtag when full tag is unsupported', () => { + registerTranslations('pt', { 'nav.features': 'Recursos' }); + expect(resolveLocale('pt-BR')).toBe('pt'); + }); + + it('falls back to "en" for a completely unknown locale', () => { + expect(resolveLocale('xx-YY')).toBe('en'); + }); + + it('falls back to "en" for an empty string', () => { + expect(resolveLocale('')).toBe('en'); + }); +}); + +// --------------------------------------------------------------------------- +// t() — happy path +// --------------------------------------------------------------------------- +describe('t() — known keys', () => { + it('returns the translation for a known key in "en"', () => { + expect(t('nav.features', 'en')).toBe('Features'); + }); + + it('resolves an unsupported locale to "en" and returns the translation', () => { + expect(t('nav.features', 'zz')).toBe('Features'); + }); + + it('uses a registered locale when available', () => { + registerTranslations('de', { 'nav.features': 'Funktionen' }); + expect(t('nav.features', 'de')).toBe('Funktionen'); + }); + + it('falls back to "en" value when key is missing in the resolved locale', () => { + registerTranslations('es', {}); // empty — no keys + expect(t('nav.features', 'es')).toBe('Features'); + }); +}); + +// --------------------------------------------------------------------------- +// t() — missing keys +// --------------------------------------------------------------------------- +describe('t() — missing keys', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + afterEach(() => warnSpy.mockClear()); + afterAll(() => warnSpy.mockRestore()); + + it('returns the key itself when the key does not exist', () => { + expect(t('nonexistent.key', 'en')).toBe('nonexistent.key'); + }); + + it('logs a warning in development for a missing key', () => { + const original = process.env.NODE_ENV; + Object.defineProperty(process.env, 'NODE_ENV', { value: 'development', writable: true }); + + t('missing.key', 'en'); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('missing.key') + ); + + Object.defineProperty(process.env, 'NODE_ENV', { value: original, writable: true }); + }); + + it('does NOT log a warning in production for a missing key', () => { + const original = process.env.NODE_ENV; + Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', writable: true }); + + t('another.missing.key', 'en'); + + expect(warnSpy).not.toHaveBeenCalled(); + + Object.defineProperty(process.env, 'NODE_ENV', { value: original, writable: true }); + }); +}); + +// --------------------------------------------------------------------------- +// registerTranslations +// --------------------------------------------------------------------------- +describe('registerTranslations', () => { + it('merges new keys into an existing locale', () => { + registerTranslations('en', { 'test.merge': 'Merged' }); + expect(t('test.merge', 'en')).toBe('Merged'); + }); + + it('creates a new locale bundle when the locale did not exist', () => { + registerTranslations('ja', { 'nav.features': '機能' }); + expect(t('nav.features', 'ja')).toBe('機能'); + }); + + it('does not overwrite unrelated keys in the same locale', () => { + const before = t('hero.title', 'en'); + registerTranslations('en', { 'test.extra': 'Extra' }); + expect(t('hero.title', 'en')).toBe(before); + }); +}); + +// --------------------------------------------------------------------------- +// Fallback chain integration +// --------------------------------------------------------------------------- +describe('fallback chain integration', () => { + it('pt-BR → pt → en when only "en" is registered', () => { + // Ensure pt is not registered for this key + delete (translations as any)['pt']; + expect(t('hero.cta', 'pt-BR')).toBe('Get Early Access'); + }); + + it('pt-BR → pt when "pt" has the key', () => { + registerTranslations('pt', { 'hero.cta': 'Obter acesso antecipado' }); + expect(t('hero.cta', 'pt-BR')).toBe('Obter acesso antecipado'); + }); +}); diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts new file mode 100644 index 00000000..0011dd99 --- /dev/null +++ b/frontend/src/lib/i18n.ts @@ -0,0 +1,95 @@ +/** + * Minimal i18n utility with graceful locale fallback. + * + * Resolution order: + * 1. Requested locale + * 2. Language-only tag (e.g. "pt" from "pt-BR") + * 3. "en" (hard fallback) + * + * Missing keys: + * - Development: console.warn + return key + * - Production: return key silently + */ + +export type Locale = string; +export type TranslationMap = Record; +export type Translations = Record; + +const FALLBACK_LOCALE = 'en'; + +// --------------------------------------------------------------------------- +// Built-in translations (extend as needed) +// --------------------------------------------------------------------------- +const translations: Translations = { + en: { + 'nav.features': 'Features', + 'nav.how_it_works': 'How It Works', + 'nav.about': 'About', + 'nav.contact': 'Contact', + 'hero.title': 'Decentralized Prediction Markets', + 'hero.cta': 'Get Early Access', + 'newsletter.placeholder': 'Enter your email', + 'newsletter.success': 'Successfully subscribed to updates!', + 'form.email_required': 'Email is required', + 'form.email_invalid': 'Please enter a valid email address', + }, +}; + +// --------------------------------------------------------------------------- +// Supported locale resolution +// --------------------------------------------------------------------------- + +/** Returns the best available locale key for the given tag. */ +export function resolveLocale(requested: string): Locale { + if (translations[requested]) return requested; + + // Try language-only subtag (e.g. "pt" from "pt-BR") + const lang = requested.split('-')[0]; + if (lang !== requested && translations[lang]) return lang; + + return FALLBACK_LOCALE; +} + +/** Detects the browser's preferred locale and resolves it. */ +export function detectLocale(): Locale { + if (typeof navigator === 'undefined') return FALLBACK_LOCALE; + const preferred = navigator.language || FALLBACK_LOCALE; + return resolveLocale(preferred); +} + +// --------------------------------------------------------------------------- +// Translation lookup +// --------------------------------------------------------------------------- + +/** + * Returns the translation for `key` in `locale`. + * Falls back to `en` for missing keys and warns in development. + */ +export function t(key: string, locale: Locale = detectLocale()): string { + const resolved = resolveLocale(locale); + const map = translations[resolved] ?? translations[FALLBACK_LOCALE]; + + if (key in map) return map[key]; + + // Try the hard fallback map if we weren't already using it + if (resolved !== FALLBACK_LOCALE && key in translations[FALLBACK_LOCALE]) { + return translations[FALLBACK_LOCALE][key]; + } + + if (process.env.NODE_ENV !== 'production') { + console.warn(`[i18n] Missing translation key "${key}" for locale "${locale}"`); + } + + return key; +} + +// --------------------------------------------------------------------------- +// Registration helper (for lazy-loaded locale bundles) +// --------------------------------------------------------------------------- + +/** Registers additional translations. Merges into existing locale maps. */ +export function registerTranslations(locale: Locale, map: TranslationMap): void { + translations[locale] = { ...(translations[locale] ?? {}), ...map }; +} + +export { translations };