diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000000..78e32d2a3c --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,86 @@ +name: Playwright Tests + +on: + pull_request: + branches: + - redesign + - gh-pages + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + playwright: + if: github.actor != 'dependabot[bot]' + name: Playwright Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.13' + cache: 'npm' + + - name: Wait for Netlify preview + id: wait-for-preview + run: | + # Calculate the Netlify preview URL based on the PR number + PREVIEW_URL="https://deploy-preview-${{ github.event.pull_request.number }}--expressjscom-preview.netlify.app" + echo "PREVIEW_URL=$PREVIEW_URL" >> "$GITHUB_ENV" + + MAX_RETRIES=10 + DELAY=20 + echo "Checking Netlify preview: $PREVIEW_URL" + + for i in $(seq 1 $MAX_RETRIES); do + # Check if the URL returns a 200 OK + if curl -s -I "$PREVIEW_URL" | grep -q "HTTP/.* 200"; then + echo "✅ Preview is live at $PREVIEW_URL" + exit 0 + fi + echo "⏳ Waiting for Netlify to deploy... ($i/$MAX_RETRIES)" + sleep $DELAY + done + + echo "❌ Preview not live after $((MAX_RETRIES*DELAY)) seconds." + exit 1 + + - name: Install dependencies + run: npm ci + + - name: Get Playwright version + id: playwright-version + run: echo "version=$(npx playwright --version | awk '{print $2}')" >> $GITHUB_OUTPUT + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }} + restore-keys: | + playwright-${{ runner.os }}- + + - name: Install Playwright Browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: npx playwright install --with-deps + + - name: Run Playwright tests + run: npm run test:e2e + env: + PLAYWRIGHT_BASE_URL: ${{ env.PREVIEW_URL }} + + - name: Upload Playwright test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 016b59ea14..c21e0d8d7a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ pnpm-debug.log* # jetbrains setting folder .idea/ + +playwright-report/ +test-results/ \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 423fc37ca9..66ccf2f188 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,25 +71,65 @@ Tooling required: ### Available Scripts -| Command | Description | -| ----------------- | ----------------------------------------- | -| `npm run dev` | Start development server with hot reload | -| `npm run build` | Build production site to `./dist` | -| `npm run preview` | Preview production build locally | -| `npm run lint` | Run ESLint to check for issues | -| `npm run check` | Run type checking and format verification | +| Command | Description | +| ------------------ | ----------------------------------------- | +| `npm run dev` | Start development server with hot reload | +| `npm run build` | Build production site to `./dist` | +| `npm run preview` | Preview production build locally | +| `npm run lint` | Run ESLint to check for issues | +| `npm run check` | Run type checking and format verification | +| `npm run test:e2e` | Run Playwright E2E tests | ## Submitting a Pull Request -1. Create a new branch from `main` -2. Make your changes -3. Run `npm run check` to verify code style and types -4. Commit with a clear message -5. Push to your fork -6. Open a PR against `main` +1. Create a new branch from `main`. +2. Make your changes. +3. Run `npm run check` to verify code style and types. +4. Run `npm run test:e2e` to ensure your changes don't break existing functionality. +5. Commit with a clear message. +6. Push to your fork. +7. Open a PR against `main`. > Ensure all checks pass and your branch is up to date with `main` before opening a PR. +## Testing + +We use **Playwright** for End-to-End (E2E) testing. All PRs are automatically tested against a Netlify Preview deployment before they can be merged. + +### Prerequisites + +Before running E2E tests for the first time, you need to install the browser binaries: + +```bash +npx playwright install --with-deps +``` + +### Running Tests Locally + +You can run the full test suite against your local development server: + +1. In one terminal, start the site: `npm run dev` +2. In another terminal, run the tests: `npm run test:e2e` + +### Writing Stable Tests + +When adding new tests or modifying components, please follow these stability guidelines: + +1. **Avoid CSS Classes**: Do not use CSS classes (e.g., `.hero__content`) for locators, as they are fragile and change during refactoring. +2. **Use data-testid**: Add `data-testid` attributes to components for stable targeting (e.g., `
`). +3. **User-Visible Locators**: Prefer semantic locators like `getByRole`, `getByText`, or `getByAltText` over IDs when possible. + +Example: + +```typescript +// Good: Stable and accessible +const logo = page.getByAltText('Express.js logo'); +const section = page.getByTestId('features-section'); + +// Bad: Fragile +const logo = page.locator('.hero__logo'); +``` + ## Further Documentation For more detailed documentation about the project, see the [`docs/`](docs/) folder: diff --git a/package-lock.json b/package-lock.json index e1495feae9..d5c7b48b19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@csstools/postcss-global-data": "^4.0.0", "@eslint/js": "^9.39.2", "@iconify-json/fluent": "^1.2.41", + "@playwright/test": "^1.59.1", "@types/node": "^25.3.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -2250,6 +2251,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -9455,6 +9472,53 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index 3134af99d1..8fad85df05 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "check": "npm run format:check && npm run lint", "fix": "npm run format && npm run lint:fix", "prepare": "husky", - "lint-staged": "lint-staged" + "lint-staged": "lint-staged", + "test:e2e": "playwright test" }, "dependencies": { "@astrojs/mdx": "^5.0.3", @@ -62,6 +63,7 @@ "@csstools/postcss-global-data": "^4.0.0", "@eslint/js": "^9.39.2", "@iconify-json/fluent": "^1.2.41", + "@playwright/test": "^1.59.1", "@types/node": "^25.3.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000000..82db311633 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,34 @@ +import { defineConfig, devices } from '@playwright/test'; + +const isCI = !!process.env.CI; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + forbidOnly: isCI, + retries: isCI ? 2 : 0, + workers: isCI ? 1 : undefined, + reporter: isCI ? [['html'], ['github']] : [['html']], + + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4321', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ], +}); diff --git a/src/components/patterns/Features/Features.astro b/src/components/patterns/Features/Features.astro index c63ec6a192..f63cac2a4c 100644 --- a/src/components/patterns/Features/Features.astro +++ b/src/components/patterns/Features/Features.astro @@ -26,7 +26,7 @@ interface Props extends HTMLAttributes<'section'> { const { title, class: className, ...rest } = Astro.props; --- -
+
diff --git a/src/components/patterns/Footer/Footer.astro b/src/components/patterns/Footer/Footer.astro index 2d25630dfa..62b8fa30c1 100644 --- a/src/components/patterns/Footer/Footer.astro +++ b/src/components/patterns/Footer/Footer.astro @@ -9,7 +9,7 @@ import { Icon } from 'astro-icon/components'; import { BodyMd, Button, Col, Container, Grid } from '@/components/primitives'; --- -
- + {EXPRESS_VERSION} @@ -53,8 +62,8 @@ app.listen(port, () => {

{t('hero.tagline')}

-
- +
+
-
+
diff --git a/src/components/patterns/LanguageSelect/LanguageSelect.astro b/src/components/patterns/LanguageSelect/LanguageSelect.astro index 0e1a26ae5e..b767a8b205 100644 --- a/src/components/patterns/LanguageSelect/LanguageSelect.astro +++ b/src/components/patterns/LanguageSelect/LanguageSelect.astro @@ -17,10 +17,13 @@ import { Select } from '@/components/primitives'; import type { LanguageSelectProps } from './types'; +import { getLangFromUrl, useTranslations } from '@/i18n/utils'; type Props = LanguageSelectProps; -const { languages, currentLanguage = 'en', class: className, id, ...rest } = Astro.props; +const { languages, currentLanguage: propLanguage, class: className, id, ...rest } = Astro.props; +const currentLanguage = propLanguage || getLangFromUrl(Astro.url); +const t = useTranslations(currentLanguage); const options = languages.map((lang) => ({ value: lang.code, @@ -36,7 +39,7 @@ const options = languages.map((lang) => ({ hideLabel={true} variant="icon" dropdownAlign="right" - ariaLabel="Select language" + ariaLabel={t('language.selectLabel')} class={className} data-language-select {...id && { id }} diff --git a/src/components/patterns/LanguageSelect/types.ts b/src/components/patterns/LanguageSelect/types.ts index 089bf60785..272f960e88 100644 --- a/src/components/patterns/LanguageSelect/types.ts +++ b/src/components/patterns/LanguageSelect/types.ts @@ -3,14 +3,15 @@ */ import type { HTMLAttributes } from 'astro/types'; +import type { LanguageCode } from '@/i18n/locales'; export interface LanguageConfig { - code: string; + code: LanguageCode; label: string; } export type LanguageSelectProps = HTMLAttributes<'div'> & { languages: LanguageConfig[]; - currentLanguage?: string; - onLanguageChange?: (code: string) => void; + currentLanguage?: LanguageCode; + onLanguageChange?: (code: LanguageCode) => void; }; diff --git a/src/components/primitives/Card/Card.astro b/src/components/primitives/Card/Card.astro index 29c2953c3d..8e5061c243 100644 --- a/src/components/primitives/Card/Card.astro +++ b/src/components/primitives/Card/Card.astro @@ -23,7 +23,7 @@ interface Props extends HTMLAttributes<'div'> { const { title, body, class: className, ...rest } = Astro.props; --- -
+

{title}

{body} diff --git a/src/i18n/ui/de.json b/src/i18n/ui/de.json index 9ab191778e..287ad4fce2 100644 --- a/src/i18n/ui/de.json +++ b/src/i18n/ui/de.json @@ -20,6 +20,9 @@ "version": { "selectLabel": "API-Version auswählen" }, + "language": { + "selectLabel": "Sprache auswählen" + }, "nav": { "mainMenu": "Hauptmenü", "toggleMenu": "Menü umschalten", diff --git a/src/i18n/ui/en.json b/src/i18n/ui/en.json index e7694734b5..0347fe4ad1 100644 --- a/src/i18n/ui/en.json +++ b/src/i18n/ui/en.json @@ -20,6 +20,9 @@ "version": { "selectLabel": "Select API version" }, + "language": { + "selectLabel": "Select language" + }, "nav": { "mainMenu": "Main menu", "toggleMenu": "Toggle menu", @@ -188,6 +191,7 @@ "hero": { "tagline": "Fast, unopinionated, minimalist web framework for Node.js", "getStarted": "Get Started", + "kawaiiLogoAlt": "Express.js kawaii logo", "videoPause": "Pause background video", "videoPlay": "Play background video" } diff --git a/src/i18n/ui/es.json b/src/i18n/ui/es.json index dc888a36b8..ace5893d13 100644 --- a/src/i18n/ui/es.json +++ b/src/i18n/ui/es.json @@ -20,6 +20,9 @@ "version": { "selectLabel": "Seleccionar versión de API" }, + "language": { + "selectLabel": "Seleccionar idioma" + }, "nav": { "mainMenu": "Menú principal", "toggleMenu": "Alternar menú", diff --git a/src/i18n/ui/fr.json b/src/i18n/ui/fr.json index b751760997..280ab48600 100644 --- a/src/i18n/ui/fr.json +++ b/src/i18n/ui/fr.json @@ -20,6 +20,9 @@ "version": { "selectLabel": "Sélectionner la version de l’API" }, + "language": { + "selectLabel": "Sélectionner la langue" + }, "nav": { "mainMenu": "Menu principal", "toggleMenu": "Afficher/masquer le menu", diff --git a/src/i18n/ui/it.json b/src/i18n/ui/it.json index 21538bcd38..a591f6a9ca 100644 --- a/src/i18n/ui/it.json +++ b/src/i18n/ui/it.json @@ -20,6 +20,9 @@ "version": { "selectLabel": "Seleziona la versione API" }, + "language": { + "selectLabel": "Seleziona lingua" + }, "nav": { "mainMenu": "Menu principale", "toggleMenu": "Mostra/Nascondi menu", diff --git a/src/i18n/ui/ja.json b/src/i18n/ui/ja.json index 682acb6767..21fae5d187 100644 --- a/src/i18n/ui/ja.json +++ b/src/i18n/ui/ja.json @@ -20,6 +20,9 @@ "version": { "selectLabel": "APIバージョンを選択" }, + "language": { + "selectLabel": "言語を選択" + }, "nav": { "mainMenu": "メインメニュー", "toggleMenu": "メニューを切替", diff --git a/src/i18n/ui/ko.json b/src/i18n/ui/ko.json index 51f5cbd03a..894c11837f 100644 --- a/src/i18n/ui/ko.json +++ b/src/i18n/ui/ko.json @@ -20,6 +20,9 @@ "version": { "selectLabel": "API 버전 선택" }, + "language": { + "selectLabel": "언어 선택" + }, "nav": { "mainMenu": "메인 메뉴", "toggleMenu": "메뉴 전환", diff --git a/src/i18n/ui/pt-br.json b/src/i18n/ui/pt-br.json index c1842e12bd..1781abdb15 100644 --- a/src/i18n/ui/pt-br.json +++ b/src/i18n/ui/pt-br.json @@ -20,6 +20,9 @@ "version": { "selectLabel": "Selecionar versão da API" }, + "language": { + "selectLabel": "Selecionar idioma" + }, "nav": { "mainMenu": "Menu principal", "toggleMenu": "Alternar menu", diff --git a/src/i18n/ui/zh-cn.json b/src/i18n/ui/zh-cn.json index ea0ed68a5d..98b2590d7b 100644 --- a/src/i18n/ui/zh-cn.json +++ b/src/i18n/ui/zh-cn.json @@ -20,6 +20,9 @@ "version": { "selectLabel": "选择 API 版本" }, + "language": { + "selectLabel": "选择语言" + }, "nav": { "mainMenu": "主菜单", "toggleMenu": "切换菜单", diff --git a/src/i18n/ui/zh-tw.json b/src/i18n/ui/zh-tw.json index ea0ed68a5d..4d6d065cb3 100644 --- a/src/i18n/ui/zh-tw.json +++ b/src/i18n/ui/zh-tw.json @@ -13,12 +13,15 @@ "ariaLabel": "开始输入以搜索" }, "theme": { - "toggle": "切换主题", - "switchToLight": "切换到浅色模式", - "switchToDark": "切换到深色模式" + "toggle": "切換主題", + "switchToLight": "切換到淺色模式", + "switchToDark": "切換到深色模式" }, "version": { - "selectLabel": "选择 API 版本" + "selectLabel": "選擇 API 版本" + }, + "language": { + "selectLabel": "選擇語言" }, "nav": { "mainMenu": "主菜单", diff --git a/tests/e2e/homepage.spec.ts b/tests/e2e/homepage.spec.ts new file mode 100644 index 0000000000..0b31eed779 --- /dev/null +++ b/tests/e2e/homepage.spec.ts @@ -0,0 +1,245 @@ +import { test, expect } from '@playwright/test'; +import enStrings from '../../src/i18n/ui/en.json' with { type: 'json' }; +import esStrings from '../../src/i18n/ui/es.json' with { type: 'json' }; +import frStrings from '../../src/i18n/ui/fr.json' with { type: 'json' }; +import deStrings from '../../src/i18n/ui/de.json' with { type: 'json' }; +import itStrings from '../../src/i18n/ui/it.json' with { type: 'json' }; +import jaStrings from '../../src/i18n/ui/ja.json' with { type: 'json' }; +import koStrings from '../../src/i18n/ui/ko.json' with { type: 'json' }; +import ptBrStrings from '../../src/i18n/ui/pt-br.json' with { type: 'json' }; +import zhCnStrings from '../../src/i18n/ui/zh-cn.json' with { type: 'json' }; +import zhTwStrings from '../../src/i18n/ui/zh-tw.json' with { type: 'json' }; + +// Add property here that will be used to test translations +const languagesToTest = [ + { + code: 'es', + label: 'Español', + tagline: esStrings.hero.tagline, + themeLabel: esStrings.theme.toggle, + }, + { + code: 'fr', + label: 'Français', + tagline: frStrings.hero.tagline, + themeLabel: frStrings.theme.toggle, + }, + { + code: 'de', + label: 'Deutsch', + tagline: deStrings.hero.tagline, + themeLabel: deStrings.theme.toggle, + }, + { + code: 'it', + label: 'Italiano', + tagline: itStrings.hero.tagline, + themeLabel: itStrings.theme.toggle, + }, + { + code: 'ja', + label: '日本語', + tagline: jaStrings.hero.tagline, + themeLabel: jaStrings.theme.toggle, + }, + { + code: 'ko', + label: '한국어', + tagline: koStrings.hero.tagline, + themeLabel: koStrings.theme.toggle, + }, + { + code: 'pt-br', + label: 'Português', + tagline: ptBrStrings.hero.tagline, + themeLabel: ptBrStrings.theme.toggle, + }, + { + code: 'zh-cn', + label: '简体中文', + tagline: zhCnStrings.hero.tagline, + themeLabel: zhCnStrings.theme.toggle, + }, + { + code: 'zh-tw', + label: '繁體中文', + tagline: zhTwStrings.hero.tagline, + themeLabel: zhTwStrings.theme.toggle, + }, +]; + +/** + * Homepage E2E Tests + * Includes: Hero, Features, Theme Toggle, Kawaii Toggle, Footer and Language Selector tests. + * + * TODO: Add tests for Sidebar and Orama Search component in the future. + */ + +test.describe('Homepage Content', () => { + let latestVersion: string; + + test.beforeAll(async () => { + const response = await fetch('https://registry.npmjs.org/express/latest'); + const data = await response.json(); + latestVersion = data.version; + }); + + test.beforeEach(async ({ page }) => { + await page.goto('/en'); + }); + + // testing hero component + test('should display the core brand elements and features', async ({ page }) => { + const heroBrand = page.getByTestId('hero-brand'); + + // 1. Verify Tagline + const tagline = page.getByRole('heading', { level: 1 }); + await expect(tagline).toBeVisible(); + await expect(tagline).toHaveText(enStrings.hero.tagline); + + // 2. Verify Logos + const standardLogo = heroBrand.getByTestId('logo-standard').first(); + const kawaiiLogo = heroBrand.getByAltText(enStrings.hero.kawaiiLogoAlt); + + await expect(standardLogo).toBeVisible(); + await expect(kawaiiLogo).toBeHidden(); + + // 3. Verify Version + const versionText = heroBrand.getByText(latestVersion); + await expect(versionText).toBeVisible(); + + // 4. Verify Features Section + const featureSection = page.getByTestId('features-section'); + const featuresTitle = featureSection.getByRole('heading', { level: 2 }); + await expect(featuresTitle).toHaveText(enStrings.features.title); + + // 5. Verify all four feature cards + const features = [ + enStrings.features.webapplication, + enStrings.features.api, + enStrings.features.performance, + enStrings.features.middleware, + ]; + + for (const feature of features) { + const card = featureSection.getByTestId('card').filter({ + has: page.getByRole('heading', { name: feature.title, exact: true }), + }); + await expect(card).toBeVisible(); + await expect(card.locator('p')).toHaveText(feature.body); + } + }); + + test('should display the installation command', async ({ page }) => { + const installCode = page.getByTestId('install-command'); + await expect(installCode).toBeVisible(); + await expect(installCode).toContainText(/npm install express --save/i); + }); + + test('should have a working "Get Started" call to action', async ({ page }) => { + const getStartedBtn = page.getByRole('link', { name: enStrings.hero.getStarted }); + await expect(getStartedBtn).toHaveAttribute('href', /\/en\/5x\/starter\/installing/); + + await getStartedBtn.click(); + await expect(page).toHaveURL(/\/en\/5x\/starter\/installing/); + }); + + test('should display the example code block', async ({ page }) => { + const exampleCode = page.getByTestId('example-code'); + await expect(exampleCode).toBeVisible(); + }); + + // testing theme toggle component + test('should toggle between light and dark themes', async ({ page }) => { + const root = page.locator('html'); + const toggleBtn = page.locator('#theme-toggle-button'); + + await expect(toggleBtn).toHaveAttribute('aria-label', enStrings.theme.toggle); + + const initialTheme = (await root.getAttribute('data-theme')) || 'light'; + const targetTheme = initialTheme === 'light' ? 'dark' : 'light'; + + await toggleBtn.click(); + await expect(root).toHaveAttribute('data-theme', targetTheme); + + const savedTheme = await page.evaluate(() => localStorage.getItem('theme')); + expect(savedTheme).toBe(targetTheme); + + await toggleBtn.click(); + await expect(root).toHaveAttribute('data-theme', initialTheme); + }); + + // Testing languages selector + for (const lang of languagesToTest) { + test(`should switch to ${lang.label} successfully`, async ({ page }) => { + // 1. Find language switcher button (using translated aria-label) + const langSwitcher = page.getByRole('button', { name: enStrings.language.selectLabel }); + await expect(langSwitcher).toBeVisible(); + + // 2. Click to open dropdown + await langSwitcher.click(); + + // 3. Select the language option + const option = page.getByRole('option', { name: lang.label }); + await expect(option).toBeVisible(); + await option.click(); + + // 4. Verify URL change (handling potential trailing slashes or other path parts) + await expect(page).toHaveURL(new RegExp(`/${lang.code}`)); + + // 5. Verify tagline is correctly translated + const tagline = page.getByRole('heading', { level: 1 }); + await expect(tagline).toHaveText(lang.tagline); + + // 6. Verify theme toggle aria-label is correctly translated + const themeToggle = page.locator('#theme-toggle-button'); + await expect(themeToggle).toHaveAttribute('aria-label', lang.themeLabel); + }); + } + + // testing footer component + test('should display copyright and foundation links', async ({ page }) => { + const footer = page.getByTestId('footer'); + await expect(footer).toBeVisible(); + + // Verify copyright text + await expect(footer).toContainText(/Copyright/); + await expect(footer).toContainText(/OpenJS Foundation/); + + // Verify key legal and foundation links + const foundationLinks = [ + { name: /The OpenJS Foundation/i, href: 'https://openjsf.org' }, + { name: /Privacy Policy/i, href: /privacy-policy/ }, + { name: /Code of Conduct/i, href: /code-of-conduct/ }, + { name: /Security Policy/i, href: /security\/policy/ }, + { name: /Trademark Policy/i, href: /trademark-policy/ }, + { name: /Terms of Use/i, href: /terms-of-use/ }, + ]; + + for (const link of foundationLinks) { + const locator = footer.getByRole('link', { name: link.name }).first(); + await expect(locator).toBeVisible(); + await expect(locator).toHaveAttribute('href', link.href); + } + }); + + test('should display social media links', async ({ page }) => { + const footer = page.getByTestId('footer'); + + const githubLink = footer.getByRole('link', { name: /GitHub/i }); + const youtubeLink = footer.getByRole('link', { name: /Youtube/i }); + const xLink = footer.getByRole('link', { name: /X account/i }); + const slackLink = footer.getByRole('link', { name: /slack/i }); + const openCollectiveLink = footer.getByRole('link', { name: /Open Collective/i }); + const blueskyLink = footer.getByRole('link', { name: /bluesky/i }); + const rssLink = footer.getByRole('link', { name: /RSS Feed/i }); + + await expect(githubLink).toBeVisible(); + await expect(youtubeLink).toBeVisible(); + await expect(xLink).toBeVisible(); + await expect(slackLink).toBeVisible(); + await expect(openCollectiveLink).toBeVisible(); + await expect(blueskyLink).toBeVisible(); + await expect(rssLink).toBeVisible(); + }); +});