Skip to content

Commit 896e3af

Browse files
committed
feat(test): add playwright tests
1 parent 479f73b commit 896e3af

File tree

6 files changed

+416
-19
lines changed

6 files changed

+416
-19
lines changed

.github/workflows/playwright.yml

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Security Notes
2+
# This workflow uses `pull_request_target`, so will run against all PRs automatically (without approval), be careful with allowing any user-provided code to be run here
3+
# Only selected Actions are allowed within this repository. Please refer to (https://github.com/nodejs/nodejs.org/settings/actions)
4+
# for the full list of available actions. If you want to add a new one, please reach out a maintainer with Admin permissions.
5+
# REVIEWERS, please always double-check security practices before merging a PR that contains Workflow changes!!
6+
# AUTHORS, please only use actions with explicit SHA references, and avoid using `@master` or `@main` references or `@version` tags.
7+
# MERGE QUEUE NOTE: This Workflow does not run on `merge_group` trigger, as this Workflow is not required for Merge Queue's
8+
9+
name: Playwright Tests
10+
11+
on:
12+
pull_request_target:
13+
branches:
14+
- main
15+
types:
16+
- labeled
17+
18+
defaults:
19+
run:
20+
# This ensures that the working directory is the root of the repository
21+
working-directory: ./
22+
23+
permissions:
24+
contents: read
25+
actions: read
26+
27+
jobs:
28+
get-vercel-preview:
29+
if: ${{ github.event.label.name == 'github_actions:pull-request' }}
30+
name: Get Vercel Preview
31+
runs-on: ubuntu-latest
32+
outputs:
33+
deployment_found: ${{ steps.set_outputs.outputs.deployment_found }}
34+
url: ${{ steps.set_outputs.outputs.url }}
35+
steps:
36+
- name: Capture Vercel Preview
37+
id: check_deployment
38+
uses: patrickedqvist/wait-for-vercel-preview@06c79330064b0e6ef7a2574603b62d3c98789125 # v1.3.2
39+
with:
40+
token: ${{ secrets.GITHUB_TOKEN }}
41+
max_timeout: 300 # timeout after 5 minutes
42+
check_interval: 10 # check every 10 seconds
43+
continue-on-error: true
44+
- name: Set Outputs
45+
if: always()
46+
id: set_outputs
47+
run: |
48+
if [[ -z "${{ steps.check_deployment.outputs.url }}" ]]; then
49+
echo "deployment_found=false" >> $GITHUB_OUTPUT
50+
else
51+
echo "deployment_found=true" >> $GITHUB_OUTPUT
52+
echo "url=${{ steps.check_deployment.outputs.url }}" >> $GITHUB_OUTPUT
53+
fi
54+
55+
playwright:
56+
needs: get-vercel-preview
57+
if: needs.get-vercel-preview.outputs.deployment_found == 'true'
58+
name: Playwright Tests
59+
runs-on: ubuntu-latest
60+
61+
steps:
62+
- name: Harden Runner
63+
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
64+
with:
65+
egress-policy: audit
66+
67+
- name: Git Checkout
68+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
69+
with:
70+
# Provides the Pull Request commit SHA or the GitHub merge group ref
71+
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.ref }}
72+
73+
- name: Set up pnpm
74+
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
75+
with:
76+
cache: true
77+
78+
- name: Set up Node.js
79+
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
80+
with:
81+
# We want to ensure that the Node.js version running here respects our supported versions
82+
node-version-file: '.nvmrc'
83+
cache: 'pnpm'
84+
85+
- name: Install packages
86+
run: pnpm install --frozen-lockfile
87+
88+
- name: Run Playwright tests
89+
working-directory: apps/site
90+
run: pnpm playwright

.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

apps/site/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"lint:fix": "turbo run lint:md lint:js lint:css --no-cache -- --fix",
1414
"lint:js": "eslint \"**/*.{js,mjs,ts,tsx}\"",
1515
"lint:md": "eslint \"**/*.md?(x)\" --cache --cache-strategy=content --cache-location=.eslintmdcache",
16+
"playwright": "playwright test",
1617
"scripts:release-post": "cross-env NODE_NO_WARNINGS=1 node scripts/release-post/index.mjs",
1718
"serve": "pnpm dev",
1819
"start": "cross-env NODE_NO_WARNINGS=1 next start",
@@ -85,6 +86,7 @@
8586
"@flarelabs-net/wrangler-build-time-fs-assets-polyfilling": "^0.0.0",
8687
"@next/eslint-plugin-next": "15.3.1",
8788
"@opennextjs/cloudflare": "^1.0.0-beta.4",
89+
"@playwright/test": "^1.52.0",
8890
"@testing-library/user-event": "~14.6.1",
8991
"@types/semver": "~7.7.0",
9092
"eslint-config-next": "15.3.1",

apps/site/playwright.config.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { defineConfig, devices } from '@playwright/test';
2+
3+
export default defineConfig({
4+
testDir: './tests/e2e',
5+
fullyParallel: true,
6+
forbidOnly: !!process.env.CI,
7+
retries: process.env.CI ? 2 : 0,
8+
workers: process.env.CI ? 1 : undefined,
9+
reporter: process.env.CI ? 'github' : 'html',
10+
use: {
11+
baseURL: process.env.VERCEL_PREVIEW_URL || 'http://127.0.0.1:3000',
12+
13+
trace: 'on-first-retry',
14+
},
15+
16+
projects: [
17+
{
18+
name: 'chromium',
19+
use: { ...devices['Desktop Chrome'] },
20+
},
21+
22+
{
23+
name: 'firefox',
24+
use: { ...devices['Desktop Firefox'] },
25+
},
26+
27+
{
28+
name: 'webkit',
29+
use: { ...devices['Desktop Safari'] },
30+
},
31+
],
32+
33+
webServer: {
34+
command: 'pnpm dev',
35+
url: 'http://127.0.0.1:3000',
36+
reuseExistingServer: !process.env.CI,
37+
},
38+
});
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
const selectors = {
4+
theme: {
5+
toggleButton: 'button[aria-label*="Toggle Dark Mode"]',
6+
},
7+
language: {
8+
selector: 'button[aria-label*="Choose Language"]',
9+
options: '[data-radix-menu-content] [data-radix-collection-item]',
10+
content: 'main',
11+
},
12+
search: {
13+
button: 'orama-button',
14+
input: 'orama-input',
15+
results: 'orama-search-results',
16+
},
17+
navigation: {
18+
menuItems: 'nav > div.hidden.peer-checked\\:flex a',
19+
mobileMenuToggle: '[for="sidebarItemToggler"]',
20+
},
21+
};
22+
23+
test.describe('Node.js Website General Behavior', () => {
24+
test.beforeEach(async ({ page }) => {
25+
await page.goto('/en');
26+
});
27+
28+
test.describe('Theme', () => {
29+
test.describe('Theme Toggle', () => {
30+
test('should change appearance when theme button is clicked', async ({
31+
page,
32+
}) => {
33+
const themeToggle = page.locator(selectors.theme.toggleButton);
34+
35+
const getTheme = () =>
36+
page.evaluate(() => document.documentElement.dataset.theme);
37+
const initialTheme = await getTheme();
38+
39+
await themeToggle.click();
40+
41+
expect(await getTheme()).not.toEqual(initialTheme);
42+
});
43+
});
44+
45+
test.describe('Theme Persistence', () => {
46+
test('should persist theme preference across page navigation', async ({
47+
page,
48+
}) => {
49+
// Toggle theme to non-default
50+
const themeToggle = page.locator(selectors.theme.toggleButton);
51+
await themeToggle.click();
52+
53+
// Get the new theme
54+
const getTheme = () =>
55+
page.evaluate(() => document.documentElement.dataset.theme);
56+
const changedTheme = await getTheme();
57+
58+
// Navigate to a different page
59+
await page
60+
.locator(selectors.navigation.menuItems)
61+
.filter({ hasText: 'Learn' })
62+
.first()
63+
.click();
64+
65+
// Verify theme persists
66+
expect(await getTheme()).toBe(changedTheme);
67+
});
68+
});
69+
70+
test.describe('System Preference', () => {
71+
test('should respect user OS preference initially', async ({
72+
browser,
73+
}) => {
74+
// Create context with color scheme preference
75+
const context = await browser.newContext({
76+
colorScheme: 'dark',
77+
});
78+
79+
const page = await context.newPage();
80+
await page.goto('/en');
81+
82+
// Check if site initially matches OS preference
83+
const theme = await page.evaluate(
84+
() => document.documentElement.dataset.theme
85+
);
86+
expect(theme).toBe('dark');
87+
88+
await context.close();
89+
});
90+
});
91+
});
92+
93+
test.describe('Language', () => {
94+
test.describe('Language Selection', () => {
95+
test('should display language options and navigate to selected language', async ({
96+
page,
97+
}) => {
98+
await page.locator(selectors.language.selector).click();
99+
100+
const languageOptions = page.locator(selectors.language.options);
101+
await expect(languageOptions.first()).toBeVisible();
102+
await expect(languageOptions.count()).resolves.toBeGreaterThan(0);
103+
104+
const spanishOption = languageOptions.getByText(/Español/);
105+
await spanishOption.click();
106+
107+
await expect(page).toHaveURL(/\/es$/);
108+
});
109+
});
110+
111+
test.describe('Content Translation', () => {
112+
test('should load translated content correctly after language change', async ({
113+
page,
114+
}) => {
115+
// Store original English content
116+
const originalContent = await page
117+
.locator(selectors.language.content)
118+
.textContent();
119+
120+
// Change language to Spanish
121+
await page.locator(selectors.language.selector).click();
122+
const spanishOption = page
123+
.locator(selectors.language.options)
124+
.getByText(/Español/);
125+
await spanishOption.click();
126+
127+
// Wait for navigation
128+
await page.waitForURL(/\/es$/);
129+
130+
// Get Spanish content
131+
const translatedContent = await page
132+
.locator(selectors.language.content)
133+
.textContent();
134+
135+
// Verify content is different (translated)
136+
expect(translatedContent).not.toEqual(originalContent);
137+
expect(translatedContent?.length).toBeGreaterThan(0);
138+
});
139+
});
140+
});
141+
142+
test.describe('Search', () => {
143+
test.beforeEach(async ({ page }) => {
144+
// Open search before each test in this group
145+
await page.locator(selectors.search.button).click();
146+
await page.locator(selectors.search.input).waitFor({ state: 'visible' });
147+
});
148+
149+
test.describe('Search UI', () => {
150+
test('should show visible search input when opened', async ({ page }) => {
151+
await expect(page.locator(selectors.search.input)).toBeVisible();
152+
});
153+
});
154+
155+
test.describe('Search Results', () => {
156+
test('should display results for valid search term', async ({ page }) => {
157+
await page.locator(selectors.search.input).pressSequentially('express');
158+
159+
const searchResults = page.locator(selectors.search.results);
160+
await expect(searchResults).toBeVisible();
161+
await expect(searchResults.count()).resolves.toBeGreaterThan(0);
162+
});
163+
});
164+
});
165+
166+
test.describe('Navigation and Accessibility', () => {
167+
test.describe('Desktop Navigation', () => {
168+
test('should have working main navigation links', async ({ page }) => {
169+
// Get main navigation items
170+
const menuItems = page.locator(selectors.navigation.menuItems);
171+
172+
// Get count of menu items
173+
const count = await menuItems.count();
174+
expect(count).toBeGreaterThan(3); // Should have at least a few items
175+
176+
// Click on Docs link
177+
await menuItems.filter({ hasText: 'Docs' }).first().click();
178+
179+
// Verify we've navigated to docs page
180+
await expect(page.url()).toContain('/docs');
181+
182+
// Go back
183+
await page.goBack();
184+
185+
// Click on another navigation item
186+
await menuItems
187+
.filter({ hasText: /Download|Install/ })
188+
.first()
189+
.click();
190+
191+
// Verify navigation occurred
192+
await expect(page.url()).not.toEqual('/en');
193+
});
194+
});
195+
196+
test.describe('Mobile Navigation', () => {
197+
test('should have working mobile menu toggle on small viewport', async ({
198+
page,
199+
}) => {
200+
// Resize viewport to mobile size
201+
await page.setViewportSize({ width: 375, height: 667 });
202+
203+
// Verify mobile toggle is visible
204+
const menuToggle = page.locator(selectors.navigation.mobileMenuToggle);
205+
await expect(menuToggle).toBeVisible();
206+
207+
// Toggle menu
208+
await menuToggle.click();
209+
210+
// Verify menu items are visible after toggle
211+
await expect(
212+
page.locator(selectors.navigation.menuItems).first()
213+
).toBeVisible();
214+
215+
// Toggle menu closed
216+
await menuToggle.click();
217+
218+
// Verify menu items are hidden
219+
await expect(
220+
page.locator(selectors.navigation.menuItems).first()
221+
).not.toBeVisible();
222+
});
223+
});
224+
});
225+
});

0 commit comments

Comments
 (0)