diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml
new file mode 100644
index 0000000000000..bbc1cb3353d53
--- /dev/null
+++ b/.github/workflows/playwright.yml
@@ -0,0 +1,107 @@
+# Security Notes
+# Only selected Actions are allowed within this repository. Please refer to (https://github.com/nodejs/nodejs.org/settings/actions)
+# for the full list of available actions. If you want to add a new one, please reach out a maintainer with Admin permissions.
+# REVIEWERS, please always double-check security practices before merging a PR that contains Workflow changes!!
+# AUTHORS, please only use actions with explicit SHA references, and avoid using `@master` or `@main` references or `@version` tags.
+# MERGE QUEUE NOTE: This Workflow does not run on `merge_group` trigger, as this Workflow is not required for Merge Queue's
+
+name: Playwright Tests
+
+on:
+ pull_request:
+ branches:
+ - main
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+ actions: read
+
+jobs:
+ get-vercel-preview:
+ name: Get Vercel Preview
+ runs-on: ubuntu-latest
+ outputs:
+ deployment_found: ${{ steps.set_outputs.outputs.deployment_found }}
+ url: ${{ steps.set_outputs.outputs.url }}
+ steps:
+ - name: Capture Vercel Preview
+ id: check_deployment
+ uses: patrickedqvist/wait-for-vercel-preview@06c79330064b0e6ef7a2574603b62d3c98789125 # v1.3.2
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ max_timeout: 300 # timeout after 5 minutes
+ check_interval: 10 # check every 10 seconds
+ continue-on-error: true
+ - name: Set Outputs
+ if: always()
+ id: set_outputs
+ run: |
+ if [[ -z "${{ steps.check_deployment.outputs.url }}" ]]; then
+ echo "deployment_found=false" >> $GITHUB_OUTPUT
+ else
+ echo "deployment_found=true" >> $GITHUB_OUTPUT
+ echo "url=${{ steps.check_deployment.outputs.url }}" >> $GITHUB_OUTPUT
+ fi
+
+ playwright:
+ needs: get-vercel-preview
+ if: needs.get-vercel-preview.outputs.deployment_found == 'true'
+ name: Playwright Tests
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Harden Runner
+ uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
+ with:
+ egress-policy: audit
+
+ - name: Git Checkout
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+
+ - name: Set up pnpm
+ uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
+ with:
+ cache: true
+
+ - name: Set up Node.js
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
+ with:
+ # We want to ensure that the Node.js version running here respects our supported versions
+ node-version-file: '.nvmrc'
+ cache: 'pnpm'
+
+ - name: Install packages
+ run: pnpm install --frozen-lockfile
+
+ - name: Get Playwright version
+ id: playwright-version
+ working-directory: apps/site
+ run: echo "version=$(pnpm exec playwright --version | awk '{print $2}')" >> $GITHUB_OUTPUT
+
+ - name: Cache Playwright browsers
+ id: playwright-cache
+ uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
+
+ - name: Install Playwright Browsers
+ working-directory: apps/site
+ run: pnpm exec playwright install --with-deps
+
+ - name: Run Playwright tests
+ working-directory: apps/site
+ run: pnpm playwright
+ env:
+ VERCEL_PREVIEW_URL: ${{ needs.get-vercel-preview.outputs.url }}
+
+ - name: Upload Playwright test results
+ if: always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: playwright-report
+ path: apps/site/playwright-report/
diff --git a/.gitignore b/.gitignore
index dd97ef0e64f2d..2c2edea1406ac 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,7 +33,6 @@ cache
# TypeScript
tsconfig.tsbuildinfo
-
dist/
# Ignore the blog-data json that we generate during dev and build
@@ -43,3 +42,7 @@ apps/site/public/blog-data.json
apps/site/.open-next
apps/site/.wrangler
+
+## Playwright
+test-results
+playwright-report
diff --git a/COLLABORATOR_GUIDE.md b/COLLABORATOR_GUIDE.md
index 6dcd3f49d5de2..857efecd5c037 100644
--- a/COLLABORATOR_GUIDE.md
+++ b/COLLABORATOR_GUIDE.md
@@ -18,6 +18,7 @@
- [Adding a Download Package Manager](#adding-a-download-package-manager)
- [Unit Tests and Storybooks](#unit-tests-and-storybooks)
- [General Guidelines for Unit Tests](#general-guidelines-for-unit-tests)
+ - [General Guidelines for Playwright E2E Tests](#general-guidelines-for-playwright-e2e-tests)
- [General Guidelines for Storybooks](#general-guidelines-for-storybooks)
- [Remarks on Technologies used](#remarks-on-technologies-used)
- [Seeking additional clarification](#seeking-additional-clarification)
@@ -437,6 +438,23 @@ Unit Tests are fundamental to ensure that code changes do not disrupt the functi
- Common Providers and Contexts from the lifecycle of our App, such as [`next-intl`][] should not be mocked but given an empty or fake context whenever possible.
- We recommend reading previous unit tests from the codebase for inspiration and code guidelines.
+### General Guidelines for Playwright E2E Tests
+
+End-to-end (E2E) tests are essential for ensuring that the entire application works correctly from a user's perspective:
+
+- E2E tests are located in the `apps/site/tests/e2e` directory.
+- We use [Playwright](https://playwright.dev/) as our E2E testing framework.
+- E2E tests should focus on user flows and critical paths through the application.
+- Tests should be written to be resilient to minor UI changes and should prioritize testing functionality over exact visual appearance.
+- When writing E2E tests:
+ - Use meaningful test descriptions that clearly indicate what is being tested.
+ - Group related tests using Playwright's test grouping features.
+ - Use page objects or similar patterns to keep tests maintainable.
+ - Minimize test interdependencies to prevent cascading failures.
+- Tests should run against the built application to accurately reflect the production environment.
+- We recommend reviewing existing E2E tests in the codebase for patterns and best practices.
+- If your feature involves complex user interactions or spans multiple pages, consider adding E2E tests to verify the complete flow.
+
### General Guidelines for Storybooks
Storybooks are an essential part of our development process. They help us to document our components and to ensure that the components are working as expected.
diff --git a/apps/site/.stylelintignore b/apps/site/.stylelintignore
index 82e0936e61013..bb4f97f4cd44d 100644
--- a/apps/site/.stylelintignore
+++ b/apps/site/.stylelintignore
@@ -17,3 +17,7 @@ styles/old
# Cloudflare Build Output
.open-next
.wrangler
+
+# Playwright
+test-results
+playwright-report
diff --git a/apps/site/components/withNavBar.tsx b/apps/site/components/withNavBar.tsx
index 882d98bcef78f..4d575157cc276 100644
--- a/apps/site/components/withNavBar.tsx
+++ b/apps/site/components/withNavBar.tsx
@@ -63,7 +63,7 @@ const WithNavBar: FC = () => {
+ page.evaluate(() => document.documentElement.dataset.theme);
+
+const openLanguageMenu = async (page: Page) => {
+ const button = page.getByRole('button', {
+ name: locators.languageDropdownName,
+ });
+ const selector = `[aria-labelledby=${await button.getAttribute('id')}]`;
+ await button.click();
+
+ await page.waitForSelector(selector);
+ return page.locator(selector);
+};
+
+const verifyTranslation = async (
+ page: Page,
+ locale: string | Record
+) => {
+ // Load locale data if string code provided (e.g., 'es', 'fr')
+ const localeData =
+ typeof locale === 'string' ? await importLocale(locale) : locale;
+
+ // Get navigation links and expected translations
+ const links = await page
+ .locator(locators.navLinksLocator)
+ .locator('a > span')
+ .all();
+ const expectedTexts = Object.values(
+ localeData.components.containers.navBar.links
+ );
+
+ // Verify each navigation link text matches an expected translation
+ for (const link of links) {
+ const linkText = await link.textContent();
+ expect(expectedTexts).toContain(linkText!.trim());
+ }
+};
+
+test.describe('Node.js Website', () => {
+ // Start each test from the English homepage
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/en');
+ });
+
+ test.describe('Theme', () => {
+ test('should toggle between light/dark themes', async ({ page }) => {
+ const themeToggle = page.getByRole('button', {
+ name: locators.themeToggleName,
+ });
+ await expect(themeToggle).toBeVisible();
+
+ const initialTheme = await getTheme(page);
+ await themeToggle.click();
+
+ const newTheme = await getTheme(page);
+ expect(newTheme).not.toEqual(initialTheme);
+ expect(['light', 'dark']).toContain(newTheme);
+ });
+
+ test('should persist theme across page navigation', async ({ page }) => {
+ const themeToggle = page.getByRole('button', {
+ name: locators.themeToggleName,
+ });
+ await themeToggle.click();
+ const selectedTheme = await getTheme(page);
+
+ await page.reload();
+ expect(await getTheme(page)).toBe(selectedTheme);
+ });
+
+ test('should respect system preference initially', async ({ browser }) => {
+ const context = await browser.newContext({ colorScheme: 'dark' });
+ const page = await context.newPage();
+
+ await page.goto('/en');
+ expect(await getTheme(page)).toBe('dark');
+
+ await context.close();
+ });
+ });
+
+ test.describe('Language', () => {
+ test('should correctly translate UI elements according to language files', async ({
+ page,
+ }) => {
+ await verifyTranslation(page, englishLocale);
+
+ // Change to Spanish and verify translations
+ const menu = await openLanguageMenu(page);
+ await menu.getByText(/español/i).click();
+ await page.waitForURL(/\/es$/);
+
+ await verifyTranslation(page, 'es');
+ });
+ });
+
+ test.describe('Search', () => {
+ test('should show and operate search functionality', async ({ page }) => {
+ // Open search dialog
+ await page.locator(locators.searchButtonTag).click();
+
+ // Verify search input is visible and enter a search term
+ const searchInput = page.locator(locators.searchInputTag);
+ await expect(searchInput).toBeVisible();
+ await searchInput.pressSequentially('express');
+
+ // Verify search results appear
+ const searchResults = page.locator(locators.searchResultsTag);
+ await expect(searchResults).toBeVisible();
+ });
+ });
+
+ test.describe('Navigation', () => {
+ test('should have functioning mobile menu on small screens', async ({
+ page,
+ }) => {
+ // Set mobile viewport size
+ await page.setViewportSize({ width: 375, height: 667 });
+
+ // Locate mobile menu toggle button and verify it's visible
+ const mobileToggle = page.getByRole('button', {
+ name: locators.mobileMenuToggleName,
+ });
+ await expect(mobileToggle).toBeVisible();
+
+ const navLinks = page.locator(locators.navLinksLocator);
+
+ // Toggle menu open and verify it's visible
+ await mobileToggle.click();
+ await expect(navLinks.first()).toBeVisible();
+
+ // Toggle menu closed and verify it's hidden
+ await mobileToggle.click();
+ await expect(navLinks.first()).not.toBeVisible();
+ });
+ });
+});
diff --git a/eslint.config.js b/eslint.config.js
index c0cd4b7e032c0..4359c1ca31f3e 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -16,6 +16,8 @@ export default [
'storybook-static/**',
'**/.wrangler',
'**/.open-next',
+ 'test-results',
+ 'playwright-report',
],
},
{
diff --git a/packages/i18n/lib/index.mjs b/packages/i18n/lib/index.mjs
index 12956d80a643b..25336fe7330a4 100644
--- a/packages/i18n/lib/index.mjs
+++ b/packages/i18n/lib/index.mjs
@@ -9,7 +9,9 @@ import localeConfig from '../config.json' with { type: 'json' };
* @returns {Promise>} The imported locale
*/
export const importLocale = async locale => {
- return import(`../locales/${locale}.json`).then(f => f.default);
+ return import(`../locales/${locale}.json`, { with: { type: 'json' } }).then(
+ f => f.default
+ );
};
/**
diff --git a/packages/ui-components/Common/ThemeToggle/index.tsx b/packages/ui-components/Common/ThemeToggle/index.tsx
index 01ada3d2765be..326338e75912b 100644
--- a/packages/ui-components/Common/ThemeToggle/index.tsx
+++ b/packages/ui-components/Common/ThemeToggle/index.tsx
@@ -1,24 +1,11 @@
import { MoonIcon, SunIcon } from '@heroicons/react/24/outline';
-import type { FC, MouseEvent } from 'react';
+import type { FC, ButtonHTMLAttributes } from 'react';
import styles from './index.module.css';
-type ThemeToggleProps = {
- onClick?: (event: MouseEvent) => void;
- ariaLabel: string;
-};
-
-const ThemeToggle: FC = ({
- onClick = () => {},
- ariaLabel,
-}) => {
+const ThemeToggle: FC> = props => {
return (
-