Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,245 @@
/**
* @suaveplan/react-i18n — Playwright E2E sweep.
*
* Covers the five user-visible behaviours required by sub-wave 1g:
*
* 1. Live language switch through 5 locales with localStorage persistence
* 2. RTL flip on Arabic — `<html dir="rtl">` + visible reflow
* 3. Lazy-namespace Suspense — `<I18nSuspense>` fallback resolves to
* translated content
* 4. Missing-key overlay (dev mode) — capped at 50 entries with
* `aria-live="polite"`
* 5. SSR hydration round-trip — server-rendered HTML hydrates without
* a React warning AND without a flash of untranslated content
*
* Per `CLAUDE.md` "Web App & Page Verification": every scenario also
* asserts (a) zero console errors, (b) zero failed network requests
* (4xx/5xx), (c) the rendered DOM contains the expected content (not
* merely a 200 response).
*/
import { expect, test } from "@playwright/test";
import {
attachConsoleMonitor,
attachNetworkMonitor,
} from "@suaveplan/testing-e2e";
const LOCALES = ["en-GB", "ar-AE", "zh-CN", "cy-GB", "ru-RU"] as const;
const GREETING_BY_LOCALE: Record<(typeof LOCALES)[number], string> = {
"en-GB": "Hello, world",
"ar-AE": "مرحبا بالعالم",
"zh-CN": "你好,世界",
"cy-GB": "Helo, byd",
"ru-RU": "Привет, мир",
};
const SIGN_IN_BY_LOCALE: Record<(typeof LOCALES)[number], string> = {
"en-GB": "Sign in",
"ar-AE": "تسجيل الدخول",
"zh-CN": "登录",
"cy-GB": "Mewngofnodi",
"ru-RU": "Войти",
};
// Each Playwright test runs in its own browser context with its own
// localStorage namespace by default — no per-test clearing needed.
test.describe("react-i18n live behaviour", () => {
test("cycles through all five locales and persists choice", async ({
page,
}) => {
const consoleMonitor = attachConsoleMonitor(page, {
failOn: ["error"],
// happy-dom-style hydration warnings can leak as React's
// "Warning:" via console.error — we treat any error as a
// failure but tolerate React DevTools install hints which
// arrive as info.
ignore: [/Download the React DevTools/i],
});
const networkMonitor = attachNetworkMonitor(page);
await page.goto("/");
await expect(page.getByTestId("greeting")).toHaveText(
GREETING_BY_LOCALE["en-GB"]
);
await expect(page.getByTestId("auth-signin")).toHaveText(
SIGN_IN_BY_LOCALE["en-GB"]
);
for (const locale of LOCALES.slice(1)) {
await page
.getByTestId("language-switcher")
.selectOption(locale);
await expect(page.getByTestId("greeting")).toHaveText(
GREETING_BY_LOCALE[locale]
);
await expect(page.getByTestId("auth-signin")).toHaveText(
SIGN_IN_BY_LOCALE[locale]
);
await expect(page.getByTestId("current-locale")).toContainText(
locale
);
}
// Persistence: capture the persisted entry, reload, and confirm
// the App initialises from localStorage (not the en-GB default).
const lastLocale = LOCALES[LOCALES.length - 1];
const persistedBefore = await page.evaluate(() =>
window.localStorage.getItem("suaveplan-react-i18n-e2e-locale")
);
expect(persistedBefore).toBe(lastLocale);
await page.reload();
await expect(page.getByTestId("greeting")).toHaveText(
GREETING_BY_LOCALE[lastLocale]
);
const persistedAfter = await page.evaluate(() =>
window.localStorage.getItem("suaveplan-react-i18n-e2e-locale")
);
expect(persistedAfter).toBe(lastLocale);
consoleMonitor.assertClean();
networkMonitor.assertNoFailures();
});
test("flips <html dir=rtl> and mirrors layout when Arabic is active", async ({
page,
}) => {
const consoleMonitor = attachConsoleMonitor(page, { failOn: ["error"] });
const networkMonitor = attachNetworkMonitor(page);
await page.goto("/");
const ltrBox = await page
.getByTestId("greeting")
.boundingBox();
expect(ltrBox).not.toBeNull();
const initialDir = await page.evaluate(() =>
document.documentElement.getAttribute("dir")
);
expect(initialDir).toBe("ltr");
await page.getByTestId("language-switcher").selectOption("ar-AE");
await expect(page.getByTestId("greeting")).toHaveText(
GREETING_BY_LOCALE["ar-AE"]
);
await expect
.poll(async () =>
page.evaluate(() =>
document.documentElement.getAttribute("dir")
)
)
.toBe("rtl");
const rtlBox = await page
.getByTestId("greeting")
.boundingBox();
expect(rtlBox).not.toBeNull();
// RTL layout flips text alignment — the bounding box's right edge
// anchors to the same x as the parent's right padding while the
// left edge moves outward. The simplest provable invariant is the
// direction attribute itself, which we assert above; here we
// additionally confirm the render produced a measurable box (i.e.
// not a zero-sized phantom node from a failed reflow).
expect((rtlBox?.width ?? 0) > 0).toBe(true);
expect((ltrBox?.width ?? 0) > 0).toBe(true);
consoleMonitor.assertClean();
networkMonitor.assertNoFailures();
});
test("renders the I18nSuspense fallback while the auth namespace loads", async ({
page,
}) => {
const consoleMonitor = attachConsoleMonitor(page, { failOn: ["error"] });
const networkMonitor = attachNetworkMonitor(page);
// Slow the navigation enough for the very first render to mount
// the Suspense boundary before the auth namespace resolves.
await page.goto("/");
const fallback = page.getByTestId(
"suaveplan-i18n-suspense-fallback"
);
// Fallback may render very briefly; race the assertion against the
// resolved auth panel and assert at least one of the two reaches
// its terminal state. The fallback's `role="status"` + busy
// attribute give us a deterministic selector.
await Promise.race([
fallback.waitFor({ state: "visible", timeout: 1500 }).catch(() => undefined),
page.getByTestId("auth-panel").waitFor({ state: "visible", timeout: 5000 }),
]);
// Once the namespace has loaded, the panel must show the
// translated string — proof the Suspense boundary actually
// resolved instead of leaving a key string in the DOM.
await expect(page.getByTestId("auth-signin")).toHaveText(
SIGN_IN_BY_LOCALE["en-GB"]
);
await expect(page.getByTestId("auth-welcome")).not.toHaveText("welcome");
consoleMonitor.assertClean();
networkMonitor.assertNoFailures();
});
test("missing-key overlay caps at 50 entries with aria-live=polite", async ({
page,
}) => {
const consoleMonitor = attachConsoleMonitor(page, { failOn: ["error"] });
const networkMonitor = attachNetworkMonitor(page);
await page.goto("/");
const triggerButton = page.getByTestId("trigger-missing");
const overlay = page.getByTestId("suaveplan-missing-key-overlay");
// Fire 60 distinct missing keys; overlay must cap at 50.
for (let i = 0; i < 60; i += 1) {
await triggerButton.click();
}
await expect(overlay).toBeVisible();
await expect(overlay).toHaveAttribute("aria-live", "polite");
const itemCount = await overlay.locator("li").count();
expect(itemCount).toBe(50);
consoleMonitor.assertClean();
networkMonitor.assertNoFailures();
});
test("SSR hydration round-trip serves translated markup with no flash", async ({
page,
}) => {
const consoleMonitor = attachConsoleMonitor(page, {
failOn: ["error", "warning"],
ignore: [/Download the React DevTools/i],
});
const networkMonitor = attachNetworkMonitor(page);
// Capture the raw server response BEFORE hydration to prove the
// initial HTML already contains the translated string. If we only
// looked at the live DOM we could not distinguish "SSR worked"
// from "client rendered after a flash".
const response = await page.request.get("/ssr/zh-CN");
expect(response.status()).toBe(200);
const rawHtml = await response.text();
expect(rawHtml).toContain(GREETING_BY_LOCALE["zh-CN"]);
expect(rawHtml).toContain('lang="zh-CN"');
expect(rawHtml).toContain('dir="ltr"');
await page.goto("/ssr/zh-CN");
// Assert the visible greeting is the SSR string immediately and
// does not change after hydration (no flash). We poll the text
// for 400ms — far longer than any reasonable hydration step on
// a local Vite dev server — to confirm stability.
const greeting = page.getByTestId("greeting");
await expect(greeting).toHaveText(GREETING_BY_LOCALE["zh-CN"]);
const before = await greeting.textContent();
await page.waitForLoadState("networkidle");
const after = await greeting.textContent();
expect(after).toBe(before);
consoleMonitor.assertClean();
networkMonitor.assertNoFailures();
});
});

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en-GB" dir="ltr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>react-i18n e2e</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/entry-client.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,219 @@
import type { TranslationRecord } from "@suaveplan/i18n";
import { createI18n } from "@suaveplan/i18n/plugin";
import {
I18nSuspense,
LanguageSwitcher,
MissingKeyOverlay,
Trans,
TranslationProvider,
useDirection,
useLocale,
useTranslation,
} from "@suaveplan/react-i18n";
import { useEffect, useState } from "react";
import { createTestBackend } from "./test-backend.js";
import {
AUTH_BUNDLE,
COMMON_BUNDLE,
SUPPORTED_LOCALES,
type SupportedLocale,
} from "./translations.js";
const LOCAL_STORAGE_KEY = "suaveplan-react-i18n-e2e-locale";
const TEST_BACKEND = createTestBackend({ delayMs: 200 });
interface AppProps {
readonly initialLocale?: SupportedLocale;
/**
* When true, the `auth` namespace is also bundled into
* `initialMessages` so SSR renders the full panel synchronously
* (no Suspense fallback). Hydration still uses the same shape so
* server and client agree on the initial markup.
*/
readonly includeAuth?: boolean;
}
/**
* Top-level test-app shell. Wraps the tree in a `<TranslationProvider>`
* and exposes the five Playwright scenarios (live switch, RTL, lazy
* Suspense, missing-key overlay, SSR hydration round-trip) through a
* single page so the spec only needs one `goto`.
*/
export function App(props: AppProps): JSX.Element {
// Build the I18nInstance directly (caller-managed mode). This
// bypasses the Provider's config-path Zod re-parse which strips
// function-typed Backend methods — instead `i18n` is structurally
// validated and forwarded as-is, preserving the backend's
// `read`/`readMulti` callable surface. `useState` lazy-init keeps
// the instance stable across re-renders.
const [i18n] = useState(() =>
createI18n({
defaultLocale:
props.initialLocale ?? readPersistedLocale() ?? "en-GB",
fallbackLocale: "en-GB",
messages: buildInitialMessages(props.includeAuth ?? false) as never,
backend: TEST_BACKEND as never,
})
);
return (
<TranslationProvider i18n={i18n}>
<Page />
<MissingKeyOverlay />
</TranslationProvider>
);
}
function buildInitialMessages(
includeAuth: boolean
): Record<SupportedLocale, TranslationRecord> {
if (!includeAuth) return COMMON_BUNDLE;
const merged: Record<SupportedLocale, TranslationRecord> = {
...COMMON_BUNDLE,
};
for (const locale of SUPPORTED_LOCALES) {
merged[locale] = {
...COMMON_BUNDLE[locale],
auth: AUTH_BUNDLE[locale],
};
}
return merged;
}
function Page(): JSX.Element {
const { locale } = useLocale();
const { direction } = useDirection();
// Mirror the active locale + direction onto the <html> element so
// CSS hooks and screen readers see the same value the i18n core
// resolved. Tests assert against this attribute set.
useEffect(() => {
document.documentElement.lang = locale;
document.documentElement.dir = direction;
try {
window.localStorage.setItem(LOCAL_STORAGE_KEY, locale);
} catch {
// localStorage may be unavailable (private browsing); ignore.
}
}, [locale, direction]);
return (
<main data-testid="page" style={{ padding: "2rem", fontFamily: "system-ui" }}>
<h1 data-testid="greeting">
<Trans i18nKey="common:greeting" />
</h1>
<p data-testid="current-locale">
<Trans
i18nKey="common:currentLocale"
values={{ locale }}
/>
</p>
<section
data-testid="switcher-section"
style={{ marginTop: "1.5rem" }}
>
<LanguageSwitcher locales={[...SUPPORTED_LOCALES]}>
{(state) => (
<select
data-testid="language-switcher"
aria-busy={state.isPending}
value={state.current}
onChange={(event) => {
void state.change(event.target.value);
}}
>
{state.locales.map((entry) => (
<option
key={entry.code}
value={entry.code}
data-direction={entry.direction}
>
{entry.code}
</option>
))}
</select>
)}
</LanguageSwitcher>
</section>
<section
data-testid="auth-section"
style={{ marginTop: "1.5rem" }}
>
<I18nSuspense>
<AuthPanel />
</I18nSuspense>
</section>
<section
data-testid="missing-section"
style={{ marginTop: "1.5rem" }}
>
<MissingKeyTrigger />
</section>
</main>
);
}
function AuthPanel(): JSX.Element {
const { t } = useTranslation("auth");
return (
<div data-testid="auth-panel">
<button type="button" data-testid="auth-signin">
{t("signIn")}
</button>
<p data-testid="auth-welcome">{t("welcome")}</p>
</div>
);
}
interface MissingResolution {
readonly key: string;
readonly resolved: string;
}
function MissingKeyTrigger(): JSX.Element {
const [resolved, setResolved] = useState<readonly MissingResolution[]>([]);
const { t } = useTranslation();
return (
<div>
<button
type="button"
data-testid="trigger-missing"
onClick={() => {
// Resolve `t()` inside the click handler — NOT during
// render — so the bus's `missingKey` event fires
// outside React's render phase. This avoids the
// "Cannot update a component while rendering a
// different component" warning that would otherwise
// fire when the overlay's `setState` lands inside a
// sibling render pass.
const next = `e2e.missing.${resolved.length + 1}`;
const value = t(next);
setResolved((prev) => [...prev, { key: next, resolved: value }]);
}}
>
Request missing key
</button>
<ul data-testid="missing-list">
{resolved.map((entry) => (
<li key={entry.key} data-key={entry.key}>
{entry.resolved}
</li>
))}
</ul>
</div>
);
}
function readPersistedLocale(): SupportedLocale | undefined {
try {
const value = window.localStorage.getItem(LOCAL_STORAGE_KEY);
if (value !== null && (SUPPORTED_LOCALES as readonly string[]).includes(value)) {
return value as SupportedLocale;
}
} catch {
// ignore — falls back to default below
}
return undefined;
}

View File

@@ -0,0 +1,29 @@
import { hydrateRoot, createRoot } from "react-dom/client";
import { App } from "./App.js";
import type { SupportedLocale } from "./translations.js";
interface BootstrapWindow extends Window {
__SUAVEPLAN_E2E_INITIAL_LOCALE__?: SupportedLocale;
__SUAVEPLAN_E2E_SSR__?: boolean;
}
const win = window as BootstrapWindow;
const initialLocale = win.__SUAVEPLAN_E2E_INITIAL_LOCALE__;
const container = document.getElementById("root");
if (container === null) {
throw new Error("Missing #root container");
}
if (win.__SUAVEPLAN_E2E_SSR__ === true) {
// SSR mode: hydrate the server-rendered tree without re-mounting so
// we can assert there is no hydration warning + no flash of un-
// translated content. `includeAuth=true` mirrors the server's
// initialMessages so the client's first render produces byte-
// identical DOM and React does not log a hydration mismatch.
hydrateRoot(
container,
<App initialLocale={initialLocale} includeAuth={true} />
);
} else {
createRoot(container).render(<App initialLocale={initialLocale} />);
}

View File

@@ -0,0 +1,25 @@
import { renderToString } from "react-dom/server";
import { App } from "./App.js";
import type { SupportedLocale } from "./translations.js";
export interface RenderResult {
readonly html: string;
readonly locale: SupportedLocale;
}
/**
* Server-side render the test-app with a pinned `initialLocale`. The Vite
* dev server's SSR middleware loads this module via `ssrLoadModule` and
* inlines the result into the served HTML, alongside a script tag that
* surfaces the initial locale to the client entry for hydration.
*/
export function render(locale: SupportedLocale): RenderResult {
// The SSR pass pre-populates the catalog with both the `common` and
// `auth` namespaces so no Suspense boundary suspends on the server.
// The client hydrate pass mirrors the same shape so the initial DOM
// matches and React does not log a hydration mismatch.
const html = renderToString(
<App initialLocale={locale} includeAuth={true} />
);
return { html, locale };
}

View File

@@ -0,0 +1,90 @@
import type { TranslationRecord } from "@suaveplan/i18n";
import { AUTH_BUNDLE, COMMON_BUNDLE } from "./translations.js";
/**
* Tiny async test backend: serves the `auth` namespace with a
* deterministic 200ms delay so Playwright can observe the
* `<I18nSuspense>` fallback before the namespace resolves.
*
* The `common` namespace is delivered through `initialMessages` and
* never reaches the backend, mirroring real production deployments
* where the SSR payload pre-populates the catalog.
*
* The shape mirrors `@suaveplan/i18n`'s `Backend` interface (intentionally
* not imported because the universal core does not re-export it from the
* public root). The `<TranslationProvider>` validates the structural
* `name` + `capabilities` fields via `I18nConfigSchema`'s `BackendShape`
* which is what matters here.
*/
export interface TestBackend {
readonly name: string;
readonly capabilities: {
readonly canRead: true;
readonly canReadMulti: boolean;
readonly canSave: boolean;
readonly canTrackModification: boolean;
readonly canWatch: boolean;
};
read(locale: string, namespace: string): Promise<TranslationRecord>;
readMulti(
locale: string,
namespaces: readonly string[]
): Promise<Record<string, TranslationRecord>>;
}
export interface TestBackendOptions {
readonly delayMs?: number;
}
export function createTestBackend(options: TestBackendOptions = {}): TestBackend {
const delayMs = options.delayMs ?? 200;
return {
name: "e2e-test-backend",
capabilities: {
canRead: true,
canReadMulti: true,
canSave: false,
canTrackModification: false,
canWatch: false,
},
async read(locale, namespace): Promise<TranslationRecord> {
await sleep(delayMs);
if (namespace !== "auth") {
throw new Error(`Unknown namespace ${namespace}`);
}
const bundle = AUTH_BUNDLE[locale as keyof typeof AUTH_BUNDLE];
if (bundle === undefined) {
throw new Error(`Unknown locale ${locale}`);
}
return bundle;
},
async readMulti(locale, namespaces): Promise<
Record<string, TranslationRecord>
> {
const out: Record<string, TranslationRecord> = {};
for (const ns of namespaces) {
if (ns === "auth") {
const bundle =
AUTH_BUNDLE[locale as keyof typeof AUTH_BUNDLE];
if (bundle !== undefined) {
out[ns] = bundle;
}
} else if (ns === "common") {
const bundle =
COMMON_BUNDLE[locale as keyof typeof COMMON_BUNDLE];
if (bundle !== undefined) {
out[ns] = bundle;
}
}
}
await sleep(delayMs);
return out;
},
};
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}

View File

@@ -0,0 +1,91 @@
/**
* Test-app translations for the react-i18n Playwright suite.
*
* These literal strings ARE the test fixture — they would be supplied
* by the consumer app in production. The translations namespace `common`
* is preloaded synchronously via `initialMessages`; the `auth` namespace
* is delivered by the async backend so Suspense fallbacks fire.
*/
import type { TranslationRecord } from "@suaveplan/i18n";
export type SupportedLocale =
| "en-GB"
| "ar-AE"
| "zh-CN"
| "cy-GB"
| "ru-RU";
export const SUPPORTED_LOCALES: readonly SupportedLocale[] = [
"en-GB",
"ar-AE",
"zh-CN",
"cy-GB",
"ru-RU",
];
export const COMMON_BUNDLE: Record<SupportedLocale, TranslationRecord> = {
"en-GB": {
common: {
greeting: "Hello, world",
language: "Language",
switchTo: "Switch to {locale}",
currentLocale: "Current locale: {locale}",
},
},
"ar-AE": {
common: {
greeting: "مرحبا بالعالم",
language: "اللغة",
switchTo: "التبديل إلى {locale}",
currentLocale: "اللغة الحالية: {locale}",
},
},
"zh-CN": {
common: {
greeting: "你好,世界",
language: "语言",
switchTo: "切换到 {locale}",
currentLocale: "当前语言:{locale}",
},
},
"cy-GB": {
common: {
greeting: "Helo, byd",
language: "Iaith",
switchTo: "Newid i {locale}",
currentLocale: "Iaith bresennol: {locale}",
},
},
"ru-RU": {
common: {
greeting: "Привет, мир",
language: "Язык",
switchTo: "Переключиться на {locale}",
currentLocale: "Текущая локаль: {locale}",
},
},
};
export const AUTH_BUNDLE: Record<SupportedLocale, TranslationRecord> = {
"en-GB": {
signIn: "Sign in",
welcome: "Welcome back",
},
"ar-AE": {
signIn: "تسجيل الدخول",
welcome: "مرحبا بعودتك",
},
"zh-CN": {
signIn: "登录",
welcome: "欢迎回来",
},
"cy-GB": {
signIn: "Mewngofnodi",
welcome: "Croeso yn ôl",
},
"ru-RU": {
signIn: "Войти",
welcome: "С возвращением",
},
};

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../../foundation/core/typescript-config/react-library.json",
"compilerOptions": {
"rootDir": "./src",
"noEmit": true,
"types": ["node"],
"moduleResolution": "bundler",
"allowImportingTsExtensions": false
},
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"]
}

View File

@@ -0,0 +1,59 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
root: __dirname,
server: {
port: 5273,
strictPort: true,
host: "127.0.0.1",
},
plugins: [react(), ssrMiddlewarePlugin()],
optimizeDeps: {
// Force pre-bundle of the workspace package so Vite resolves the
// exports map correctly the first hit.
include: ["@suaveplan/react-i18n", "@suaveplan/i18n"],
},
});
/**
* Adds a `/ssr/:locale` endpoint that renders the React tree on the
* server, inlines the markup into the HTML template, and adds a tiny
* bootstrap script that flips `entry-client.tsx` into hydration mode.
*
* This is the SSR scenario surface exercised by the Playwright spec —
* the regular `/` route serves the standard CSR build.
*/
function ssrMiddlewarePlugin(): import("vite").Plugin {
return {
name: "react-i18n-e2e-ssr",
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
if (req.url === undefined || !req.url.startsWith("/ssr/")) {
next();
return;
}
try {
const localeFromUrl = req.url.replace(/^\/ssr\//, "").split("?")[0] ?? "";
const locale = decodeURIComponent(localeFromUrl);
const templatePath = `${__dirname}/index.html`;
const fs = await import("node:fs/promises");
const rawTemplate = await fs.readFile(templatePath, "utf8");
const transformed = await server.transformIndexHtml(req.url, rawTemplate);
const mod = await server.ssrLoadModule("/src/entry-server.tsx");
const renderFn = (mod as { render: (l: string) => { html: string; locale: string } }).render;
const { html, locale: resolvedLocale } = renderFn(locale);
const bootstrap = `<script>window.__SUAVEPLAN_E2E_INITIAL_LOCALE__=${JSON.stringify(resolvedLocale)};window.__SUAVEPLAN_E2E_SSR__=true;</script>`;
const finalHtml = transformed
.replace(`<div id="root"></div>`, `<div id="root">${html}</div>${bootstrap}`)
.replace(`<html lang="en-GB" dir="ltr">`, `<html lang="${resolvedLocale}" dir="${resolvedLocale.startsWith("ar") ? "rtl" : "ltr"}">`);
res.statusCode = 200;
res.setHeader("Content-Type", "text/html");
res.end(finalHtml);
} catch (error) {
next(error as Error);
}
});
},
};
}