diff --git a/.gitignore b/.gitignore
index 2166845..ad003fa 100755
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,11 @@
*.ps1
*.py
+!/tests/E2E/*.py
/node_modules/
package-lock.json
.continue/
.playwright-mcp/
vendor/
.phpunit.cache/
+build/
+.phpunit.coverage.cache/
diff --git a/phpunit.xml b/phpunit.xml
index 2410051..b37abfc 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -20,4 +20,12 @@
app
+
+
+
+
+
+
+
+
diff --git a/tests/E2E/language_flow.py b/tests/E2E/language_flow.py
new file mode 100644
index 0000000..18c5942
--- /dev/null
+++ b/tests/E2E/language_flow.py
@@ -0,0 +1,97 @@
+#!/usr/bin/env python3
+"""
+E2E test: language switcher flow on the landing page.
+
+Verifies that clicking a flag in the locale switcher changes the active
+language and the page hero content updates accordingly. Runs against a
+PHP dev-server instance expected at http://127.0.0.1:8081.
+
+Usage: python3 tests/E2E/language_flow.py
+ EXIT 0 on success, non-zero on failure.
+"""
+from __future__ import annotations
+
+import os
+import sys
+from pathlib import Path
+
+# Resolve the playwright path without requiring a global install.
+try:
+ from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError
+except ImportError:
+ sys.stderr.write("playwright not installed; skipping E2E\n")
+ sys.exit(0)
+
+BASE_URL = os.environ.get("BASE_URL", "http://127.0.0.1:8081")
+
+# Per-locale hero line 1, kept in sync with app/Locales/*.php.
+# The E2E test reads the title from the H1 instead of hardcoding so it
+# only needs to be kept in sync with the EN locale (the default fallback
+# shown when navigating to the root).
+EXPECTED = {
+ "de": ("Großzügiges", "Einfamilienhaus"),
+ "en": ("Spacious", "Detached house"),
+ "uk": ("Просторий", "Приватний будинок"),
+ "ru": ("Просторный", "Частный дом"),
+}
+
+
+def check_locale(page, code: str) -> None:
+ line1, line2 = EXPECTED[code]
+ h1 = page.locator("h1.hero-h1")
+ h1.wait_for(state="visible", timeout=5_000)
+ text = h1.inner_text(timeout=5_000)
+ if line1 not in text or line2 not in text:
+ raise AssertionError(
+ f"[{code}] expected hero to contain {line1!r} + {line2!r}, got: {text!r}"
+ )
+
+ # must reflect the active locale.
+ html_lang = page.evaluate("document.documentElement.lang")
+ if html_lang != code:
+ raise AssertionError(
+ f"[{code}] expected ={code!r}, got {html_lang!r}"
+ )
+
+
+def main() -> int:
+ with sync_playwright() as pw:
+ # Use system Chrome (Playwright's bundled Chromium does not install
+ # on Ubuntu 26.04). Pass --no-sandbox since this runs as root in CI.
+ browser = pw.chromium.launch(
+ executable_path=os.environ.get("CHROME_BIN", "/usr/bin/google-chrome"),
+ headless=True,
+ args=["--no-sandbox", "--disable-gpu"],
+ )
+ context = browser.new_context(accept_downloads=False)
+ page = context.new_page()
+
+ try:
+ # Pin the initial locale via query-string so the test is not
+ # at the mercy of the browser's Accept-Language header.
+ page.goto(f"{BASE_URL}/?lang=de", wait_until="domcontentloaded", timeout=10_000)
+ # Default locale is DE (Locale::DEFAULT).
+ check_locale(page, "de")
+
+ for code in ("en", "uk", "ru", "de"):
+ # Locale switcher links are with hreflang equal to the locale code.
+ link = page.locator(f'a[hreflang="{code}"]').first
+ link.wait_for(state="visible", timeout=5_000)
+ link.click()
+ # Wait for the page to reload / navigate.
+ page.wait_for_load_state("domcontentloaded", timeout=10_000)
+ check_locale(page, code)
+ print(f" ✓ locale={code} hero verified")
+
+ print("OK: language flow E2E passed for all 4 locales")
+ return 0
+ except (PlaywrightTimeoutError, AssertionError) as exc:
+ print(f"FAIL: {exc}", file=sys.stderr)
+ return 1
+ finally:
+ context.close()
+ browser.close()
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tests/Integration/RenderTest.php b/tests/Integration/RenderTest.php
new file mode 100644
index 0000000..60fade7
--- /dev/null
+++ b/tests/Integration/RenderTest.php
@@ -0,0 +1,223 @@
+ value]
+ */
+ private function renderHomeIn(string $locale, array $formErrors = [], array $formFieldErrors = [], bool $formSuccess = false): array
+ {
+ // Sanity: the locale must be one of the supported ones.
+ self::assertTrue(Locale::isSupported($locale), "Unsupported locale for render: $locale");
+
+ $t = static function (string $key) use ($locale): string {
+ return I18n::t($key, [], $locale);
+ };
+
+ $formData = [
+ 'fname' => '',
+ 'lname' => '',
+ 'email' => '',
+ 'phone' => '',
+ 'interest' => 'visit',
+ 'message' => '',
+ ];
+
+ $interestKeys = [
+ 'visit' => 'form.interest.visit',
+ 'info' => 'form.interest.info',
+ 'apply' => 'form.interest.apply',
+ ];
+
+ $escapeContactValue = static fn(string $value): string
+ => htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
+
+ // Empty honeypot/csrf/session.
+ $_SESSION = [
+ 'csrf_token' => 'csrf-test-token',
+ 'form_start_time' => time(),
+ ];
+
+ $viewPath = dirname(__DIR__, 2) . '/app/views/home/index.php';
+ self::assertFileExists($viewPath);
+
+ ob_start();
+ include $viewPath;
+ $body = (string) ob_get_clean();
+
+ // Layout prefix: open ... via a tiny shim.
+ $layout = sprintf(
+ '%s%s',
+ htmlspecialchars($locale, ENT_QUOTES),
+ htmlspecialchars($t('hero.h1.line1') . ' ' . $t('hero.h1.line2'), ENT_QUOTES),
+ $body,
+ );
+
+ return [$layout, $locale];
+ }
+
+ /**
+ * Each locale's home render must contain the locale-specific
+ * translation keys (and they must be the locale's expected text).
+ */
+ #[Test]
+ #[DataProvider('supportedLocaleProvider')]
+ public function homeRendersLocaleSpecificHeroCopy(string $locale): void
+ {
+ [$html] = $this->renderHomeIn($locale);
+
+ // Per-locale hero h1 line 1 — strong, locale-specific token.
+ $expectedLine1 = I18n::t('hero.h1.line1', [], $locale);
+ self::assertNotSame('hero.h1.line1', $expectedLine1, "Translation missing for hero.h1.line1 in $locale");
+ self::assertStringContainsString(
+ $expectedLine1,
+ $html,
+ "Expected hero line 1 ($expectedLine1) not rendered in $locale"
+ );
+
+ // Hero tag
+ $expectedTag = I18n::t('hero.tag', [], $locale);
+ self::assertStringContainsString($expectedTag, $html, "Hero tag not rendered in $locale");
+ }
+
+ /**
+ * `` must match the active locale so screen readers,
+ * search engines, and the browser's auto-translate UI behave.
+ */
+ #[Test]
+ #[DataProvider('supportedLocaleProvider')]
+ public function htmlRootLangAttributeMatchesActiveLocale(string $locale): void
+ {
+ [$html] = $this->renderHomeIn($locale);
+ self::assertMatchesRegularExpression(
+ '~ to $locale"
+ );
+ }
+
+ /**
+ * Regression guard: a hardcoded DE string that's not a proper noun
+ * (e.g. "Einfamilienhaus") must NOT appear in the EN/UK/RU render.
+ */
+ #[Test]
+ #[DataProvider('nonGermanLocaleProvider')]
+ public function nonGermanRenderDoesNotLeakGermanCopy(string $locale): void
+ {
+ [$html] = $this->renderHomeIn($locale);
+
+ $germanOnly = [
+ 'Einfamilienhaus', // hero.h1.line2 DE
+ 'Großzügiges', // hero.h1.line1 DE (with ß)
+ 'Entdecken', // hero.discover DE
+ 'Galerie', // nav.gallery DE
+ ];
+
+ foreach ($germanOnly as $needle) {
+ self::assertStringNotContainsString(
+ $needle,
+ $html,
+ "German copy \"$needle\" leaked into $locale render"
+ );
+ }
+ }
+
+ /**
+ * Switcher widget: with the active locale, that locale's flag/link
+ * must carry `aria-current="true"` per a11y contract.
+ */
+ #[Test]
+ #[DataProvider('supportedLocaleProvider')]
+ public function localeSwitcherMarksActiveLocaleWithAriaCurrent(string $locale): void
+ {
+ $switcherHtml = (new LocaleSwitcher($locale, '/'))->render();
+
+ self::assertStringContainsString('aria-current="true"', $switcherHtml, "Active locale $locale should have aria-current");
+
+ // The active locale's link must point at itself (relative path stays on the page).
+ self::assertMatchesRegularExpression(
+ '~aria-current="true"[^>]*>.*?'
+ . preg_quote(I18n::t('locale.' . $locale, [], $locale), '~')
+ . '~s',
+ $switcherHtml,
+ "Active locale $locale not properly labelled in switcher"
+ );
+ }
+
+ /**
+ * All four locales should produce roughly the same DOM skeleton
+ * (same section ids, same form structure) — translation is
+ * content swap, not structural drift.
+ */
+ #[Test]
+ public function homeDomSkeletonIsStableAcrossLocales(): void
+ {
+ $skeletons = [];
+ foreach (Locale::SUPPORTED as $locale) {
+ [$html] = $this->renderHomeIn($locale);
+ // Pull out section/landmark ids + a couple of structural tags.
+ preg_match_all(
+ '~<(?:section|main|header|footer|nav|aside|form)\b[^>]*(?:\bid="([^"]+)")?~',
+ $html,
+ $matches
+ );
+ $skeletons[$locale] = $matches[0];
+ }
+
+ // All four skeletons must have the same number of structural tags.
+ $counts = array_map('count', $skeletons);
+ $unique = array_unique($counts);
+ self::assertCount(1, $unique, 'DOM skeleton size differs across locales: ' . json_encode($counts));
+ }
+
+ public static function supportedLocaleProvider(): array
+ {
+ $out = [];
+ foreach (Locale::SUPPORTED as $locale) {
+ $out[$locale] = [$locale];
+ }
+ return $out;
+ }
+
+ public static function nonGermanLocaleProvider(): array
+ {
+ $out = [];
+ foreach (Locale::SUPPORTED as $locale) {
+ if ($locale === Locale::DEFAULT) {
+ continue;
+ }
+ $out[$locale] = [$locale];
+ }
+ return $out;
+ }
+
+ protected function setUp(): void
+ {
+ // Make sure the I18n cache is fresh per test.
+ I18n::flushCache();
+ parent::setUp();
+ }
+}