diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f97c702..c456dec0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,3 +53,28 @@ jobs: - run: uv run python manage.py createcachetable - run: uv run python manage.py makemigrations --check - run: just test-coverage + + playwright: + runs-on: ubuntu-latest + timeout-minutes: 60 + + env: + DJANGO_SETTINGS_MODULE: apps.guide.settings.e2e + + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + - run: npm ci + - run: npm run build + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + - run: uv sync --frozen + - run: npm run e2e:install + - run: npx playwright test + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 0f9ad9a3..acb79272 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,11 @@ apps/guide/settings/local.py content/evals/translations-inspect_ai/logs/ # Generated glossaries (regenerate with `just eval-glossary `) content/evals/translations-inspect_ai/glossary/ + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ +/db_e2e.sqlite3 diff --git a/AGENTS.md b/AGENTS.md index c8484919..30825c29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ Important context for AI coding agents working on this project. - `just lint`: Lint the project. - `just format`: Format project files. - `just test`: Run backend (Django) and frontend (Vitest) tests. +- `just e2e`: Run end-to-end tests with Playwright (requires `just e2e-install` first; stop any dev server on :8000 before running). - `just translations`: Generate and compile translation strings. ## Setup & run commands diff --git a/README.md b/README.md index bda42d12..0db6b959 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ If you're a Python or Django developer, fork the repo and join us. You'll find a - Run formatting (Ruff & Oxfmt) `just format` - Run linting (Ruff, Oxlint, Oxfmt, Stylelint) `just lint` - Run tests `just test` +- Run end-to-end tests `just e2e` (first-time setup: `just e2e-install`) # Other Notes diff --git a/apps/guide/settings/e2e.py b/apps/guide/settings/e2e.py new file mode 100644 index 00000000..7baff5ba --- /dev/null +++ b/apps/guide/settings/e2e.py @@ -0,0 +1,14 @@ +from .test import * # noqa: F403 + +DEBUG = True +ALLOWED_HOSTS = ["127.0.0.1", "localhost"] + +CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}} + +DATABASES = { + "default": dj_database_url.parse( # noqa: F405 + f"sqlite:///{BASE_DIR / 'db_e2e.sqlite3'}" # noqa: F405 + ) +} + +WAGTAILADMIN_BASE_URL = "http://127.0.0.1:8000" diff --git a/justfile b/justfile index c158b8c4..5d3b20bb 100644 --- a/justfile +++ b/justfile @@ -30,6 +30,24 @@ test-coverage: DJANGO_SETTINGS_MODULE=apps.guide.settings.test uv run coverage run manage.py test uv run coverage report +# Install Playwright browsers (Chromium, Firefox, Edge). +e2e-install: + npm run e2e:install + +# Run end-to-end tests with Playwright. Builds frontend assets first. +e2e: + npm run build + npm run e2e + +# Run end-to-end tests interactively with the Playwright UI. +e2e-ui: + npm run build + npm run e2e:ui + +# Open the last Playwright HTML report. +e2e-report: + npm run e2e:report + # Format the backend code with Ruff. format-backend: uv run ruff check . --fix diff --git a/package-lock.json b/package-lock.json index a84eba56..ba90b958 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "bootstrap": "^5.3.8" }, "devDependencies": { + "@playwright/test": "^1.62.1", + "@types/node": "^26.2.0", "@wagtail/stylelint-config-wagtail": "^2.0.1", "browserslist": "^4.29.3", "happy-dom": "^20.14.5", @@ -1453,6 +1455,22 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -4959,6 +4977,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/pngjs": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", diff --git a/package.json b/package.json index 0fc41899..c291bbce 100644 --- a/package.json +++ b/package.json @@ -12,13 +12,19 @@ "lint:js": "vp lint --report-unused-disable-directives", "lint:format": "vp fmt --check", "lint": "npm run lint:js && npm run lint:css && npm run lint:format", - "test": "vp test run" + "test": "vp test run", + "e2e": "playwright test", + "e2e:ui": "playwright test --ui", + "e2e:report": "playwright show-report", + "e2e:install": "playwright install --with-deps chromium firefox msedge" }, "dependencies": { "@popperjs/core": "^2.11.5", "bootstrap": "^5.3.8" }, "devDependencies": { + "@playwright/test": "^1.62.1", + "@types/node": "^26.2.0", "@wagtail/stylelint-config-wagtail": "^2.0.1", "browserslist": "^4.29.3", "happy-dom": "^20.14.5", diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 00000000..a077042b --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,41 @@ +// @ts-check +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [['list'], ['html', { open: 'never' }]], + use: { + baseURL: 'http://127.0.0.1:8000', + locale: 'en-US', + timezoneId: 'UTC', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'msedge', + use: { ...devices['Desktop Edge'], channel: 'msedge' }, + }, + ], + webServer: { + command: + 'rm -f db_e2e.sqlite3 && uv run python manage.py migrate && uv run python manage.py buildfixtures && uv run python manage.py runserver --noreload 127.0.0.1:8000', + url: 'http://127.0.0.1:8000/en/', + reuseExistingServer: false, + timeout: 120_000, + env: { + DJANGO_SETTINGS_MODULE: 'apps.guide.settings.e2e', + }, + }, +}); diff --git a/tests/e2e/smoke.spec.js b/tests/e2e/smoke.spec.js new file mode 100644 index 00000000..d90b2b66 --- /dev/null +++ b/tests/e2e/smoke.spec.js @@ -0,0 +1,40 @@ +import { expect, test } from '@playwright/test'; + +test('homepage loads with title and sections', async ({ page }) => { + await page.goto('/en/'); + await expect(page).toHaveURL(/\/en\/$/); + await expect( + page.getByRole('heading', { name: /Wagtail User Guide/i }), + ).toBeVisible(); + await expect( + page.getByRole('link', { name: /Tutorial/i }).first(), + ).toBeVisible(); +}); + +test('tutorial section page loads', async ({ page }) => { + const response = await page.goto('/en/tutorial/'); + expect(response?.status()).toBe(200); + await expect( + page.getByRole('heading', { name: /Tutorial/i }).first(), + ).toBeVisible(); +}); + +test('tutorial subpage loads', async ({ page }) => { + const response = await page.goto('/en/tutorial/getting-started/'); + expect(response?.status()).toBe(200); +}); + +test('how-to subpage loads', async ({ page }) => { + const response = await page.goto('/en/how-to/add-a-user/'); + expect(response?.status()).toBe(200); +}); + +test('unknown page returns 404', async ({ request }) => { + const response = await request.get('/en/does-not-exist/'); + expect(response.status()).toBe(404); +}); + +test('dutch locale loads', async ({ page }) => { + const response = await page.goto('/nl/'); + expect(response?.status()).toBe(200); +});