mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-13 17:52:38 +02:00
feat: add e2e test cursor skill
This commit is contained in:
parent
2e1b9b5582
commit
4ac994792b
45 changed files with 39848 additions and 0 deletions
622
.cursor/skills/playwright-testing/i18n-and-localization.md
Executable file
622
.cursor/skills/playwright-testing/i18n-and-localization.md
Executable file
|
|
@ -0,0 +1,622 @@
|
|||
# Internationalization and Localization Testing
|
||||
|
||||
> **When to use**: Verifying your application works correctly across locales, languages, text directions, date/number formats, and timezones. Catches layout breaks, missing translations, and format errors before they reach international users.
|
||||
> **Prerequisites**: [core/configuration.md](configuration.md), [core/locators.md](locators.md)
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```typescript
|
||||
// Set locale and timezone per context
|
||||
const context = await browser.newContext({
|
||||
locale: 'de-DE',
|
||||
timezoneId: 'Europe/Berlin',
|
||||
});
|
||||
|
||||
// Or in playwright.config.ts for project-level locale testing
|
||||
projects: [
|
||||
{ name: 'english', use: { locale: 'en-US', timezoneId: 'America/New_York' } },
|
||||
{ name: 'german', use: { locale: 'de-DE', timezoneId: 'Europe/Berlin' } },
|
||||
{ name: 'arabic', use: { locale: 'ar-SA', timezoneId: 'Asia/Riyadh' } },
|
||||
],
|
||||
```
|
||||
|
||||
## Patterns
|
||||
|
||||
### Setting Browser Locale
|
||||
|
||||
**Use when**: Testing locale-dependent rendering -- date formats, number formatting, currency, sorting, and browser-level localization.
|
||||
**Avoid when**: Your app does not use the browser locale and instead relies on a user preference stored server-side.
|
||||
|
||||
**TypeScript**
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('locale-specific formatting', () => {
|
||||
test('US locale formats dates as MM/DD/YYYY', async ({ browser }) => {
|
||||
const context = await browser.newContext({ locale: 'en-US' });
|
||||
const page = await context.newPage();
|
||||
await page.goto('/dashboard');
|
||||
|
||||
// Verify date format matches US convention
|
||||
await expect(page.getByTestId('last-updated')).toHaveText(/\d{1,2}\/\d{1,2}\/\d{4}/);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('German locale formats dates as DD.MM.YYYY', async ({ browser }) => {
|
||||
const context = await browser.newContext({ locale: 'de-DE' });
|
||||
const page = await context.newPage();
|
||||
await page.goto('/dashboard');
|
||||
|
||||
await expect(page.getByTestId('last-updated')).toHaveText(/\d{1,2}\.\d{1,2}\.\d{4}/);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('Japanese locale formats numbers with commas', async ({ browser }) => {
|
||||
const context = await browser.newContext({ locale: 'ja-JP' });
|
||||
const page = await context.newPage();
|
||||
await page.goto('/pricing');
|
||||
|
||||
// Japanese yen: no decimal places, uses comma grouping
|
||||
await expect(page.getByTestId('price')).toHaveText(/[\d,]+円/);
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**JavaScript**
|
||||
```javascript
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test('German locale formats dates as DD.MM.YYYY', async ({ browser }) => {
|
||||
const context = await browser.newContext({ locale: 'de-DE' });
|
||||
const page = await context.newPage();
|
||||
await page.goto('/dashboard');
|
||||
|
||||
await expect(page.getByTestId('last-updated')).toHaveText(/\d{1,2}\.\d{1,2}\.\d{4}/);
|
||||
await context.close();
|
||||
});
|
||||
```
|
||||
|
||||
### Multi-Locale Project Configuration
|
||||
|
||||
**Use when**: Running the full test suite across multiple locales in CI.
|
||||
**Avoid when**: You only need to test a single locale or the app does not vary by locale.
|
||||
|
||||
**TypeScript**
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: 'en-US',
|
||||
use: {
|
||||
locale: 'en-US',
|
||||
timezoneId: 'America/New_York',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'de-DE',
|
||||
use: {
|
||||
locale: 'de-DE',
|
||||
timezoneId: 'Europe/Berlin',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ar-SA',
|
||||
use: {
|
||||
locale: 'ar-SA',
|
||||
timezoneId: 'Asia/Riyadh',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ja-JP',
|
||||
use: {
|
||||
locale: 'ja-JP',
|
||||
timezoneId: 'Asia/Tokyo',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
**JavaScript**
|
||||
```javascript
|
||||
// playwright.config.js
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
|
||||
module.exports = defineConfig({
|
||||
projects: [
|
||||
{ name: 'en-US', use: { locale: 'en-US', timezoneId: 'America/New_York' } },
|
||||
{ name: 'de-DE', use: { locale: 'de-DE', timezoneId: 'Europe/Berlin' } },
|
||||
{ name: 'ar-SA', use: { locale: 'ar-SA', timezoneId: 'Asia/Riyadh' } },
|
||||
{ name: 'ja-JP', use: { locale: 'ja-JP', timezoneId: 'Asia/Tokyo' } },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### RTL Layout Testing
|
||||
|
||||
**Use when**: Your app supports right-to-left languages (Arabic, Hebrew, Persian, Urdu) and you need to verify layout direction, text alignment, and mirrored UI.
|
||||
**Avoid when**: Your app has no RTL support.
|
||||
|
||||
**TypeScript**
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('RTL layout', () => {
|
||||
test('Arabic locale renders RTL layout', async ({ browser }) => {
|
||||
const context = await browser.newContext({ locale: 'ar-SA' });
|
||||
const page = await context.newPage();
|
||||
await page.goto('/');
|
||||
|
||||
// Verify the document direction
|
||||
const dir = await page.getAttribute('html', 'dir');
|
||||
expect(dir).toBe('rtl');
|
||||
|
||||
// Verify navigation is right-aligned
|
||||
const nav = page.getByRole('navigation', { name: 'Main' });
|
||||
const navBox = await nav.boundingBox();
|
||||
const viewportWidth = page.viewportSize()!.width;
|
||||
// Nav should start from the right side
|
||||
expect(navBox!.x + navBox!.width).toBeGreaterThan(viewportWidth * 0.5);
|
||||
|
||||
// Verify text alignment
|
||||
const heading = page.getByRole('heading', { level: 1 });
|
||||
const textAlign = await heading.evaluate((el) =>
|
||||
window.getComputedStyle(el).textAlign
|
||||
);
|
||||
expect(textAlign).toMatch(/right|start/);
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('RTL layout does not cause horizontal overflow', async ({ browser }) => {
|
||||
const context = await browser.newContext({ locale: 'ar-SA' });
|
||||
const page = await context.newPage();
|
||||
await page.goto('/dashboard');
|
||||
|
||||
// Check for horizontal scrollbar (content overflow)
|
||||
const hasHorizontalOverflow = await page.evaluate(() => {
|
||||
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
|
||||
});
|
||||
expect(hasHorizontalOverflow).toBe(false);
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('icons and directional elements are mirrored in RTL', async ({ browser }) => {
|
||||
const context = await browser.newContext({ locale: 'ar-SA' });
|
||||
const page = await context.newPage();
|
||||
await page.goto('/dashboard');
|
||||
|
||||
// Back arrow should point right in RTL
|
||||
const backButton = page.getByRole('button', { name: /back|رجوع/i });
|
||||
const transform = await backButton.evaluate((el) =>
|
||||
window.getComputedStyle(el).transform
|
||||
);
|
||||
// CSS transform for horizontal flip: matrix(-1, 0, 0, 1, 0, 0) or scaleX(-1)
|
||||
// Or check the logical property direction
|
||||
expect(transform).not.toBe('none');
|
||||
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**JavaScript**
|
||||
```javascript
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test('Arabic locale renders RTL layout', async ({ browser }) => {
|
||||
const context = await browser.newContext({ locale: 'ar-SA' });
|
||||
const page = await context.newPage();
|
||||
await page.goto('/');
|
||||
|
||||
const dir = await page.getAttribute('html', 'dir');
|
||||
expect(dir).toBe('rtl');
|
||||
|
||||
const hasHorizontalOverflow = await page.evaluate(() =>
|
||||
document.documentElement.scrollWidth > document.documentElement.clientWidth
|
||||
);
|
||||
expect(hasHorizontalOverflow).toBe(false);
|
||||
|
||||
await context.close();
|
||||
});
|
||||
```
|
||||
|
||||
### Date, Number, and Currency Format Verification
|
||||
|
||||
**Use when**: Your app uses `Intl.DateTimeFormat`, `Intl.NumberFormat`, or similar locale-sensitive APIs.
|
||||
**Avoid when**: Formats are hardcoded and do not depend on locale.
|
||||
|
||||
**TypeScript**
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const FORMAT_EXPECTATIONS = {
|
||||
'en-US': {
|
||||
date: /\d{1,2}\/\d{1,2}\/\d{4}/, // 1/15/2025
|
||||
number: /1,234,567\.89/, // 1,234,567.89
|
||||
currency: /\$[\d,]+\.\d{2}/, // $1,234.56
|
||||
},
|
||||
'de-DE': {
|
||||
date: /\d{1,2}\.\d{1,2}\.\d{4}/, // 15.1.2025
|
||||
number: /1\.234\.567,89/, // 1.234.567,89
|
||||
currency: /[\d.,]+\s?€/, // 1.234,56 €
|
||||
},
|
||||
'ja-JP': {
|
||||
date: /\d{4}\/\d{1,2}\/\d{1,2}/, // 2025/1/15
|
||||
number: /1,234,567\.89/, // 1,234,567.89
|
||||
currency: /[¥¥][\d,]+/, // ¥1,235
|
||||
},
|
||||
} as const;
|
||||
|
||||
for (const [locale, expected] of Object.entries(FORMAT_EXPECTATIONS)) {
|
||||
test.describe(`${locale} formatting`, () => {
|
||||
test(`dates match ${locale} format`, async ({ browser }) => {
|
||||
const context = await browser.newContext({ locale });
|
||||
const page = await context.newPage();
|
||||
await page.goto('/account');
|
||||
|
||||
const dateText = await page.getByTestId('member-since').textContent();
|
||||
expect(dateText).toMatch(expected.date);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test(`currency matches ${locale} format`, async ({ browser }) => {
|
||||
const context = await browser.newContext({ locale });
|
||||
const page = await context.newPage();
|
||||
await page.goto('/pricing');
|
||||
|
||||
const priceText = await page.getByTestId('monthly-price').textContent();
|
||||
expect(priceText).toMatch(expected.currency);
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**JavaScript**
|
||||
```javascript
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
const locales = [
|
||||
{ locale: 'en-US', datePattern: /\d{1,2}\/\d{1,2}\/\d{4}/, currencyPattern: /\$[\d,]+\.\d{2}/ },
|
||||
{ locale: 'de-DE', datePattern: /\d{1,2}\.\d{1,2}\.\d{4}/, currencyPattern: /[\d.,]+\s?€/ },
|
||||
];
|
||||
|
||||
for (const { locale, datePattern, currencyPattern } of locales) {
|
||||
test(`${locale} date format`, async ({ browser }) => {
|
||||
const context = await browser.newContext({ locale });
|
||||
const page = await context.newPage();
|
||||
await page.goto('/account');
|
||||
|
||||
const dateText = await page.getByTestId('member-since').textContent();
|
||||
expect(dateText).toMatch(datePattern);
|
||||
await context.close();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Language Switcher Testing
|
||||
|
||||
**Use when**: Your app has an in-app language selector that changes the UI language without depending on browser locale.
|
||||
**Avoid when**: Language is determined purely by browser locale with no user override.
|
||||
|
||||
**TypeScript**
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('language switcher changes UI language', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Default language
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText('Welcome');
|
||||
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
|
||||
|
||||
// Switch to French
|
||||
await page.getByRole('combobox', { name: /language|langue/i }).selectOption('fr');
|
||||
|
||||
// UI should update to French
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText('Bienvenue');
|
||||
await expect(page.getByRole('button', { name: 'Se connecter' })).toBeVisible();
|
||||
|
||||
// Verify language persists after navigation
|
||||
await page.getByRole('link', { name: /about|à propos/i }).click();
|
||||
await expect(page.getByRole('heading', { level: 1 })).not.toContainText('About');
|
||||
});
|
||||
|
||||
test('language preference persists across sessions', async ({ page, context }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('combobox', { name: /language/i }).selectOption('es');
|
||||
await expect(page.getByRole('button', { name: 'Iniciar sesión' })).toBeVisible();
|
||||
|
||||
// Reload page — language should persist (cookie/localStorage)
|
||||
await page.reload();
|
||||
await expect(page.getByRole('button', { name: 'Iniciar sesión' })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
**JavaScript**
|
||||
```javascript
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test('language switcher changes UI language', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText('Welcome');
|
||||
|
||||
await page.getByRole('combobox', { name: /language/i }).selectOption('fr');
|
||||
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText('Bienvenue');
|
||||
await expect(page.getByRole('button', { name: 'Se connecter' })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Translation Completeness Checks
|
||||
|
||||
**Use when**: Verifying that all visible UI strings are translated and no fallback keys leak into the UI.
|
||||
**Avoid when**: You have a build-time translation validation step that already catches missing keys.
|
||||
|
||||
**TypeScript**
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const LANGUAGES = ['en', 'fr', 'de', 'es', 'ja'];
|
||||
const PAGES_TO_CHECK = ['/', '/login', '/dashboard', '/settings', '/pricing'];
|
||||
|
||||
for (const lang of LANGUAGES) {
|
||||
test.describe(`${lang} translation completeness`, () => {
|
||||
for (const pagePath of PAGES_TO_CHECK) {
|
||||
test(`no missing translations on ${pagePath}`, async ({ page }) => {
|
||||
// Set language via URL param, cookie, or language switcher
|
||||
await page.goto(`${pagePath}?lang=${lang}`);
|
||||
|
||||
// Check for common translation key leak patterns
|
||||
const pageText = await page.textContent('body');
|
||||
|
||||
// Translation keys typically look like: key.subkey, UPPER_SNAKE_CASE, or {{key}}
|
||||
expect(pageText).not.toMatch(/\b[a-z]+\.[a-z]+\.[a-z]+\b/); // dot.notation.keys
|
||||
expect(pageText).not.toContain('{{'); // Unresolved templates
|
||||
expect(pageText).not.toContain('}}');
|
||||
expect(pageText).not.toMatch(/\bTODO\b/i); // Placeholder text
|
||||
|
||||
// Check for untranslated English text when in non-English locale
|
||||
if (lang !== 'en') {
|
||||
// These common English strings should be translated
|
||||
const untranslated = ['Sign in', 'Log out', 'Settings', 'Dashboard', 'Submit'];
|
||||
for (const text of untranslated) {
|
||||
const count = await page.getByText(text, { exact: true }).count();
|
||||
// Allow 0 matches (element not on page) but flag exact English matches
|
||||
if (count > 0) {
|
||||
console.warn(`Possible untranslated text "${text}" found on ${pagePath} for ${lang}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**JavaScript**
|
||||
```javascript
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
const LANGUAGES = ['en', 'fr', 'de'];
|
||||
const PAGES = ['/', '/login', '/dashboard'];
|
||||
|
||||
for (const lang of LANGUAGES) {
|
||||
for (const pagePath of PAGES) {
|
||||
test(`${lang}: no missing translations on ${pagePath}`, async ({ page }) => {
|
||||
await page.goto(`${pagePath}?lang=${lang}`);
|
||||
|
||||
const pageText = await page.textContent('body');
|
||||
expect(pageText).not.toMatch(/\b[a-z]+\.[a-z]+\.[a-z]+\b/);
|
||||
expect(pageText).not.toContain('{{');
|
||||
expect(pageText).not.toContain('}}');
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Timezone Testing
|
||||
|
||||
**Use when**: Your app displays time-sensitive data (event times, deadlines, scheduling) and you need to verify correct timezone rendering.
|
||||
**Avoid when**: All times are displayed in UTC with no local conversion.
|
||||
|
||||
**TypeScript**
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('timezone rendering', () => {
|
||||
test('event times adjust to user timezone', async ({ browser }) => {
|
||||
// Event stored as 2025-03-15T14:00:00Z (2 PM UTC)
|
||||
const contextNY = await browser.newContext({
|
||||
locale: 'en-US',
|
||||
timezoneId: 'America/New_York', // UTC-5 in March (EST)
|
||||
});
|
||||
const pageNY = await contextNY.newPage();
|
||||
await pageNY.goto('/events/123');
|
||||
// Should display 9:00 AM
|
||||
await expect(pageNY.getByTestId('event-time')).toContainText('9:00 AM');
|
||||
await contextNY.close();
|
||||
|
||||
const contextTokyo = await browser.newContext({
|
||||
locale: 'en-US',
|
||||
timezoneId: 'Asia/Tokyo', // UTC+9
|
||||
});
|
||||
const pageTokyo = await contextTokyo.newPage();
|
||||
await pageTokyo.goto('/events/123');
|
||||
// Should display 11:00 PM
|
||||
await expect(pageTokyo.getByTestId('event-time')).toContainText('11:00 PM');
|
||||
await contextTokyo.close();
|
||||
});
|
||||
|
||||
test('deadline displays correctly across DST boundary', async ({ browser }) => {
|
||||
const context = await browser.newContext({
|
||||
locale: 'en-US',
|
||||
timezoneId: 'America/Los_Angeles',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
// Test a deadline that crosses DST (March second Sunday)
|
||||
await page.goto('/tasks?deadline=2025-03-10T07:00:00Z');
|
||||
// Before DST: UTC-8, so 11 PM on March 9
|
||||
await expect(page.getByTestId('deadline')).toContainText('Mar 9');
|
||||
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**JavaScript**
|
||||
```javascript
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test('event times adjust to user timezone', async ({ browser }) => {
|
||||
const context = await browser.newContext({
|
||||
locale: 'en-US',
|
||||
timezoneId: 'America/New_York',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto('/events/123');
|
||||
await expect(page.getByTestId('event-time')).toContainText('9:00 AM');
|
||||
await context.close();
|
||||
});
|
||||
```
|
||||
|
||||
### Multi-Language Screenshot Comparison
|
||||
|
||||
**Use when**: Catching visual layout regressions caused by text expansion, RTL mirroring, or font rendering differences across locales.
|
||||
**Avoid when**: Visual regression testing is handled separately and locale is not a layout risk.
|
||||
|
||||
**TypeScript**
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const LOCALES_TO_SCREENSHOT = [
|
||||
{ locale: 'en-US', name: 'english' },
|
||||
{ locale: 'de-DE', name: 'german' }, // German text is ~30% longer than English
|
||||
{ locale: 'ja-JP', name: 'japanese' }, // CJK characters, different font metrics
|
||||
{ locale: 'ar-SA', name: 'arabic' }, // RTL layout
|
||||
];
|
||||
|
||||
for (const { locale, name } of LOCALES_TO_SCREENSHOT) {
|
||||
test(`visual snapshot for ${name} (${locale})`, async ({ browser }) => {
|
||||
const context = await browser.newContext({
|
||||
locale,
|
||||
viewport: { width: 1280, height: 720 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto('/');
|
||||
|
||||
// Wait for fonts and images to load
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(page).toHaveScreenshot(`homepage-${name}.png`, {
|
||||
maxDiffPixelRatio: 0.02, // Allow 2% pixel difference for font rendering
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await context.close();
|
||||
});
|
||||
}
|
||||
|
||||
test('long German text does not overflow buttons', async ({ browser }) => {
|
||||
const context = await browser.newContext({ locale: 'de-DE' });
|
||||
const page = await context.newPage();
|
||||
await page.goto('/dashboard');
|
||||
|
||||
// Check that no button has overflowing text
|
||||
const buttons = page.getByRole('button');
|
||||
const count = await buttons.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const button = buttons.nth(i);
|
||||
const overflow = await button.evaluate((el) => {
|
||||
const style = window.getComputedStyle(el);
|
||||
return el.scrollWidth > el.clientWidth && style.overflow !== 'hidden';
|
||||
});
|
||||
if (overflow) {
|
||||
const text = await button.textContent();
|
||||
expect(overflow, `Button "${text}" overflows in German`).toBe(false);
|
||||
}
|
||||
}
|
||||
|
||||
await context.close();
|
||||
});
|
||||
```
|
||||
|
||||
**JavaScript**
|
||||
```javascript
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
const LOCALES = [
|
||||
{ locale: 'en-US', name: 'english' },
|
||||
{ locale: 'de-DE', name: 'german' },
|
||||
{ locale: 'ar-SA', name: 'arabic' },
|
||||
];
|
||||
|
||||
for (const { locale, name } of LOCALES) {
|
||||
test(`visual snapshot for ${name}`, async ({ browser }) => {
|
||||
const context = await browser.newContext({ locale, viewport: { width: 1280, height: 720 } });
|
||||
const page = await context.newPage();
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(page).toHaveScreenshot(`homepage-${name}.png`, {
|
||||
maxDiffPixelRatio: 0.02,
|
||||
fullPage: true,
|
||||
});
|
||||
await context.close();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Decision Guide
|
||||
|
||||
| Scenario | Approach | Why |
|
||||
|---|---|---|
|
||||
| Test locale-dependent formatting | Set `locale` in browser context | Playwright sets `navigator.language` and affects `Intl` APIs |
|
||||
| Test app language switcher | Interact with the switcher UI directly | Tests the actual user workflow, not just browser locale |
|
||||
| Test timezone rendering | Set `timezoneId` in browser context | Overrides `Date` and `Intl.DateTimeFormat` timezone |
|
||||
| Catch text overflow from long translations | Visual regression + bounding box checks | German/Finnish text is 30-40% longer than English |
|
||||
| Verify RTL layout | Set Arabic/Hebrew locale + assert `dir="rtl"` | Tests both browser signal and app response |
|
||||
| Catch missing translations | Scan page text for key patterns (`{{`, dot notation) | Catches build/deploy issues where translation files are missing |
|
||||
| Compare layouts across locales | `toHaveScreenshot` per locale with project-based config | Captures visual differences automatically |
|
||||
| Test DST edge cases | Set specific `timezoneId` + known date boundaries | DST boundaries cause the most timezone bugs |
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Don't Do This | Problem | Do This Instead |
|
||||
|---|---|---|
|
||||
| Hardcoding translated text in assertions | Breaks when translations change | Use `getByRole` with `name` regex, or test IDs for locale-independent selection |
|
||||
| Testing only `en-US` | Misses RTL, text overflow, and format bugs | Test at least one LTR, one RTL, and one CJK locale |
|
||||
| Setting `locale` on the page instead of context | `locale` is a context-level option, not page-level | Set locale when creating the context or in project config |
|
||||
| Ignoring text expansion for German/Finnish | Buttons and labels overflow in longer languages | Use visual regression or bounding box assertions |
|
||||
| Using `toHaveScreenshot` without `maxDiffPixelRatio` | Font rendering differs slightly across OS/CI | Set `maxDiffPixelRatio: 0.02` or higher for cross-platform tolerance |
|
||||
| Testing timezone only in UTC | Masks timezone conversion bugs | Test with at least 3 timezones: UTC-negative, UTC, UTC-positive |
|
||||
| Relying on browser locale for app language | Many apps use server-side or cookie-based language | Test via the app's own language switching mechanism |
|
||||
| Not testing DST boundaries | Time displayed can be off by 1 hour at transitions | Test dates near DST transitions for your target timezones |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely Cause | Fix |
|
||||
|---|---|---|
|
||||
| `locale` option has no effect | App ignores `navigator.language` and uses server-side locale | Set locale through the app's own mechanism (cookie, URL param, API) |
|
||||
| Date format does not change with locale | App hardcodes date format instead of using `Intl.DateTimeFormat` | This is an app bug to fix, not a test issue |
|
||||
| RTL page still renders LTR | App checks locale but does not set `dir="rtl"` | Verify the app's RTL detection logic; may need to set `Accept-Language` header |
|
||||
| Visual screenshots fail across OS | Font rendering differs between macOS, Linux, Windows | Run visual tests in Docker for consistency, or increase `maxDiffPixelRatio` |
|
||||
| `timezoneId` does not affect page | App uses server time, not client `Date` | Timezone testing only works for client-side date rendering |
|
||||
| Translation key appears briefly then disappears | Translation files load asynchronously | Wait for `networkidle` or a specific translated element before asserting |
|
||||
| Character encoding issues (garbled text) | Incorrect charset in HTML or missing font | Verify `<meta charset="utf-8">` and that CJK/Arabic fonts are available |
|
||||
|
||||
## Related
|
||||
|
||||
- [core/locators.md](locators.md) -- locator strategies that work across locales
|
||||
- [core/configuration.md](configuration.md) -- project-level locale and timezone configuration
|
||||
- [core/visual-regression.md](visual-regression.md) -- screenshot comparison fundamentals
|
||||
- [core/clock-and-time-mocking.md](clock-and-time-mocking.md) -- mocking time for date-dependent testing
|
||||
- [ci/docker-and-containers.md](../ci/docker-and-containers.md) -- consistent font rendering in CI with Docker
|
||||
Loading…
Add table
Add a link
Reference in a new issue