Skip to content

Commit 3eb8f8c

Browse files
authored
feat(test): add playwright tests (#7749)
* feat(test): add playwright tests * use roles when possible * Update general-behavior.spec.ts Signed-off-by: Aviv Keller <me@aviv.sh> * add browsers back Signed-off-by: Aviv Keller <me@aviv.sh> * use locator --------- Signed-off-by: Aviv Keller <me@aviv.sh>
1 parent 8161d6d commit 3eb8f8c

File tree

13 files changed

+387
-30
lines changed

13 files changed

+387
-30
lines changed

.github/workflows/playwright.yml

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# Security Notes
2+
# Only selected Actions are allowed within this repository. Please refer to (https://github.com/nodejs/nodejs.org/settings/actions)
3+
# for the full list of available actions. If you want to add a new one, please reach out a maintainer with Admin permissions.
4+
# REVIEWERS, please always double-check security practices before merging a PR that contains Workflow changes!!
5+
# AUTHORS, please only use actions with explicit SHA references, and avoid using `@master` or `@main` references or `@version` tags.
6+
# MERGE QUEUE NOTE: This Workflow does not run on `merge_group` trigger, as this Workflow is not required for Merge Queue's
7+
8+
name: Playwright Tests
9+
10+
on:
11+
pull_request:
12+
branches:
13+
- main
14+
15+
concurrency:
16+
group: ${{ github.workflow }}-${{ github.ref }}
17+
cancel-in-progress: true
18+
19+
permissions:
20+
contents: read
21+
actions: read
22+
23+
jobs:
24+
get-vercel-preview:
25+
name: Get Vercel Preview
26+
runs-on: ubuntu-latest
27+
outputs:
28+
deployment_found: ${{ steps.set_outputs.outputs.deployment_found }}
29+
url: ${{ steps.set_outputs.outputs.url }}
30+
steps:
31+
- name: Capture Vercel Preview
32+
id: check_deployment
33+
uses: patrickedqvist/wait-for-vercel-preview@06c79330064b0e6ef7a2574603b62d3c98789125 # v1.3.2
34+
with:
35+
token: ${{ secrets.GITHUB_TOKEN }}
36+
max_timeout: 300 # timeout after 5 minutes
37+
check_interval: 10 # check every 10 seconds
38+
continue-on-error: true
39+
- name: Set Outputs
40+
if: always()
41+
id: set_outputs
42+
run: |
43+
if [[ -z "${{ steps.check_deployment.outputs.url }}" ]]; then
44+
echo "deployment_found=false" >> $GITHUB_OUTPUT
45+
else
46+
echo "deployment_found=true" >> $GITHUB_OUTPUT
47+
echo "url=${{ steps.check_deployment.outputs.url }}" >> $GITHUB_OUTPUT
48+
fi
49+
50+
playwright:
51+
needs: get-vercel-preview
52+
if: needs.get-vercel-preview.outputs.deployment_found == 'true'
53+
name: Playwright Tests
54+
runs-on: ubuntu-latest
55+
56+
steps:
57+
- name: Harden Runner
58+
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
59+
with:
60+
egress-policy: audit
61+
62+
- name: Git Checkout
63+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
64+
65+
- name: Set up pnpm
66+
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
67+
with:
68+
cache: true
69+
70+
- name: Set up Node.js
71+
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
72+
with:
73+
# We want to ensure that the Node.js version running here respects our supported versions
74+
node-version-file: '.nvmrc'
75+
cache: 'pnpm'
76+
77+
- name: Install packages
78+
run: pnpm install --frozen-lockfile
79+
80+
- name: Get Playwright version
81+
id: playwright-version
82+
working-directory: apps/site
83+
run: echo "version=$(pnpm exec playwright --version | awk '{print $2}')" >> $GITHUB_OUTPUT
84+
85+
- name: Cache Playwright browsers
86+
id: playwright-cache
87+
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
88+
with:
89+
path: ~/.cache/ms-playwright
90+
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
91+
92+
- name: Install Playwright Browsers
93+
working-directory: apps/site
94+
run: pnpm exec playwright install --with-deps
95+
96+
- name: Run Playwright tests
97+
working-directory: apps/site
98+
run: pnpm playwright
99+
env:
100+
VERCEL_PREVIEW_URL: ${{ needs.get-vercel-preview.outputs.url }}
101+
102+
- name: Upload Playwright test results
103+
if: always()
104+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
105+
with:
106+
name: playwright-report
107+
path: apps/site/playwright-report/

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ cache
3333

3434
# TypeScript
3535
tsconfig.tsbuildinfo
36-
3736
dist/
3837

3938
# Ignore the blog-data json that we generate during dev and build
@@ -43,3 +42,7 @@ apps/site/public/blog-data.json
4342
apps/site/.open-next
4443
apps/site/.wrangler
4544

45+
46+
## Playwright
47+
test-results
48+
playwright-report

COLLABORATOR_GUIDE.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
- [Adding a Download Package Manager](#adding-a-download-package-manager)
1919
- [Unit Tests and Storybooks](#unit-tests-and-storybooks)
2020
- [General Guidelines for Unit Tests](#general-guidelines-for-unit-tests)
21+
- [General Guidelines for Playwright E2E Tests](#general-guidelines-for-playwright-e2e-tests)
2122
- [General Guidelines for Storybooks](#general-guidelines-for-storybooks)
2223
- [Remarks on Technologies used](#remarks-on-technologies-used)
2324
- [Seeking additional clarification](#seeking-additional-clarification)
@@ -437,6 +438,23 @@ Unit Tests are fundamental to ensure that code changes do not disrupt the functi
437438
- 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.
438439
- We recommend reading previous unit tests from the codebase for inspiration and code guidelines.
439440

441+
### General Guidelines for Playwright E2E Tests
442+
443+
End-to-end (E2E) tests are essential for ensuring that the entire application works correctly from a user's perspective:
444+
445+
- E2E tests are located in the `apps/site/tests/e2e` directory.
446+
- We use [Playwright](https://playwright.dev/) as our E2E testing framework.
447+
- E2E tests should focus on user flows and critical paths through the application.
448+
- Tests should be written to be resilient to minor UI changes and should prioritize testing functionality over exact visual appearance.
449+
- When writing E2E tests:
450+
- Use meaningful test descriptions that clearly indicate what is being tested.
451+
- Group related tests using Playwright's test grouping features.
452+
- Use page objects or similar patterns to keep tests maintainable.
453+
- Minimize test interdependencies to prevent cascading failures.
454+
- Tests should run against the built application to accurately reflect the production environment.
455+
- We recommend reviewing existing E2E tests in the codebase for patterns and best practices.
456+
- If your feature involves complex user interactions or spans multiple pages, consider adding E2E tests to verify the complete flow.
457+
440458
### General Guidelines for Storybooks
441459

442460
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.

apps/site/.stylelintignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,7 @@ styles/old
1717
# Cloudflare Build Output
1818
.open-next
1919
.wrangler
20+
21+
# Playwright
22+
test-results
23+
playwright-report

apps/site/components/withNavBar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ const WithNavBar: FC = () => {
6363

6464
<ThemeToggle
6565
onClick={toggleCurrentTheme}
66-
ariaLabel={t('components.common.themeToggle.label')}
66+
aria-label={t('components.common.themeToggle.label')}
6767
/>
6868

6969
<LanguageDropdown

apps/site/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"lint:fix": "turbo run lint:md lint:js lint:css --no-cache -- --fix",
1313
"lint:js": "eslint \"**/*.{js,mjs,ts,tsx}\"",
1414
"lint:md": "eslint \"**/*.md?(x)\" --cache --cache-strategy=content --cache-location=.eslintmdcache",
15+
"playwright": "playwright test",
1516
"scripts:release-post": "cross-env NODE_NO_WARNINGS=1 node scripts/release-post/index.mjs",
1617
"serve": "pnpm dev",
1718
"start": "cross-env NODE_NO_WARNINGS=1 next start",
@@ -78,6 +79,7 @@
7879
"@flarelabs-net/wrangler-build-time-fs-assets-polyfilling": "^0.0.0",
7980
"@next/eslint-plugin-next": "15.3.1",
8081
"@opennextjs/cloudflare": "^1.0.0-beta.4",
82+
"@playwright/test": "^1.52.0",
8183
"@testing-library/user-event": "~14.6.1",
8284
"@types/semver": "~7.7.0",
8385
"eslint-config-next": "15.3.1",

apps/site/playwright.config.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { defineConfig, devices } from '@playwright/test';
2+
3+
const isCI = !!process.env.CI;
4+
5+
// https://playwright.dev/docs/test-configuration
6+
export default defineConfig({
7+
testDir: './tests/e2e',
8+
fullyParallel: true,
9+
forbidOnly: isCI,
10+
retries: isCI ? 2 : 0,
11+
workers: isCI ? 1 : undefined,
12+
reporter: isCI ? [['html'], ['github']] : [['html']],
13+
use: {
14+
baseURL: process.env.VERCEL_PREVIEW_URL || 'http://127.0.0.1:3000',
15+
trace: 'on-first-retry',
16+
},
17+
18+
projects: [
19+
{
20+
name: 'chromium',
21+
use: { ...devices['Desktop Chrome'] },
22+
},
23+
{
24+
name: 'firefox',
25+
use: { ...devices['Desktop Firefox'] },
26+
},
27+
{
28+
name: 'webkit',
29+
use: { ...devices['Desktop Safari'] },
30+
},
31+
],
32+
});
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { importLocale } from '@node-core/website-i18n';
2+
import { test, expect, type Page } from '@playwright/test';
3+
4+
const englishLocale = await importLocale('en');
5+
6+
// TODO(@avivkeller): It would be ideal for all the Test IDs to not exist in the
7+
// ui-components package, and instead be passed as props.
8+
const locators = {
9+
// Navigation elements
10+
mobileMenuToggleName:
11+
englishLocale.components.containers.navBar.controls.toggle,
12+
navLinksLocator: `[aria-label="${englishLocale.components.containers.navBar.controls.toggle}"] + div`,
13+
// Global UI controls
14+
languageDropdownName: englishLocale.components.common.languageDropdown.label,
15+
themeToggleName: englishLocale.components.common.themeToggle.label,
16+
17+
// Search components (from Orama library)
18+
searchButtonTag: 'orama-button',
19+
searchInputTag: 'orama-input',
20+
searchResultsTag: 'orama-search-results',
21+
};
22+
23+
const getTheme = (page: Page) =>
24+
page.evaluate(() => document.documentElement.dataset.theme);
25+
26+
const openLanguageMenu = async (page: Page) => {
27+
const button = page.getByRole('button', {
28+
name: locators.languageDropdownName,
29+
});
30+
const selector = `[aria-labelledby=${await button.getAttribute('id')}]`;
31+
await button.click();
32+
33+
await page.waitForSelector(selector);
34+
return page.locator(selector);
35+
};
36+
37+
const verifyTranslation = async (
38+
page: Page,
39+
locale: string | Record<string, unknown>
40+
) => {
41+
// Load locale data if string code provided (e.g., 'es', 'fr')
42+
const localeData =
43+
typeof locale === 'string' ? await importLocale(locale) : locale;
44+
45+
// Get navigation links and expected translations
46+
const links = await page
47+
.locator(locators.navLinksLocator)
48+
.locator('a > span')
49+
.all();
50+
const expectedTexts = Object.values(
51+
localeData.components.containers.navBar.links
52+
);
53+
54+
// Verify each navigation link text matches an expected translation
55+
for (const link of links) {
56+
const linkText = await link.textContent();
57+
expect(expectedTexts).toContain(linkText!.trim());
58+
}
59+
};
60+
61+
test.describe('Node.js Website', () => {
62+
// Start each test from the English homepage
63+
test.beforeEach(async ({ page }) => {
64+
await page.goto('/en');
65+
});
66+
67+
test.describe('Theme', () => {
68+
test('should toggle between light/dark themes', async ({ page }) => {
69+
const themeToggle = page.getByRole('button', {
70+
name: locators.themeToggleName,
71+
});
72+
await expect(themeToggle).toBeVisible();
73+
74+
const initialTheme = await getTheme(page);
75+
await themeToggle.click();
76+
77+
const newTheme = await getTheme(page);
78+
expect(newTheme).not.toEqual(initialTheme);
79+
expect(['light', 'dark']).toContain(newTheme);
80+
});
81+
82+
test('should persist theme across page navigation', async ({ page }) => {
83+
const themeToggle = page.getByRole('button', {
84+
name: locators.themeToggleName,
85+
});
86+
await themeToggle.click();
87+
const selectedTheme = await getTheme(page);
88+
89+
await page.reload();
90+
expect(await getTheme(page)).toBe(selectedTheme);
91+
});
92+
93+
test('should respect system preference initially', async ({ browser }) => {
94+
const context = await browser.newContext({ colorScheme: 'dark' });
95+
const page = await context.newPage();
96+
97+
await page.goto('/en');
98+
expect(await getTheme(page)).toBe('dark');
99+
100+
await context.close();
101+
});
102+
});
103+
104+
test.describe('Language', () => {
105+
test('should correctly translate UI elements according to language files', async ({
106+
page,
107+
}) => {
108+
await verifyTranslation(page, englishLocale);
109+
110+
// Change to Spanish and verify translations
111+
const menu = await openLanguageMenu(page);
112+
await menu.getByText(/español/i).click();
113+
await page.waitForURL(/\/es$/);
114+
115+
await verifyTranslation(page, 'es');
116+
});
117+
});
118+
119+
test.describe('Search', () => {
120+
test('should show and operate search functionality', async ({ page }) => {
121+
// Open search dialog
122+
await page.locator(locators.searchButtonTag).click();
123+
124+
// Verify search input is visible and enter a search term
125+
const searchInput = page.locator(locators.searchInputTag);
126+
await expect(searchInput).toBeVisible();
127+
await searchInput.pressSequentially('express');
128+
129+
// Verify search results appear
130+
const searchResults = page.locator(locators.searchResultsTag);
131+
await expect(searchResults).toBeVisible();
132+
});
133+
});
134+
135+
test.describe('Navigation', () => {
136+
test('should have functioning mobile menu on small screens', async ({
137+
page,
138+
}) => {
139+
// Set mobile viewport size
140+
await page.setViewportSize({ width: 375, height: 667 });
141+
142+
// Locate mobile menu toggle button and verify it's visible
143+
const mobileToggle = page.getByRole('button', {
144+
name: locators.mobileMenuToggleName,
145+
});
146+
await expect(mobileToggle).toBeVisible();
147+
148+
const navLinks = page.locator(locators.navLinksLocator);
149+
150+
// Toggle menu open and verify it's visible
151+
await mobileToggle.click();
152+
await expect(navLinks.first()).toBeVisible();
153+
154+
// Toggle menu closed and verify it's hidden
155+
await mobileToggle.click();
156+
await expect(navLinks.first()).not.toBeVisible();
157+
});
158+
});
159+
});

eslint.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export default [
1616
'storybook-static/**',
1717
'**/.wrangler',
1818
'**/.open-next',
19+
'test-results',
20+
'playwright-report',
1921
],
2022
},
2123
{

0 commit comments

Comments
 (0)