feat(www) Add scaffolding for WWW E2E tests and CI check (#48861)

Closes DOCS-1278

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Feature. Adds E2E test scaffolding and a CI check for the marketing
site.

## What is the current behavior?

Closes [FE-4047](https://linear.app/supabase/issue/FE-4047).

The marketing site has no E2E coverage. Docs has a suite in `e2e/docs`,
but its
runner, git helpers and axe reporting are private to that package, so a
second
site cannot reuse them.

## What is the new behavior?

* **A www suite scoped to changed content.** Changed `.mdx` files in
`_blog`,
`_events`, `_customers` and `_alternatives` map to the URLs they render.
Pages
with `disable_page_build: true` are skipped because they 404 by design.
Capped
at 20 pages. Enforces `heading-order` and `page-has-heading-one`,
matching docs.
* **`e2e/shared` The docs site is also static with similar needs. This
folder shares the docs logic with www.
* **A CI check that is safe to mark required.** Path scoping lives in a
`Detect changed paths` step rather than a `paths:` trigger, so the check
  reports on every pull request instead of being skipped.
`waitForVercelDocsPreview.js` becomes `waitForVercelPreview.js`, shared
by both
  workflows.

## How the check behaves

The job always reports a check run, so it is safe to mark required. Path
scoping
happens in a step rather than a `paths:` trigger, which would leave
non-www pull
requests waiting on a check that never reports.

| Case | Behavior |
| --- | --- |
| Fork pull request adds new pages | Passes without testing. The Vercel
wait is gated on `head.repo.full_name == github.repository`, so forks
resolve no preview URL. The job emits a `::warning` and a job summary
containing a ready-to-run `gh workflow run www-e2e.yml` command with the
resolved page paths, so a maintainer can run it against the preview. |
| Vercel preview times out or fails | Passes without testing. The wait
step is `continue-on-error: true`, so a 900s timeout or a failed
deployment leaves the URL unset and the suite skips. Vercel's own
`Vercel – zone-www-dot-com` check already reports the failure. |
| Draft pull request | Job does not run at all, gated at the job level
on `pull_request.draft == false`. `ready_for_review` is in the trigger's
`types`, so marking it ready runs the check. |
| Another app changed, www untouched | Job runs and every step skips.
The `www` filter matches only the four content directories, `e2e/www`,
`e2e/shared`, the lockfile, and this workflow. |
| Only the harness changed | Passes without testing. Scope resolves to
zero pages, and the Vercel wait is additionally gated on `www_app`, so
it does not wait for a preview Vercel skipped. |
| No preview resolves, any reason | Skips rather than falling back to
production. Production does not serve pages the pull request adds, so
testing it would fail a valid change. |

### Not covered

Changes to `apps/www` components and routes do not trigger this check —
only the
four content directories do. A follow-up can check global components
such as the navigation and the footer.

## Manual testing

1. Start the site: `pnpm dev:www`
2. Run `pnpm e2e:www` with no www content changed. It should resolve
zero pages
   and skip Playwright, not fail.
3. Touch a post, then run `pnpm e2e:www` again:
`echo "" >> apps/www/_blog/2024-01-01-some-post.mdx`. The resolved
`/blog/...`
   path should be listed before Playwright starts.
4. Run against production with no local server:
`PLAYWRIGHT_BASE_URL=https://supabase.com
WWW_E2E_PAGE_PATHS=/blog/postgres-language-server pnpm e2e:www`
5. Point step 4 at a page with a known heading problem. The failure
should name
   the rule, the CSS selector and the markup.
6. Confirm docs still passes on the shared runner: `pnpm dev:docs`, then
   `pnpm e2e:docs`

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added WWW end-to-end testing for affected content pages, including
accessibility checks.
* Added standard and full-site test commands, configurable preview
testing, and failure reports.
* Added shared utilities for page discovery, accessibility scanning, and
test execution.

* **Documentation**
* Documented WWW test setup, coverage, debugging, CI behavior, and
running checks against production or preview environments.

* **Improvements**
* Updated documentation test workflows to better identify affected
changes and handle preview environments.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Miranda LimonczenkoandClaude Opus 5 authored and GitHub committed 2026-08-11 22:06:51 +00:00
1 parent 065468f8ac
commit 6d3a4bcc48
28 files changed
+1031 -402

No files matched your search

+6 -8
View File
@@ -55,12 +55,8 @@ jobs:
- 'apps/docs/content/guides/**/*.mdx'
- 'apps/docs/content/troubleshooting/**/*.mdx'
- 'apps/docs/content/_partials/**'
- 'e2e/docs/features/**'
- 'e2e/docs/utils/**'
- 'e2e/docs/scripts/**'
- 'e2e/docs/playwright.config.ts'
- 'e2e/docs/package.json'
- 'e2e/docs/tsconfig.json'
- 'e2e/docs/**'
- 'e2e/shared/**'
- 'pnpm-lock.yaml'
- '.github/workflows/docs-e2e.yml'
docs_app:
@@ -75,6 +71,7 @@ jobs:
fetch-depth: ${{ github.event_name == 'pull_request' && '0' || '1' }}
sparse-checkout: |
e2e/docs
e2e/shared
scripts
patches
apps/docs/content/guides
@@ -138,17 +135,18 @@ jobs:
# times out polling that API even though the preview builds fine.
# Poll the "Vercel – docs" commit status instead — Vercel keeps posting
# those — then resolve the deployment it points to via Vercel's own API
# to get the actual preview URL. See scripts/waitForVercelDocsPreview.js.
# to get the actual preview URL. See scripts/waitForVercelPreview.js.
# A Vercel failure or timeout is not the author's problem, and the required
# "Vercel – docs" check already reports it. Resolve no URL and skip below.
- name: Wait for Vercel docs preview
if: steps.scope.outputs.skip == 'false' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && steps.changes.outputs.docs_app == 'true'
id: deployment
continue-on-error: true
run: node scripts/waitForVercelDocsPreview.js
run: node scripts/waitForVercelPreview.js
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
VERCEL_STATUS_CONTEXT: 'Vercel – docs'
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }}
+208
View File
@@ -0,0 +1,208 @@
name: WWW E2E Tests
# Path scoping lives in "Detect changed paths" rather than a `paths` trigger, so
# this reports a check run on every pull request and stays safe to mark required.
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
branches: ['master']
workflow_dispatch:
inputs:
base_url:
description: 'Base URL to test against'
required: false
default: 'https://supabase.com'
type: string
page_paths:
description: 'Comma-separated site-relative paths to test (required for manual runs)'
required: false
default: ''
type: string
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
statuses: read
pull-requests: read
env:
CI: true
jobs:
e2e:
name: WWW E2E
if: github.event_name == 'workflow_dispatch' || github.event.pull_request.draft == false
timeout-minutes: 30
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Detect changed paths
id: changes
if: github.event_name == 'pull_request'
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
filters: |
www:
- 'apps/www/_blog/**/*.mdx'
- 'apps/www/_events/**/*.mdx'
- 'apps/www/_customers/**/*.mdx'
- 'apps/www/_alternatives/**/*.mdx'
- 'e2e/www/**'
- 'e2e/shared/**'
- 'pnpm-lock.yaml'
- '.github/workflows/www-e2e.yml'
www_app:
- 'apps/www/**'
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
if: github.event_name == 'workflow_dispatch' || steps.changes.outputs.www == 'true'
with:
persist-credentials: false
# String '0' — numeric 0 is falsy in Actions expressions.
fetch-depth: ${{ github.event_name == 'pull_request' && '0' || '1' }}
sparse-checkout: |
e2e/www
e2e/shared
scripts
patches
apps/www/_blog
apps/www/_events
apps/www/_customers
apps/www/_alternatives
- name: Use Node.js
if: github.event_name == 'workflow_dispatch' || steps.changes.outputs.www == 'true'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: '.nvmrc'
- name: Resolve www E2E scope
id: scope
if: github.event_name == 'workflow_dispatch' || steps.changes.outputs.www == 'true'
env:
EVENT_NAME: ${{ github.event_name }}
BASE_REF: ${{ github.base_ref }}
PAGE_PATHS_INPUT: ${{ inputs.page_paths }}
run: |
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
if [ -z "$PAGE_PATHS_INPUT" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "paths=" >> "$GITHUB_OUTPUT"
echo "Manual run requires the page_paths input."
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
printf 'paths=%s\n' "$PAGE_PATHS_INPUT" >> "$GITHUB_OUTPUT"
exit 0
fi
git diff --name-only --diff-filter=ACMR "origin/$BASE_REF"...HEAD \
| node --experimental-strip-types e2e/www/scripts/resolve-www-scope.ts
- name: Skip Playwright (no in-scope pages)
if: steps.scope.outputs.skip == 'true'
run: echo "No in-scope www pages changed; skipping Playwright suite."
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
if: steps.scope.outputs.skip == 'false'
name: Install pnpm
with:
run_install: false
- name: Enable pnpm store cache
if: steps.scope.outputs.skip == 'false'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
# Vercel skips the preview when only the harness changed, so wait for one
# only when apps/www changed. See scripts/waitForVercelPreview.js.
- name: Wait for Vercel www preview
if: steps.scope.outputs.skip == 'false' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && steps.changes.outputs.www_app == 'true'
id: deployment
continue-on-error: true
run: node scripts/waitForVercelPreview.js
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
VERCEL_STATUS_CONTEXT: 'Vercel – zone-www-dot-com'
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }}
- name: Resolve base URL
if: steps.scope.outputs.skip == 'false'
id: base-url
env:
EVENT_NAME: ${{ github.event_name }}
BASE_URL_INPUT: ${{ inputs.base_url }}
DEPLOYMENT_URL: ${{ steps.deployment.outputs.deployment-url }}
PAGE_PATHS: ${{ steps.scope.outputs.paths }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
printf 'url=%s\n' "$BASE_URL_INPUT" >> "$GITHUB_OUTPUT"
if [ "$BASE_URL_INPUT" = "https://supabase.com" ]; then
echo "use_bypass=false" >> "$GITHUB_OUTPUT"
else
echo "use_bypass=true" >> "$GITHUB_OUTPUT"
fi
echo "should_test=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ -n "$DEPLOYMENT_URL" ]; then
printf 'url=%s\n' "$DEPLOYMENT_URL" >> "$GITHUB_OUTPUT"
echo "use_bypass=true" >> "$GITHUB_OUTPUT"
echo "should_test=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Production lacks pages this pull request adds, so it is not a fallback.
echo "url=" >> "$GITHUB_OUTPUT"
echo "use_bypass=false" >> "$GITHUB_OUTPUT"
echo "should_test=false" >> "$GITHUB_OUTPUT"
echo "::warning::No Vercel www preview URL for this pull request. Skipping Playwright rather than testing production, which does not have pages this pull request adds."
{
echo "### WWW E2E skipped: no preview to test against"
echo
echo "Fork pull requests reach this path because they run without repository"
echo "secrets. A maintainer can run the suite against the preview manually:"
echo
echo '```'
echo "gh workflow run www-e2e.yml \\"
echo " -f base_url=<preview-url> \\"
echo " -f page_paths=$PAGE_PATHS"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Install dependencies
if: steps.base-url.outputs.should_test == 'true'
run: pnpm install --frozen-lockfile --filter=e2e-www...
- name: Install Playwright Chromium
if: steps.base-url.outputs.should_test == 'true'
run: pnpm -C e2e/www exec playwright install chromium --with-deps --only-shell
- name: Run www E2E
if: steps.base-url.outputs.should_test == 'true'
working-directory: e2e/www
run: pnpm run e2e:www
env:
PLAYWRIGHT_BASE_URL: ${{ steps.base-url.outputs.url }}
WWW_E2E_PAGE_PATHS: ${{ steps.scope.outputs.paths }}
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ steps.base-url.outputs.use_bypass == 'true' && secrets.VERCEL_AUTOMATION_BYPASS_WWW || '' }}
- name: Upload Playwright report
if: failure() && steps.scope.outputs.skip == 'false'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: www-playwright-report
path: |
e2e/www/playwright-report/
e2e/www/test-results/
retention-days: 7
+20
View File
@@ -130,6 +130,26 @@ og_image: /images/events/2025-01-meetup/custom-og.png # Optional override
**Note**: The `og_image` field is optional. If not provided, OG images are generated automatically via the Edge Function.
## End-to-end checks
Content pages are loaded and scanned with axe-core by the Playwright suite in
`e2e/www`. Pull requests test the pages your change affects. Today the suite
enforces one accessibility rule, `page-has-heading-one`.
To test the pages your current branch changes:
```bash
PLAYWRIGHT_BASE_URL=https://supabase.com pnpm e2e:www
```
That resolves which pages to test from your branch, but reads them from
production, so it won't see your edits and will 404 on a page you just added.
Point `PLAYWRIGHT_BASE_URL` at your pull request's preview to test your own
content.
See [`e2e/www/README.md`](https://github.com/supabase/supabase/blob/master/e2e/www/README.md)
for coverage and limits.
## Go pages (`/go/*`)
`/go/` is a system for building standalone campaign landing pages (lead generation, legal, thank-you flows). The name is intentionally generic — these pages are typically linked from ads, emails, or partner campaigns and are not part of the main site navigation.
+5 -7
View File
@@ -1,6 +1,7 @@
import { expect, test } from '@playwright/test'
import type { TestInfo } from '@playwright/test'
import { parsePagePaths } from '../../shared/paths.ts'
import {
attachScanReport,
blockingViolations,
@@ -11,15 +12,15 @@ import {
settleForAxe,
shouldEnforceAll,
unloadedResult,
violationIds,
} from '../utils/axe-helpers.js'
import {
articleSelectorForPagePath,
browserLikeUserAgent,
collectDocsOwnedLinks,
parseDocsE2EPagePaths,
} from '../utils/docs-links.js'
const pagePaths = parseDocsE2EPagePaths(process.env.DOCS_E2E_PAGE_PATHS)
const pagePaths = parsePagePaths(process.env.DOCS_E2E_PAGE_PATHS)
function annotate(testInfo: TestInfo, description: string) {
testInfo.annotations.push({ type: 'warning', description })
@@ -27,10 +28,7 @@ function annotate(testInfo: TestInfo, description: string) {
}
test.describe('Docs owned pages', () => {
// playwright.config.ts sets fullyParallel: false, and Playwright shards
// work by file rather than by test in that mode — without this, every test
// in this single spec file runs on one worker no matter what --workers is
// passed. Opt this describe block into parallel scheduling explicitly.
// Without this, every test in this file runs on one worker.
test.describe.configure({ mode: 'parallel' })
test('resolved page list must not be empty', () => {
@@ -140,7 +138,7 @@ test.describe('Docs owned pages', () => {
const enforced = shouldEnforceAll() ? 'all WCAG 2.1 A/AA rules' : ENFORCED_RULES.join(', ')
expect(
blocking,
violationIds(blocking),
`${pagePath} has blocking a11y violations (${enforced}):\n${formatViolations(blocking)}`
).toEqual([])
})
+1 -5
View File
@@ -20,8 +20,6 @@ export default defineConfig({
baseURL: `http://localhost:${WEB_SERVER_PORT}`,
browserName: 'chromium',
headless: true,
// Higher than usual: each run boots a fresh dev server, so the first
// request to a route pays Next.js's on-demand compile cost.
navigationTimeout: 60_000,
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
@@ -35,13 +33,11 @@ export default defineConfig({
['json', { outputFile: './test-results/local-smoke-results.json' }],
],
outputDir: './test-results',
// Explicitly cleared so this suite is credential-free even if the local
// .env.local has real secrets, and never reused so a server already
// running on this port (possibly with real credentials) can't be reused.
webServer: {
command: 'pnpm --workspace-root run dev:docs',
port: WEB_SERVER_PORT,
timeout: 4 * 60 * 1000,
// Never reuse a running server: it may hold real credentials from .env.local.
reuseExistingServer: false,
env: {
DOCS_GITHUB_APP_PRIVATE_KEY: '',
+7 -74
View File
@@ -1,82 +1,15 @@
#!/usr/bin/env node
/**
* Resolve docs E2E page scope from changed files.
*
* Usage:
* git diff --name-only origin/master...HEAD | node --experimental-strip-types scripts/resolve-docs-scope.ts
* node --experimental-strip-types scripts/resolve-docs-scope.ts --files a.mdx,b.mdx
*
* Outputs (GitHub Actions friendly):
* skip=true|false
* paths=<comma-separated /docs/... paths>
* Also prints each path on its own line to stderr for debugging.
*/
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { parseChangedFilesList, resolveDocsScope } from '../utils/resolve-docs-scope.ts'
import { runResolveScopeCli } from '../../shared/resolve-scope-cli.ts'
import { resolveDocsScope } from '../utils/resolve-docs-scope.ts'
const __dirname = dirname(fileURLToPath(import.meta.url))
const REPO_ROOT = join(__dirname, '../../..')
function readChangedFilesFromArgv(argv: string[]): string[] | null {
const filesIdx = argv.indexOf('--files')
if (filesIdx !== -1 && argv[filesIdx + 1]) {
return parseChangedFilesList(argv[filesIdx + 1])
}
return null
}
async function readStdin(): Promise<string> {
const chunks: Buffer[] = []
for await (const chunk of process.stdin) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk)
}
return Buffer.concat(chunks).toString('utf8')
}
async function main() {
const argv = process.argv.slice(2)
let changedFiles = readChangedFilesFromArgv(argv)
if (!changedFiles) {
if (process.stdin.isTTY) {
console.error('Pass changed files via stdin or --files path1,path2')
process.exit(2)
}
changedFiles = parseChangedFilesList(await readStdin())
}
const result = await resolveDocsScope({
changedFiles,
repoRoot: REPO_ROOT,
})
// GitHub Actions step outputs
const githubOutput = process.env.GITHUB_OUTPUT
const skipLine = `skip=${result.skip}`
const pathsLine = `paths=${result.pages.join(',')}`
if (githubOutput) {
// appendFile via sync to keep the CLI dependency-free
const { appendFileSync } = await import('node:fs')
appendFileSync(githubOutput, `${skipLine}\n${pathsLine}\n`)
} else {
console.log(skipLine)
console.log(pathsLine)
}
if (result.pages.length > 0) {
console.error(`Resolved ${result.pages.length} docs page(s):`)
for (const page of result.pages) {
console.error(` ${page}`)
}
} else {
console.error('No in-scope docs pages — skipping Playwright suite.')
}
}
main().catch((error) => {
runResolveScopeCli({
label: 'docs',
repoRoot: join(dirname(fileURLToPath(import.meta.url)), '../../..'),
resolveScope: resolveDocsScope,
}).catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exit(1)
})
+12 -169
View File
@@ -1,177 +1,20 @@
#!/usr/bin/env node
/**
* Default entry for `pnpm e2e:docs`.
*
* If DOCS_E2E_PAGE_PATHS is already set (CI, or an explicit local override),
* runs Playwright with that list. Otherwise resolves pages from files changed
* vs DOCS_E2E_BASE_REF (default origin/master), including the working tree.
*
* Pass `--all` to test every in-scope guide and troubleshooting page instead
* (hundreds of pages — expect a long run against a deployed site).
*
* Extra CLI args are forwarded to Playwright (e.g. --ui, a spec file path).
*/
import { spawn, spawnSync } from 'node:child_process'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
parseChangedFilesList,
resolveAllDocsPages,
resolveDocsScope,
} from '../utils/resolve-docs-scope.ts'
import { runSuite } from '../../shared/run-suite.ts'
import { resolveAllDocsPages, resolveDocsScope } from '../utils/resolve-docs-scope.ts'
const __dirname = dirname(fileURLToPath(import.meta.url))
const E2E_DOCS_ROOT = join(__dirname, '..')
// Mirrors the default in playwright.config.ts.
const DEFAULT_BASE_URL = 'http://localhost:3001'
const PREFLIGHT_TIMEOUT_MS = 3_000
async function isBaseUrlReachable(baseUrl: string): Promise<boolean> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), PREFLIGHT_TIMEOUT_MS)
try {
await fetch(baseUrl, { signal: controller.signal })
return true
} catch {
return false
} finally {
clearTimeout(timeout)
}
}
function git(args: string[], cwd: string): string {
const result = spawnSync('git', args, {
cwd,
encoding: 'utf8',
env: process.env,
})
if (result.status !== 0) {
const detail = (result.stderr || result.stdout || '').trim()
throw new Error(`git ${args.join(' ')} failed${detail ? `: ${detail}` : ''}`)
}
return result.stdout
}
function repoRootFromCwd(): string {
return git(['rev-parse', '--show-toplevel'], E2E_DOCS_ROOT).trim()
}
function collectChangedFiles(repoRoot: string, baseRef: string): string[] {
const ranges: string[][] = [
// Commits on this branch since diverging from the base
['diff', '--name-only', '--diff-filter=ACMR', `${baseRef}...HEAD`],
// Unstaged working tree
['diff', '--name-only', '--diff-filter=ACMR'],
// Staged working tree
['diff', '--name-only', '--diff-filter=ACMR', '--cached'],
]
const files = new Set<string>()
for (const args of ranges) {
try {
for (const file of parseChangedFilesList(git([...args], repoRoot))) {
files.add(file)
}
} catch (error) {
if (args.includes(`${baseRef}...HEAD`)) {
throw error
}
// Working-tree diffs can be empty / fail in odd git states; ignore those.
}
}
return [...files].sort()
}
async function resolveAllPagePaths(): Promise<string[]> {
const repoRoot = repoRootFromCwd()
const pages = await resolveAllDocsPages(repoRoot)
console.error(`Resolved all ${pages.length} in-scope docs page(s) (guides + troubleshooting).`)
return pages
}
async function resolvePagePaths(): Promise<string[] | null> {
const existing = process.env.DOCS_E2E_PAGE_PATHS?.trim()
if (existing) {
return existing
.split(/[\n,]/)
.map((p) => p.trim())
.filter(Boolean)
}
const baseRef = process.env.DOCS_E2E_BASE_REF?.trim() || 'origin/master'
const repoRoot = repoRootFromCwd()
const changedFiles = collectChangedFiles(repoRoot, baseRef)
const result = await resolveDocsScope({ changedFiles, repoRoot })
if (result.skip) {
console.error(
`No in-scope docs pages changed vs ${baseRef} (including working tree). Skipping Playwright.`
)
return null
}
console.error(`Resolved ${result.pages.length} docs page(s) from changes vs ${baseRef}:`)
for (const page of result.pages) {
console.error(` ${page}`)
}
return result.pages
}
async function main() {
const rawArgs = process.argv.slice(2)
const runAll = rawArgs.includes('--all')
const playwrightArgs = rawArgs.filter((arg) => arg !== '--all' && arg !== '--')
const pages = runAll ? await resolveAllPagePaths() : await resolvePagePaths()
if (pages === null) {
process.exit(0)
}
// playwright.config.ts sets a global maxFailures: 3, which would otherwise
// abort an exhaustive --all run after just 3 failing pages out of hundreds.
// -x is Playwright's shorthand for --max-failures=1.
const hasMaxFailuresArg = playwrightArgs.some(
(arg) => arg === '-x' || arg.startsWith('--max-failures')
)
const finalPlaywrightArgs =
runAll && !hasMaxFailuresArg ? [...playwrightArgs, '--max-failures=0'] : playwrightArgs
const baseUrl = process.env.PLAYWRIGHT_BASE_URL?.trim() || DEFAULT_BASE_URL
if (!(await isBaseUrlReachable(baseUrl))) {
console.error(`No docs server responding at ${baseUrl}.`)
if (!process.env.PLAYWRIGHT_BASE_URL) {
console.error('Start it with `pnpm dev:docs`, or point at a deployed site:')
console.error(' PLAYWRIGHT_BASE_URL=https://supabase.com pnpm e2e:docs')
} else {
console.error('Check that the URL is correct and reachable.')
}
process.exit(1)
}
const env = {
...process.env,
DOCS_E2E_PAGE_PATHS: pages.join(','),
}
const child = spawn('pnpm', ['exec', 'playwright', 'test', ...finalPlaywrightArgs], {
cwd: E2E_DOCS_ROOT,
env,
stdio: 'inherit',
shell: process.platform === 'win32',
})
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal)
return
}
process.exit(code ?? 1)
})
}
main().catch((error) => {
runSuite({
root: join(dirname(fileURLToPath(import.meta.url)), '..'),
label: 'docs',
devCommand: 'pnpm dev:docs',
pagePathsEnv: 'DOCS_E2E_PAGE_PATHS',
baseRefEnv: 'DOCS_E2E_BASE_REF',
defaultBaseUrl: 'http://localhost:3001',
resolveScope: resolveDocsScope,
resolveAllPages: resolveAllDocsPages,
}).catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exit(1)
})
+6 -51
View File
@@ -1,7 +1,8 @@
import { AxeBuilder } from '@axe-core/playwright'
import type { Page, TestInfo } from '@playwright/test'
import type { Result } from 'axe-core'
import { scan } from '../../shared/axe.ts'
export const WCAG_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']
export const ENFORCED_RULES = ['heading-order', 'page-has-heading-one']
@@ -33,50 +34,15 @@ export function shouldEnforceAll(): boolean {
return !!process.env.A11Y_ENFORCE_ALL
}
export async function settleForAxe(page: Page): Promise<void> {
await page.waitForLoadState('domcontentloaded')
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {})
await page
.evaluate(
({ quietMs, capMs }) =>
new Promise<void>((resolve) => {
let timer: ReturnType<typeof setTimeout>
const observer = new MutationObserver(() => {
clearTimeout(timer)
timer = setTimeout(finish, quietMs)
})
function finish() {
clearTimeout(cap)
clearTimeout(timer)
observer.disconnect()
resolve()
}
const cap = setTimeout(finish, capMs)
timer = setTimeout(finish, quietMs)
observer.observe(document.body, { subtree: true, childList: true, attributes: true })
}),
{ quietMs: 500, capMs: 5_000 }
)
.catch(() => {})
}
export async function scanArticle(
page: Page,
surface: string,
include: string
): Promise<A11yScanResult> {
const scan = () => new AxeBuilder({ page }).setLegacyMode(true).include(include)
const reported = await scan(page, { tags: WCAG_TAGS, excludeRules: EXCLUDED_RULES, include })
const enforced = await scan(page, { rules: ENFORCED_RULES, include })
const reported = await scan().withTags(WCAG_TAGS).disableRules(EXCLUDED_RULES).analyze()
const enforced = await scan().withRules(ENFORCED_RULES).analyze()
const byRule = new Map(
[...reported.violations, ...enforced.violations].map((violation) => [violation.id, violation])
)
const byRule = new Map([...reported, ...enforced].map((violation) => [violation.id, violation]))
const elementCount = await page.evaluate(
(selector) => document.querySelector(selector)?.querySelectorAll('*').length ?? 0,
@@ -134,15 +100,4 @@ export function blockingViolations(result: A11yScanResult): Result[] {
return result.violations.filter((violation) => ENFORCED_RULES.includes(violation.id))
}
export function formatViolations(violations: Result[]): string {
return violations
.map(
(violation) =>
`${violation.id} (${violation.impact}, ${violation.nodes.length} node(s)): ${violation.help}\n` +
violation.nodes
.slice(0, 5)
.map((node) => ` ${node.target.join(' ')}\n ${node.html.slice(0, 200)}`)
.join('\n')
)
.join('\n')
}
export { formatViolations, settleForAxe, violationIds } from '../../shared/axe.ts'
+1 -29
View File
@@ -6,10 +6,6 @@ export const TROUBLESHOOTING_ARTICLE_SELECTOR =
const DOCS_PATH_PREFIX = '/docs'
const TROUBLESHOOTING_PATH_PREFIX = '/docs/guides/troubleshooting/'
/**
* Pick the main article selector for a docs page path.
* Both guides and troubleshooting entries expose a stable data-testid.
*/
export function articleSelectorForPagePath(pagePath: string): string {
const pathname = pagePath.startsWith('http') ? new URL(pagePath).pathname : pagePath
@@ -23,12 +19,6 @@ export function articleSelectorForPagePath(pagePath: string): string {
return GUIDE_ARTICLE_SELECTOR
}
/**
* Collect unique docs-owned links from the main article.
*
* Cross-app paths such as `/ui` and `/dashboard` are excluded because the
* docs preview does not own those routes.
*/
export async function collectDocsOwnedLinks(
page: Page,
baseURL: string,
@@ -65,26 +55,8 @@ export async function collectDocsOwnedLinks(
return [...links].sort()
}
/**
* Playwright's headless Chromium reports a `HeadlessChrome` UA string, which
* Vercel's bot protection blocks on some routes (notably /docs/reference/*)
* even though the same page loads fine for a real browser. Stripping
* `Headless` avoids that false positive when checking links out-of-band via
* page.request rather than an actual navigation.
*/
// Vercel bot protection blocks the HeadlessChrome UA on some routes; strip it.
export async function browserLikeUserAgent(page: Page): Promise<string> {
const userAgent = await page.evaluate(() => navigator.userAgent)
return userAgent.replace('HeadlessChrome', 'Chrome')
}
/**
* Parse DOCS_E2E_PAGE_PATHS (comma- or newline-separated /docs/... paths).
*/
export function parseDocsE2EPagePaths(raw: string | undefined): string[] {
if (!raw?.trim()) return []
return raw
.split(/[\n,]/)
.map((path) => path.trim())
.filter(Boolean)
.map((path) => (path.startsWith('/') ? path : `/${path}`))
}
+3 -49
View File
@@ -1,11 +1,8 @@
import { readdir, readFile } from 'node:fs/promises'
import { basename, join, relative, sep } from 'node:path'
import { basename, join, relative } from 'node:path'
import { normalizeRepoPath } from '../../shared/paths.ts'
/**
* Federated guide section prefixes — mirrors
* apps/docs/scripts/federated-content/sources/*.ts. Checked automatically
* against those files by assertFederatedSectionsInSync below.
*/
export const FEDERATED_SECTIONS = [
'graphql',
'database/extensions/wrappers',
@@ -26,12 +23,6 @@ const FEDERATED_CONTENT_SOURCES_DIR = 'apps/docs/scripts/federated-content/sourc
const PARTIAL_PATH_RE = /<\$Partial\b[\s\S]*?\bpath\s*=\s*"([^"]+)"[\s\S]*?\/?>/g
const SOURCE_SECTION_RE = /\bsection:\s*'([^']+)'/g
/**
* Compares FEDERATED_SECTIONS against the `section:` field declared in each
* apps/docs/scripts/federated-content/sources/*.ts file, so a section added
* or removed there can't silently drift from what this suite treats as
* out-of-scope.
*/
async function assertFederatedSectionsInSync(repoRoot: string): Promise<void> {
const sourcesDir = join(repoRoot, FEDERATED_CONTENT_SOURCES_DIR)
const sourceFiles = (await readdir(sourcesDir)).filter((file) => file.endsWith('.ts'))
@@ -70,11 +61,8 @@ async function assertFederatedSectionsInSync(repoRoot: string): Promise<void> {
}
export type ResolveDocsScopeOptions = {
/** Repo-root-relative changed file paths */
changedFiles: string[]
/** Absolute path to the monorepo root */
repoRoot: string
/** Max pages to test before the rest are truncated (default MAX_SCOPED_PAGES) */
maxPages?: number
}
@@ -83,10 +71,6 @@ export type ResolveDocsScopeResult = {
skip: boolean
}
function normalizeRepoPath(filePath: string): string {
return filePath.replaceAll('\\', '/')
}
function isFederatedGuideSlug(slug: string): boolean {
return FEDERATED_SECTIONS.some((section) => slug === section || slug.startsWith(`${section}/`))
}
@@ -95,9 +79,6 @@ function isHiddenMdx(filePath: string): boolean {
return basename(filePath).startsWith('_')
}
/**
* Map a changed content file to a docs URL, or null if not a testable page.
*/
export function changedFileToPagePath(filePath: string): string | null {
const normalized = normalizeRepoPath(filePath)
@@ -129,8 +110,6 @@ function partialRelPathFromChangedFile(filePath: string): string | null {
}
function normalizePartialRef(pathAttr: string): string | null {
// $Partial paths are relative to content/_partials (see Partial.ts).
// Leading-slash example-code paths are not real partials.
if (!pathAttr || pathAttr.startsWith('/') || pathAttr.startsWith('http')) {
return null
}
@@ -172,9 +151,7 @@ async function walkMdxFiles(dir: string): Promise<string[]> {
}
type PartialIndex = {
/** partial rel path → set of partial rel paths that include it */
includedByPartials: Map<string, Set<string>>
/** page URL → set of direct partial rel paths */
pagePartials: Map<string, Set<string>>
}
@@ -225,10 +202,6 @@ async function buildPartialIndex(repoRoot: string): Promise<PartialIndex> {
return { includedByPartials, pagePartials }
}
/**
* Expand a changed partial to all partials that transitively include it
* (including itself).
*/
function expandPartialClosure(
seed: string,
includedByPartials: Map<string, Set<string>>
@@ -266,11 +239,6 @@ function pagesUsingPartials(
return pages
}
/**
* Resolve which docs pages to E2E-test from a list of changed repo files.
* Truncates to maxPages (sorted) so a widely shared partial can't blow up
* runtime; use resolveAllDocsPages / `pnpm e2e:docs:all` to cover everything.
*/
export async function resolveDocsScope(
options: ResolveDocsScopeOptions
): Promise<ResolveDocsScopeResult> {
@@ -311,11 +279,6 @@ export async function resolveDocsScope(
}
}
/**
* List every in-scope docs page — all guides (excluding federated sections)
* and all troubleshooting entries — regardless of what changed. Used for
* full-suite runs rather than the default changed-files scope.
*/
export async function resolveAllDocsPages(repoRoot: string): Promise<string[]> {
await assertFederatedSectionsInSync(repoRoot)
@@ -342,12 +305,3 @@ export async function resolveAllDocsPages(repoRoot: string): Promise<string[]> {
return [...pages].sort()
}
/** Parse changed-file list from stdin or newline/comma-separated string. */
export function parseChangedFilesList(input: string): string[] {
return input
.split(/[\n,]/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => normalizeRepoPath(line.split(sep).join('/')))
}
+85
View File
@@ -0,0 +1,85 @@
import { AxeBuilder } from '@axe-core/playwright'
import type { Page } from '@playwright/test'
import type { Result } from 'axe-core'
export const MAX_REPORTED_NODES = 5
export const MAX_REPORTED_HTML = 200
export type ScanOptions = {
rules?: string[]
tags?: string[]
excludeRules?: string[]
include?: string
}
export async function settleForAxe(page: Page): Promise<void> {
await page.waitForLoadState('domcontentloaded')
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {})
await page
.evaluate(
({ quietMs, capMs }) =>
new Promise<void>((resolve) => {
let timer: ReturnType<typeof setTimeout>
const observer = new MutationObserver(() => {
clearTimeout(timer)
timer = setTimeout(finish, quietMs)
})
function finish() {
clearTimeout(cap)
clearTimeout(timer)
observer.disconnect()
resolve()
}
const cap = setTimeout(finish, capMs)
timer = setTimeout(finish, quietMs)
observer.observe(document.body, { subtree: true, childList: true, attributes: true })
}),
{ quietMs: 500, capMs: 5_000 }
)
.catch(() => {})
}
// Legacy mode keeps cross-origin embeds out of the scan. Rules and tags are
// mutually exclusive in one axe run, so `rules` wins when both are given.
export async function scan(page: Page, options: ScanOptions): Promise<Result[]> {
let builder = new AxeBuilder({ page }).setLegacyMode(true)
if (options.include) builder = builder.include(options.include)
if (options.rules) builder = builder.withRules(options.rules)
else if (options.tags) builder = builder.withTags(options.tags)
if (options.excludeRules?.length) builder = builder.disableRules(options.excludeRules)
const { violations } = await builder.analyze()
return violations
}
export function violationIds(violations: Result[]): string[] {
return violations.map((violation) => violation.id)
}
export function formatViolations(
violations: Result[],
maxNodes: number = MAX_REPORTED_NODES
): string {
return violations
.map((violation) => {
const shown = violation.nodes.slice(0, maxNodes)
const hidden = violation.nodes.length - shown.length
const nodes = shown
.map(
(node) => ` ${node.target.join(' ')}\n ${node.html.slice(0, MAX_REPORTED_HTML)}`
)
.join('\n')
const more = hidden > 0 ? `\n … +${hidden} more node(s)` : ''
return (
`${violation.id} (${violation.impact ?? 'unknown'}, ${violation.nodes.length} node(s)): ` +
`${violation.help}\n${nodes}${more}`
)
})
.join('\n')
}
+38
View File
@@ -0,0 +1,38 @@
import { spawnSync } from 'node:child_process'
import { parseChangedFilesList } from './paths.ts'
export function git(args: string[], cwd: string): string {
const result = spawnSync('git', args, { cwd, encoding: 'utf8', env: process.env })
if (result.status !== 0) {
const detail = (result.stderr || result.stdout || '').trim()
throw new Error(`git ${args.join(' ')} failed${detail ? `: ${detail}` : ''}`)
}
return result.stdout
}
export function repoRootFrom(cwd: string): string {
return git(['rev-parse', '--show-toplevel'], cwd).trim()
}
export function collectChangedFiles(repoRoot: string, baseRef: string): string[] {
const branchRange = `${baseRef}...HEAD`
const ranges = [
['diff', '--name-only', '--diff-filter=ACMR', branchRange],
['diff', '--name-only', '--diff-filter=ACMR'],
['diff', '--name-only', '--diff-filter=ACMR', '--cached'],
]
const files = new Set<string>()
for (const args of ranges) {
try {
for (const file of parseChangedFilesList(git(args, repoRoot))) {
files.add(file)
}
} catch (error) {
if (args.includes(branchRange)) throw error
}
}
return [...files].sort()
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "e2e-shared",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"@axe-core/playwright": "^4.12.1",
"@playwright/test": "^1.59.1",
"axe-core": "^4.12.1"
},
"devDependencies": {
"@types/node": "catalog:"
}
}
+22
View File
@@ -0,0 +1,22 @@
import { sep } from 'node:path'
export function normalizeRepoPath(filePath: string): string {
return filePath.replaceAll('\\', '/')
}
export function parseChangedFilesList(input: string): string[] {
return input
.split(/[\n,]/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => normalizeRepoPath(line.split(sep).join('/')))
}
export function parsePagePaths(raw: string | undefined): string[] {
if (!raw?.trim()) return []
return raw
.split(/[\n,]/)
.map((path) => path.trim())
.filter(Boolean)
.map((path) => (path.startsWith('/') ? path : `/${path}`))
}
+50
View File
@@ -0,0 +1,50 @@
import { parseChangedFilesList } from './paths.ts'
import type { ScopeResult } from './run-suite.ts'
export type ResolveScopeCliConfig = {
label: string
repoRoot: string
resolveScope: (options: { changedFiles: string[]; repoRoot: string }) => Promise<ScopeResult>
}
async function readStdin(): Promise<string> {
const chunks: Buffer[] = []
for await (const chunk of process.stdin) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk)
}
return Buffer.concat(chunks).toString('utf8')
}
export async function runResolveScopeCli(config: ResolveScopeCliConfig): Promise<void> {
const argv = process.argv.slice(2)
const filesIdx = argv.indexOf('--files')
let changedFiles: string[]
if (filesIdx !== -1 && argv[filesIdx + 1]) {
changedFiles = parseChangedFilesList(argv[filesIdx + 1])
} else if (process.stdin.isTTY) {
console.error('Pass changed files via stdin or --files path1,path2')
process.exit(2)
} else {
changedFiles = parseChangedFilesList(await readStdin())
}
const result = await config.resolveScope({ changedFiles, repoRoot: config.repoRoot })
const output = `skip=${result.skip}\npaths=${result.pages.join(',')}\n`
if (process.env.GITHUB_OUTPUT) {
const { appendFileSync } = await import('node:fs')
appendFileSync(process.env.GITHUB_OUTPUT, output)
} else {
process.stdout.write(output)
}
if (result.pages.length > 0) {
console.error(`Resolved ${result.pages.length} ${config.label} page(s):`)
for (const page of result.pages) {
console.error(` ${page}`)
}
} else {
console.error(`No in-scope ${config.label} pages — skipping Playwright suite.`)
}
}
+108
View File
@@ -0,0 +1,108 @@
import { spawn } from 'node:child_process'
import { collectChangedFiles, repoRootFrom } from './git.ts'
export type ScopeResult = { pages: string[]; skip: boolean }
export type SuiteConfig = {
root: string
label: string
devCommand: string
pagePathsEnv: string
baseRefEnv: string
defaultBaseUrl: string
resolveScope: (options: { changedFiles: string[]; repoRoot: string }) => Promise<ScopeResult>
resolveAllPages?: (repoRoot: string) => Promise<string[]>
}
const PREFLIGHT_TIMEOUT_MS = 3_000
async function isBaseUrlReachable(baseUrl: string): Promise<boolean> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), PREFLIGHT_TIMEOUT_MS)
try {
await fetch(baseUrl, { signal: controller.signal })
return true
} catch {
return false
} finally {
clearTimeout(timeout)
}
}
async function resolvePagePaths(config: SuiteConfig): Promise<string[] | null> {
const explicit = process.env[config.pagePathsEnv]?.trim()
if (explicit) {
return explicit
.split(/[\n,]/)
.map((path) => path.trim())
.filter(Boolean)
}
const baseRef = process.env[config.baseRefEnv]?.trim() || 'origin/master'
const repoRoot = repoRootFrom(config.root)
const changedFiles = collectChangedFiles(repoRoot, baseRef)
const result = await config.resolveScope({ changedFiles, repoRoot })
if (result.skip) {
console.error(
`No in-scope ${config.label} pages changed vs ${baseRef} (including working tree). Skipping Playwright.`
)
return null
}
console.error(
`Resolved ${result.pages.length} ${config.label} page(s) from changes vs ${baseRef}:`
)
for (const page of result.pages) {
console.error(` ${page}`)
}
return result.pages
}
export async function runSuite(config: SuiteConfig): Promise<void> {
const rawArgs = process.argv.slice(2)
const runAll = rawArgs.includes('--all')
const playwrightArgs = rawArgs.filter((arg) => arg !== '--all' && arg !== '--')
let pages: string[] | null
if (runAll && config.resolveAllPages) {
pages = await config.resolveAllPages(repoRootFrom(config.root))
console.error(`Resolved all ${pages.length} in-scope ${config.label} page(s).`)
} else {
pages = await resolvePagePaths(config)
}
if (pages === null) process.exit(0)
const hasMaxFailuresArg = playwrightArgs.some(
(arg) => arg === '-x' || arg.startsWith('--max-failures')
)
const finalArgs =
runAll && !hasMaxFailuresArg ? [...playwrightArgs, '--max-failures=0'] : playwrightArgs
const baseUrl = process.env.PLAYWRIGHT_BASE_URL?.trim() || config.defaultBaseUrl
if (!(await isBaseUrlReachable(baseUrl))) {
console.error(`No ${config.label} server responding at ${baseUrl}.`)
if (!process.env.PLAYWRIGHT_BASE_URL) {
console.error(`Start it with \`${config.devCommand}\`, or point at a deployed site.`)
} else {
console.error('Check that the URL is correct and reachable.')
}
process.exit(1)
}
const child = spawn('pnpm', ['exec', 'playwright', 'test', ...finalArgs], {
cwd: config.root,
env: { ...process.env, [config.pagePathsEnv]: pages.join(',') },
stdio: 'inherit',
shell: process.platform === 'win32',
})
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal)
return
}
process.exit(code ?? 1)
})
}
+5
View File
@@ -0,0 +1,5 @@
node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
+169
View File
@@ -0,0 +1,169 @@
# WWW E2E tests
This guide explains how to run Playwright end-to-end checks against marketing
site content pages.
Use this suite when you change blog posts, events, customer stories, or
alternatives pages under `apps/www`. It loads each in-scope page, checks that it
returns a successful status, and scans it for a single accessibility rule.
This page covers:
- [Set up](#set-up) — install the browser once
- [Run the tests](#run-the-tests) — the usual local command
- [Choose a target URL](#choose-a-target-url) — production, preview, or local www
- [Override which pages run](#override-which-pages-run) — when the default git
scope is wrong
- [What the suite covers](#what-the-suite-covers) — in-scope paths and limits
- [Debug failures](#debug-failures) — reports and traces
- [How CI uses this suite](#how-ci-uses-this-suite) — pull request behavior
## Set up
1. From this directory, install the Playwright Chromium browser once:
```bash
cd e2e/www
pnpm exec playwright install chromium
```
## Run the tests
By default, `pnpm e2e:www` tests pages affected by your current changes: commits
since `origin/master`, plus staged and unstaged working-tree files. If nothing
in scope changed, the command exits successfully without starting Playwright.
1. From the repository root, point the suite at a deployed site and run it:
```bash
PLAYWRIGHT_BASE_URL=https://supabase.com pnpm e2e:www
```
Extra arguments pass through to Playwright, so `pnpm e2e:www --ui` opens UI mode
and `pnpm e2e:www --grep @a11y` runs only the accessibility assertions.
### Run every in-scope page
To test every content page instead of a changed-files scope — for example, a
periodic full-site check — run:
```bash
PLAYWRIGHT_BASE_URL=https://supabase.com pnpm e2e:www:all
```
This ignores `WWW_E2E_PAGE_PATHS` and the 20-page cap described in
[Limits](#limits), and tests every page across all four content directories —
around 480 as of this writing. `--all` runs also default to
`--max-failures=0`, so a full run isn't cut short by `playwright.config.ts`'s
global `maxFailures: 3`. The suite runs one worker by default, so pass
`--workers` to parallelize it:
```bash
PLAYWRIGHT_BASE_URL=https://supabase.com pnpm e2e:www:all -- --workers=4
```
Against production, raising workers does not pay off: a serial full run finishes
in about 17 minutes with no failures, while four workers took longer and timed
out on 34 of 480 navigations. Those timeouts are load, not page defects. Prefer
the default single worker unless you are pointed at a preview or a local server.
## Choose a target URL
Tests use `PLAYWRIGHT_BASE_URL`. When unset, they default to the local www dev
server at `http://localhost:3000`.
Prefer a deployed site for day-to-day checks. Use the local server only when you
need unpublished content that production does not serve yet.
For a protected Vercel preview, also set `VERCEL_AUTOMATION_BYPASS_SECRET`.
To use the local server, start it with `pnpm dev:www` from the repository root
and run the suite without `PLAYWRIGHT_BASE_URL`.
## Override which pages run
Leave `WWW_E2E_PAGE_PATHS` unset to keep the default changed-files scope.
To test specific pages instead of the git diff:
```bash
WWW_E2E_PAGE_PATHS=/blog/supabase-steve-chavez \
PLAYWRIGHT_BASE_URL=https://supabase.com pnpm e2e:www
```
`WWW_E2E_PAGE_PATHS` accepts a comma- or newline-separated list of
site-relative paths. `WWW_E2E_BASE_REF` overrides the base ref the git diff
compares against.
## What the suite covers
### In scope
| Changed path | Behavior |
| ------------------------------ | --------------------------- |
| `apps/www/_blog/*.mdx` | Test `/blog/<slug>` |
| `apps/www/_events/*.mdx` | Test `/events/<slug>` |
| `apps/www/_customers/*.mdx` | Test `/customers/<slug>` |
| `apps/www/_alternatives/*.mdx` | Test `/alternatives/<slug>` |
Slugs come from the filename, matching `getAllPostSlugs` in
`apps/www/lib/posts.tsx`. Blog and event filenames drop their `YYYY-MM-DD-`
prefix; customers and alternatives use the filename as-is.
Each page gets one test: it must return a successful status, and an axe scan
must report no `page-has-heading-one` violations. That is the only rule enforced
today — add more to `ENFORCED_RULES` in `features/www-pages.spec.ts` once a class
of issue reaches zero across the site.
`ENFORCED_RULES` is deliberately separate from the docs suite's list. Docs
enforces `heading-order` as well; www cannot yet. A full-site scan found
`heading-order` violations on the large majority of content pages, almost all
from the same two shared components — the related-posts card (`h4` under an `h2`)
and a trailing `h6`. Enforcing it here would fail nearly every pull request.
### Out of scope
- Events with `disable_page_build: true`, which return a 404 by design
- Static marketing routes under `apps/www/pages` and `apps/www/app`
- Index and listing pages such as `/blog` and `/customers`
- Link checking, and every accessibility rule other than the one above
### Limits
Resolved scope is capped at 20 pages so a large content drop cannot explode
runtime. If a change resolves to more pages than that, only the first 20 in
sorted order are tested. To test beyond the cap, use `pnpm e2e:www:all` or set
`WWW_E2E_PAGE_PATHS` explicitly.
## Debug failures
1. Open the HTML report after a run:
```bash
pnpm -C e2e/www exec playwright show-report
```
2. Inspect traces and screenshots under `test-results/` for failed runs.
## How CI uses this suite
The workflow at `.github/workflows/www-e2e.yml` runs on pull requests that touch
owned www content, `e2e/www`, or `e2e/shared`.
1. Diff the pull request against its base branch and resolve in-scope page paths.
2. Skip Playwright when nothing in scope changed.
3. When `apps/www` changed, wait for the Vercel www preview and set
`PLAYWRIGHT_BASE_URL` to that preview. When no preview resolves, skip rather
than test against production, which does not have pages the pull request adds.
4. Run the suite with `WWW_E2E_PAGE_PATHS` set to the resolved list.
Draft pull requests stay skipped until you mark them ready for review. Manual
`workflow_dispatch` runs require a `page_paths` input and accept an optional
`base_url`, which defaults to production.
## Shared helpers
`e2e/shared` holds the pieces this suite and `e2e/docs` both use: git diff
collection, page-path parsing, the runner, and the scope-resolver CLI. Suite
directories keep only what is specific to them — for www, that is the
content-file-to-URL mapping in `utils/resolve-www-scope.ts`.
+36
View File
@@ -0,0 +1,36 @@
import { expect, test } from '@playwright/test'
import { formatViolations, scan, violationIds } from '../../shared/axe.ts'
import { parsePagePaths } from '../../shared/paths.ts'
const ENFORCED_RULES = ['page-has-heading-one']
const pagePaths = parsePagePaths(process.env.WWW_E2E_PAGE_PATHS)
test.describe('WWW content pages', () => {
test('resolved page list must not be empty', () => {
expect(
pagePaths.length,
'No pages to test. `pnpm e2e:www` resolves pages from git changes by default, ' +
'or set WWW_E2E_PAGE_PATHS explicitly.'
).toBeGreaterThan(0)
})
for (const pagePath of pagePaths) {
test(`${pagePath} loads and passes enforced a11y rules @a11y`, async ({ page }) => {
const response = await page.goto(pagePath)
expect(
response?.ok(),
`${pagePath} should return a successful status, got ${response?.status()}`
).toBeTruthy()
const violations = await scan(page, { rules: ENFORCED_RULES })
expect(
violationIds(violations),
`${pagePath} violates enforced a11y rules (${ENFORCED_RULES.join(', ')}):\n` +
formatViolations(violations)
).toEqual([])
})
}
})
+18
View File
@@ -0,0 +1,18 @@
{
"name": "e2e-www",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"e2e:www": "node --experimental-strip-types scripts/run-e2e-www.ts",
"e2e:www:all": "node --experimental-strip-types scripts/run-e2e-www.ts --all"
},
"dependencies": {
"@playwright/test": "^1.59.1"
},
"devDependencies": {
"@axe-core/playwright": "^4.12.1",
"@types/node": "catalog:",
"axe-core": "^4.12.1"
}
}
+31
View File
@@ -0,0 +1,31 @@
import { defineConfig } from '@playwright/test'
const IS_CI = !!process.env.CI
export default defineConfig({
testDir: './features',
testMatch: /.*\.spec\.ts/,
timeout: 60_000,
forbidOnly: IS_CI,
retries: IS_CI ? 2 : 0,
maxFailures: 3,
expect: { timeout: 15_000 },
fullyParallel: true,
workers: 1,
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
browserName: 'chromium',
headless: true,
navigationTimeout: 30_000,
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
extraHTTPHeaders: process.env.VERCEL_AUTOMATION_BYPASS_SECRET
? {
'x-vercel-protection-bypass': process.env.VERCEL_AUTOMATION_BYPASS_SECRET,
'x-vercel-set-bypass-cookie': 'true',
}
: undefined,
},
reporter: [['list'], ['html', { open: 'never', outputFolder: './playwright-report' }]],
outputDir: './test-results',
})
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env node
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { runResolveScopeCli } from '../../shared/resolve-scope-cli.ts'
import { resolveWwwScope } from '../utils/resolve-www-scope.ts'
runResolveScopeCli({
label: 'www',
repoRoot: join(dirname(fileURLToPath(import.meta.url)), '../../..'),
resolveScope: resolveWwwScope,
}).catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exit(1)
})
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env node
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { runSuite } from '../../shared/run-suite.ts'
import { resolveAllWwwPages, resolveWwwScope } from '../utils/resolve-www-scope.ts'
runSuite({
root: join(dirname(fileURLToPath(import.meta.url)), '..'),
label: 'www',
devCommand: 'pnpm dev:www',
pagePathsEnv: 'WWW_E2E_PAGE_PATHS',
baseRefEnv: 'WWW_E2E_BASE_REF',
defaultBaseUrl: 'http://localhost:3000',
resolveScope: resolveWwwScope,
resolveAllPages: resolveAllWwwPages,
}).catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exit(1)
})
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "nodenext",
"skipLibCheck": true,
"esModuleInterop": true,
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"types": ["node"]
}
}
+91
View File
@@ -0,0 +1,91 @@
import { readdir, readFile } from 'node:fs/promises'
import { basename, join } from 'node:path'
import { normalizeRepoPath } from '../../shared/paths.ts'
const DATE_PREFIX_LENGTH = 11
export const MAX_SCOPED_PAGES = 20
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/
const DISABLE_PAGE_BUILD_RE = /^\s*disable_page_build:\s*true\s*$/m
const CONTENT_SOURCES = [
{ dir: 'apps/www/_blog', urlPrefix: '/blog/', datePrefixed: true },
{ dir: 'apps/www/_events', urlPrefix: '/events/', datePrefixed: true },
{ dir: 'apps/www/_customers', urlPrefix: '/customers/', datePrefixed: false },
{ dir: 'apps/www/_alternatives', urlPrefix: '/alternatives/', datePrefixed: false },
] as const
export type ResolveWwwScopeOptions = {
changedFiles: string[]
repoRoot: string
maxPages?: number
}
export function changedFileToPagePath(filePath: string): string | null {
const normalized = normalizeRepoPath(filePath)
if (!normalized.endsWith('.mdx')) return null
for (const { dir, urlPrefix, datePrefixed } of CONTENT_SOURCES) {
if (!normalized.startsWith(`${dir}/`)) continue
const filename = normalized.slice(dir.length + 1)
if (filename.includes('/')) return null
const name = basename(filename, '.mdx')
const slug = datePrefixed ? name.substring(DATE_PREFIX_LENGTH) : name
return slug ? `${urlPrefix}${slug}` : null
}
return null
}
async function isPageBuilt(absolutePath: string): Promise<boolean> {
let source: string
try {
source = await readFile(absolutePath, 'utf8')
} catch (error) {
// A deleted page has no route to test; anything else is a real failure.
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
throw error
}
const frontmatter = FRONTMATTER_RE.exec(source)?.[1]
return !frontmatter || !DISABLE_PAGE_BUILD_RE.test(frontmatter)
}
export async function resolveWwwScope(options: ResolveWwwScopeOptions) {
const pages = new Set<string>()
for (const file of options.changedFiles) {
const page = changedFileToPagePath(file)
if (page && (await isPageBuilt(join(options.repoRoot, file)))) {
pages.add(page)
}
}
const sorted = [...pages].sort().slice(0, options.maxPages ?? MAX_SCOPED_PAGES)
return { pages: sorted, skip: sorted.length === 0 }
}
export async function resolveAllWwwPages(repoRoot: string): Promise<string[]> {
const pages = new Set<string>()
for (const { dir, urlPrefix, datePrefixed } of CONTENT_SOURCES) {
const absoluteDir = join(repoRoot, dir)
const entries = await readdir(absoluteDir, { withFileTypes: true }).catch(() => [])
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.mdx')) continue
if (!(await isPageBuilt(join(absoluteDir, entry.name)))) continue
const name = basename(entry.name, '.mdx')
const slug = datePrefixed ? name.substring(DATE_PREFIX_LENGTH) : name
if (slug) pages.add(`${urlPrefix}${slug}`)
}
}
return [...pages].sort()
}
+2
View File
@@ -41,6 +41,8 @@
"e2e:docs:a11y": "pnpm --prefix e2e/docs run e2e:docs:a11y",
"e2e:docs:ui": "pnpm --prefix e2e/docs run e2e:ui",
"e2e:docs:local-smoke": "pnpm --prefix e2e/docs run e2e:docs:local-smoke",
"e2e:www": "pnpm --prefix e2e/www run e2e:www",
"e2e:www:all": "pnpm --prefix e2e/www run e2e:www:all",
"perf:kong": "ab -t 5 -c 20 -T application/json http://localhost:8000/",
"perf:meta": "ab -t 5 -c 20 -T application/json http://localhost:5555/tables",
"setup:cli": "supabase start -x studio && supabase status --output json > keys.json && node scripts/generateLocalEnv.js",
+32
View File
@@ -2034,6 +2034,22 @@ importers:
specifier: ^4.12.1
version: 4.12.1
e2e/shared:
dependencies:
'@axe-core/playwright':
specifier: ^4.12.1
version: 4.12.1(playwright-core@1.59.1)
'@playwright/test':
specifier: ^1.59.1
version: 1.59.1
axe-core:
specifier: ^4.12.1
version: 4.12.1
devDependencies:
'@types/node':
specifier: 'catalog:'
version: 22.13.14
e2e/studio:
dependencies:
'@playwright/test':
@@ -2062,6 +2078,22 @@ importers:
specifier: 'catalog:'
version: 4.22.4
e2e/www:
dependencies:
'@playwright/test':
specifier: ^1.59.1
version: 1.59.1
devDependencies:
'@axe-core/playwright':
specifier: ^4.12.1
version: 4.12.1(playwright-core@1.59.1)
'@types/node':
specifier: 'catalog:'
version: 22.13.14
axe-core:
specifier: ^4.12.1
version: 4.12.1
packages/ai-commands:
dependencies:
'@serafin/schema-builder':
@@ -1,15 +1,17 @@
// Vercel's GitHub App has stopped writing GitHub Deployment objects since
// 2026-02-17 (broken app auth), so polling the Deployments API (as
// vercel/wait-for-deployment-action does) times out even though the docs
// preview builds fine. Poll the "Vercel – docs" commit status instead, then
// resolve the actual preview URL via Vercel's own deployments API.
// vercel/wait-for-deployment-action does) times out even though the preview
// builds fine. Poll the project's Vercel commit status instead, then resolve
// the actual preview URL via Vercel's own deployments API.
//
// Set VERCEL_STATUS_CONTEXT to the project's commit status name, e.g.
// "Vercel – docs" or "Vercel – zone-www-dot-com".
const { appendFileSync } = require('fs')
const STATUS_CONTEXT = 'Vercel – docs'
const TIMEOUT_MS = 900_000
const POLL_INTERVAL_MS = 15_000
async function fetchLatestStatus(repository, sha, githubToken) {
async function fetchLatestStatus(repository, sha, githubToken, statusContext) {
const url = `https://api.github.com/repos/${repository}/commits/${sha}/statuses`
const response = await fetch(url, {
headers: {
@@ -25,7 +27,7 @@ async function fetchLatestStatus(repository, sha, githubToken) {
const statuses = await response.json()
return statuses
.filter((status) => status.context === STATUS_CONTEXT)
.filter((status) => status.context === statusContext)
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]
}
@@ -68,21 +70,23 @@ async function main() {
const githubToken = process.env.GITHUB_TOKEN
const vercelToken = process.env.VERCEL_TOKEN
const teamId = process.env.VERCEL_TEAM_ID
const statusContext = process.env.VERCEL_STATUS_CONTEXT
if (!repository) throw new Error('GITHUB_REPOSITORY environment variable is required')
if (!sha) throw new Error('HEAD_SHA environment variable is required')
if (!githubToken) throw new Error('GITHUB_TOKEN environment variable is required')
if (!vercelToken) throw new Error('VERCEL_TOKEN environment variable is required')
if (!statusContext) throw new Error('VERCEL_STATUS_CONTEXT environment variable is required')
const start = Date.now()
for (;;) {
const latest = await fetchLatestStatus(repository, sha, githubToken)
const latest = await fetchLatestStatus(repository, sha, githubToken, statusContext)
if (latest?.state === 'success') {
if (!latest.target_url) {
throw new Error(
'Vercel docs commit status succeeded but had no target_url to resolve a deployment from'
`"${statusContext}" commit status succeeded but had no target_url to resolve a deployment from`
)
}
const deploymentUrl = await resolveDeploymentUrl(latest.target_url, vercelToken, teamId)
@@ -91,11 +95,11 @@ async function main() {
}
if (latest?.state === 'failure' || latest?.state === 'error') {
throw new Error(`Vercel docs deployment failed (commit status: ${latest.state})`)
throw new Error(`"${statusContext}" deployment failed (commit status: ${latest.state})`)
}
if (Date.now() - start > TIMEOUT_MS) {
throw new Error('Timed out after 900s waiting for the Vercel docs preview deployment')
throw new Error(`Timed out after 900s waiting for the "${statusContext}" preview deployment`)
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))