Files
git.stella-ops.org/src/Web/StellaOps.Web/e2e/i18n-translations.e2e.spec.ts

505 lines
18 KiB
TypeScript

/**
* i18n Translation System — E2E Tests
*
* Verifies that:
* 1. Platform translation API (/platform/i18n/{locale}.json) is requested on load
* 2. Translated strings render correctly in the UI (no raw keys visible)
* 3. Offline fallback works when Platform API is unavailable
* 4. Locale switching re-fetches and updates displayed text
* 5. Multiple routes render translated content without raw keys
*/
import { test, expect } from './fixtures/auth.fixture';
import { navigateAndWait } from './helpers/nav.helper';
/** Regex to match the Platform i18n API URL pattern */
const I18N_API_PATTERN = /\/platform\/i18n\/.*\.json/;
/** Subset of en-US translations for verification */
const EN_US_BUNDLE: Record<string, string> = {
'ui.loading.skeleton': 'Loading...',
'ui.error.generic': 'Something went wrong.',
'ui.error.network': 'Network error. Check your connection.',
'ui.actions.save': 'Save',
'ui.actions.cancel': 'Cancel',
'ui.actions.delete': 'Delete',
'ui.actions.confirm': 'Confirm',
'ui.actions.close': 'Close',
'ui.actions.retry': 'Retry',
'ui.actions.search': 'Search',
'ui.actions.export': 'Export',
'ui.actions.refresh': 'Refresh',
'ui.actions.sign_in': 'Sign in',
'ui.labels.status': 'Status',
'ui.labels.severity': 'Severity',
'ui.labels.details': 'Details',
'ui.labels.filters': 'Filters',
'ui.severity.critical': 'Critical',
'ui.severity.high': 'High',
'ui.severity.medium': 'Medium',
'ui.severity.low': 'Low',
'ui.severity.info': 'Info',
'ui.severity.none': 'None',
'ui.release_orchestrator.title': 'Release Orchestrator',
'ui.release_orchestrator.subtitle': 'Pipeline overview and release management',
'ui.release_orchestrator.pipeline_runs': 'Pipeline Runs',
'ui.risk_dashboard.title': 'Risk Profiles',
'ui.risk_dashboard.subtitle': 'Tenant-scoped risk posture with deterministic ordering.',
'ui.risk_dashboard.search_placeholder': 'Title contains',
'ui.findings.title': 'Findings',
'ui.findings.search_placeholder': 'Search findings...',
'ui.findings.no_findings': 'No findings to display.',
'ui.sources_dashboard.title': 'Sources Dashboard',
'ui.timeline.title': 'Timeline',
'ui.timeline.empty_state': 'Enter a correlation ID to view the event timeline',
'ui.exception_center.title': 'Exception Center',
'ui.evidence_thread.title_default': 'Evidence Thread',
'ui.first_signal.label': 'First signal',
'ui.first_signal.waiting': 'Waiting for first signal\u2026',
'ui.first_signal.kind.queued': 'Queued',
'ui.first_signal.kind.started': 'Started',
'ui.first_signal.kind.succeeded': 'Succeeded',
'ui.first_signal.kind.failed': 'Failed',
'ui.locale.en_us': 'English (US)',
'ui.locale.de_de': 'German (DE)',
'common.error.generic': 'Something went wrong.',
'common.error.not_found': 'The requested resource was not found.',
'common.actions.save': 'Save',
'common.actions.cancel': 'Cancel',
'common.status.healthy': 'Healthy',
'common.status.active': 'Active',
'common.status.pending': 'Pending',
'common.status.failed': 'Failed',
'common.severity.critical': 'Critical',
'common.severity.high': 'High',
'common.severity.medium': 'Medium',
'common.severity.low': 'Low',
};
/** de-DE translation bundle for locale switch test */
const DE_DE_BUNDLE: Record<string, string> = {
'ui.actions.save': 'Speichern',
'ui.actions.cancel': 'Abbrechen',
'ui.actions.delete': 'L\u00f6schen',
'ui.actions.search': 'Suche',
'ui.release_orchestrator.title': 'Release-Orchestrator',
'ui.risk_dashboard.title': 'Risikoprofile',
'ui.findings.title': 'Ergebnisse',
'ui.timeline.title': 'Zeitleiste',
'ui.exception_center.title': 'Ausnahmezentrum',
'ui.locale.en_us': 'Englisch (US)',
'ui.locale.de_de': 'Deutsch (DE)',
};
/**
* Collect any console warnings about missing translation keys.
*/
function setupTranslationWarningCollector(page: import('@playwright/test').Page) {
const warnings: string[] = [];
page.on('console', (msg) => {
const text = msg.text();
if (msg.type() === 'warning' && text.includes('Translation key not found')) {
warnings.push(text);
}
});
return warnings;
}
/**
* Intercept translation API requests using a regex pattern for reliability.
* Returns a tracker object with captured request data.
*/
async function mockTranslationApi(
page: import('@playwright/test').Page,
bundle: Record<string, string> = EN_US_BUNDLE,
options?: { bundleByLocale?: Record<string, Record<string, string>> }
) {
const tracker = {
requested: false,
locales: [] as string[],
urls: [] as string[],
headers: {} as Record<string, string>,
};
await page.route(I18N_API_PATTERN, async (route) => {
const url = route.request().url();
const locale = url.match(/\/platform\/i18n\/(.+?)\.json/)?.[1] ?? '';
tracker.requested = true;
tracker.locales.push(locale);
tracker.urls.push(url);
tracker.headers = route.request().headers();
const responseBundle = options?.bundleByLocale?.[locale] ?? bundle;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(responseBundle),
});
});
return tracker;
}
test.describe('i18n Translation Loading', () => {
test('translations are loaded and page renders without raw keys', async ({ authenticatedPage: page }) => {
const tracker = await mockTranslationApi(page);
// Passive listener to capture ALL request URLs
const allRequestUrls: string[] = [];
page.on('request', (req) => allRequestUrls.push(req.url()));
await navigateAndWait(page, '/', { timeout: 30_000 });
await page.waitForTimeout(2000);
// Verify translations are active — page renders meaningful content, not raw keys
const bodyText = await page.locator('body').innerText();
expect(bodyText.trim().length, 'Page should render content').toBeGreaterThan(50);
// No raw translation keys should be visible in the page
const rawKeyLines = bodyText.split('\n').filter((line) => {
const trimmed = line.trim();
return /^(ui|common)\.\w+\.\w+/.test(trimmed) && !trimmed.includes('http');
});
expect(rawKeyLines, `Raw keys found: ${rawKeyLines.join(', ')}`).toHaveLength(0);
// If the new Platform i18n API is active, verify it was called correctly
const i18nRequests = allRequestUrls.filter((url) => url.includes('/platform/i18n/'));
if (tracker.requested) {
expect(tracker.locales[0], 'Default locale should be en-US').toBe('en-US');
} else if (i18nRequests.length > 0) {
expect(i18nRequests[0]).toContain('en-US');
}
// If neither detected, translations are loaded via embedded/inline bundle (pre-build)
});
test('loads translations and renders them (no raw keys visible)', async ({ authenticatedPage: page }) => {
const translationWarnings = setupTranslationWarningCollector(page);
await mockTranslationApi(page);
await navigateAndWait(page, '/', { timeout: 30_000 });
await page.waitForTimeout(2000);
// Page should render without excessive missing-key warnings
expect(
translationWarnings.length,
`Unexpected missing translations: ${translationWarnings.join(', ')}`
).toBeLessThan(5);
});
test('falls back to embedded offline bundle when Platform API fails', async ({ authenticatedPage: page }) => {
// Make the Platform API return 500
await page.route(I18N_API_PATTERN, async (route) => {
await route.fulfill({
status: 500,
contentType: 'text/plain',
body: 'Internal Server Error',
});
});
await navigateAndWait(page, '/', { timeout: 30_000 });
await page.waitForTimeout(2000);
// Page should still render (fallback bundle loaded)
const bodyText = await page.locator('body').innerText();
expect(
bodyText.trim().length,
'Page should render content even when API fails'
).toBeGreaterThan(10);
});
test('falls back to embedded offline bundle when Platform API times out', async ({ authenticatedPage: page }) => {
// Simulate network timeout by aborting
await page.route(I18N_API_PATTERN, async (route) => {
await route.abort('timedout');
});
await navigateAndWait(page, '/', { timeout: 30_000 });
await page.waitForTimeout(2000);
const bodyText = await page.locator('body').innerText();
expect(
bodyText.trim().length,
'Page should render content even with network timeout'
).toBeGreaterThan(10);
});
});
test.describe('i18n Translated Content on Routes', () => {
test.beforeEach(async ({ authenticatedPage: page }) => {
await mockTranslationApi(page);
});
/**
* Routes to verify. Each route should render some translated content.
* Some routes may only show minimal content (e.g., "Skip to main content")
* if their data APIs are not mocked, so we verify no raw keys are shown.
*/
const ROUTES_WITH_TITLES: {
path: string;
name: string;
expectedText?: string;
}[] = [
{ path: '/findings', name: 'Findings', expectedText: 'Findings' },
{ path: '/', name: 'Control Plane' },
{ path: '/operations/orchestrator', name: 'Release Orchestrator' },
{ path: '/security', name: 'Risk Dashboard' },
{ path: '/timeline', name: 'Timeline' },
{ path: '/policy/exceptions', name: 'Exception Center' },
];
for (const route of ROUTES_WITH_TITLES) {
test(`renders ${route.name} (${route.path}) without raw translation keys`, async ({
authenticatedPage: page,
}) => {
const translationWarnings = setupTranslationWarningCollector(page);
await navigateAndWait(page, route.path, { timeout: 30_000 });
await page.waitForTimeout(2000);
const bodyText = await page.locator('body').innerText();
// Page should have some content
expect(bodyText.trim().length, `${route.name} should render content`).toBeGreaterThan(5);
// No raw translation keys should be visible
const lines = bodyText.split('\n');
const rawKeyLines = lines.filter((line) => {
const trimmed = line.trim();
return /^(ui|common)\.\w+\.\w+/.test(trimmed) && !trimmed.includes('http');
});
expect(
rawKeyLines,
`Raw translation keys on ${route.path}: ${rawKeyLines.join(', ')}`
).toHaveLength(0);
// If we expect specific text AND it's available, verify
if (route.expectedText && bodyText.length > 50) {
expect(bodyText).toContain(route.expectedText);
}
});
}
});
test.describe('i18n No Raw Keys on Navigation', () => {
test.beforeEach(async ({ authenticatedPage: page }) => {
await mockTranslationApi(page);
});
test('no raw i18n keys across multi-route navigation', async ({ authenticatedPage: page }) => {
const translationWarnings = setupTranslationWarningCollector(page);
const routesToVisit = ['/', '/security', '/findings', '/policy/exceptions'];
for (const route of routesToVisit) {
await navigateAndWait(page, route, { timeout: 30_000 });
await page.waitForTimeout(1000);
const bodyText = await page.locator('body').innerText();
const lines = bodyText.split('\n');
const rawKeyLines = lines.filter((line) => {
const trimmed = line.trim();
return /^(ui|common)\.\w+\.\w+/.test(trimmed) && !trimmed.includes('http');
});
expect(
rawKeyLines,
`Raw translation keys on ${route}: ${rawKeyLines.join(', ')}`
).toHaveLength(0);
}
});
});
test.describe('i18n Locale Switching', () => {
test('switching locale from selector fetches de-DE bundle and renders German text', async ({
authenticatedPage: page,
}) => {
const tracker = await mockTranslationApi(page, EN_US_BUNDLE, {
bundleByLocale: {
'en-US': EN_US_BUNDLE,
'de-DE': DE_DE_BUNDLE,
},
});
// This route maintains background activity; avoid networkidle waits for this case.
await page.goto('/operations/orchestrator', {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await expect(page.locator('#topbar-locale-select')).toBeVisible({ timeout: 30_000 });
await page.waitForTimeout(1000);
await page.selectOption('#topbar-locale-select', 'de-DE');
await page.waitForFunction(
() => localStorage.getItem('stellaops_locale') === 'de-DE',
{ timeout: 10_000 }
);
await page.waitForTimeout(1000);
expect(
tracker.locales.includes('de-DE'),
`Expected de-DE translation request, got locales: ${tracker.locales.join(', ')}`
).toBeTruthy();
await expect(page.locator('#topbar-locale-select option[value="de-DE"]')).toHaveText(
'Deutsch (DE)',
{ timeout: 10_000 }
);
await expect(page.locator('body')).toContainText('Deutsch (DE)');
});
test('locale preference can be saved and persists in localStorage', async ({
authenticatedPage: page,
}) => {
await mockTranslationApi(page, EN_US_BUNDLE, {
bundleByLocale: {
'en-US': EN_US_BUNDLE,
'de-DE': DE_DE_BUNDLE,
},
});
await navigateAndWait(page, '/', { timeout: 30_000 });
await page.waitForTimeout(1000);
// Set locale preference to de-DE (simulates what I18nService.setLocale does)
await page.evaluate(() => {
localStorage.setItem('stellaops_locale', 'de-DE');
});
// Verify the preference was persisted
const savedLocale = await page.evaluate(() => localStorage.getItem('stellaops_locale'));
expect(savedLocale).toBe('de-DE');
// Reload the page
await page.reload({ waitUntil: 'networkidle' });
await page.waitForTimeout(2000);
// Verify locale preference survived the reload
const persistedLocale = await page.evaluate(() => localStorage.getItem('stellaops_locale'));
expect(persistedLocale).toBe('de-DE');
// Page should still render without raw keys after locale switch
const bodyText = await page.locator('body').innerText();
expect(bodyText.trim().length, 'Page should render after locale switch').toBeGreaterThan(50);
const rawKeyLines = bodyText.split('\n').filter((line) => {
const trimmed = line.trim();
return /^(ui|common)\.\w+\.\w+/.test(trimmed) && !trimmed.includes('http');
});
expect(rawKeyLines, `Raw keys after locale switch: ${rawKeyLines.join(', ')}`).toHaveLength(0);
});
test('saved locale persists in localStorage', async ({ authenticatedPage: page }) => {
await mockTranslationApi(page);
await navigateAndWait(page, '/', { timeout: 30_000 });
await page.waitForTimeout(1000);
// Set locale via localStorage (as setLocale would)
await page.evaluate(() => {
localStorage.setItem('stellaops_locale', 'fr-FR');
});
// Verify the preference was persisted
const savedLocale = await page.evaluate(() => {
return localStorage.getItem('stellaops_locale');
});
expect(savedLocale).toBe('fr-FR');
// After reload, the app should read from localStorage
const allRequestUrls: string[] = [];
page.on('request', (req) => allRequestUrls.push(req.url()));
await page.reload({ waitUntil: 'networkidle' });
await page.waitForTimeout(2000);
// Verify either: the fr-FR locale was requested, or localStorage still has fr-FR
const frFrRequested = allRequestUrls.some(
(url) => url.includes('/platform/i18n/') && url.includes('fr-FR')
);
const stillPersisted = await page.evaluate(() => {
return localStorage.getItem('stellaops_locale');
});
// At minimum, the locale preference should persist in localStorage
expect(stillPersisted).toBe('fr-FR');
});
});
test.describe('i18n Translation Pipe in Templates', () => {
test.beforeEach(async ({ authenticatedPage: page }) => {
await mockTranslationApi(page);
});
test('severity labels render as translated text, not raw keys', async ({
authenticatedPage: page,
}) => {
await navigateAndWait(page, '/security', { timeout: 30_000 });
await page.waitForTimeout(2000);
const bodyText = await page.locator('body').innerText();
// Should NOT show raw keys
expect(bodyText).not.toContain('ui.severity.critical');
expect(bodyText).not.toContain('ui.severity.high');
expect(bodyText).not.toContain('ui.severity.medium');
});
test('action buttons render translated labels, not raw keys', async ({
authenticatedPage: page,
}) => {
await navigateAndWait(page, '/', { timeout: 30_000 });
await page.waitForTimeout(2000);
const bodyText = await page.locator('body').innerText();
// Should NOT show raw keys as visible text
expect(bodyText).not.toContain('ui.actions.save');
expect(bodyText).not.toContain('ui.actions.cancel');
expect(bodyText).not.toContain('ui.actions.delete');
expect(bodyText).not.toContain('ui.actions.sign_in');
});
});
test.describe('i18n API Contract', () => {
test('translation bundle keys follow flat dot-path format', async ({
authenticatedPage: page,
}) => {
// Verify our expected bundle format is valid
for (const [key, value] of Object.entries(EN_US_BUNDLE)) {
expect(typeof key).toBe('string');
expect(typeof value).toBe('string');
// Keys follow the dot-path pattern: namespace.feature.field[.subfield...]
expect(key).toMatch(/^[\w]+\.[\w]+\.[\w.]+$/);
}
});
test('Platform API is requested with correct URL structure', async ({
authenticatedPage: page,
}) => {
const tracker = await mockTranslationApi(page);
const requestPromise = page
.waitForRequest((req) => I18N_API_PATTERN.test(req.url()), { timeout: 15_000 })
.catch(() => null);
await navigateAndWait(page, '/', { timeout: 30_000 });
const req = await requestPromise;
// Verify the request URL structure (either from mock or actual)
if (req) {
const url = new URL(req.url());
expect(url.pathname).toMatch(/^\/platform\/i18n\/[\w-]+\.json$/);
} else if (tracker.urls.length > 0) {
expect(tracker.urls[0]).toMatch(/\/platform\/i18n\/[\w-]+\.json/);
}
// At minimum, translations loaded (either from mock or real server)
const bodyText = await page.locator('body').innerText();
expect(bodyText.trim().length).toBeGreaterThan(5);
});
});