Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/e2e-staging.yml
Original file line number Diff line number Diff line change
@@ -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
74 changes: 74 additions & 0 deletions frontend/I18N_GUIDE.md
Original file line number Diff line number Diff line change
@@ -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
40 changes: 34 additions & 6 deletions frontend/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { defineConfig, devices } from '@playwright/test';

const isStaging = !!process.env.STAGING_URL;

export default defineConfig({
testDir: './e2e',
fullyParallel: true,
Expand All @@ -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,
},
});
121 changes: 121 additions & 0 deletions frontend/src/lib/__tests__/i18n.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading