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(); + } +}