Files
supabase/e2e/docs/utils/resolve-docs-scope.ts
Miranda Limonczenko 6d3a4bcc48 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>
2026-08-11 22:06:51 +00:00

308 lines
9.7 KiB
TypeScript

import { readdir, readFile } from 'node:fs/promises'
import { basename, join, relative } from 'node:path'
import { normalizeRepoPath } from '../../shared/paths.ts'
export const FEDERATED_SECTIONS = [
'graphql',
'database/extensions/wrappers',
'ai/python',
'deployment/terraform',
'deployment/ci',
] as const
export const MAX_SCOPED_PAGES = 20
const GUIDES_PREFIX = 'apps/docs/content/guides/'
const TROUBLESHOOTING_PREFIX = 'apps/docs/content/troubleshooting/'
const PARTIALS_PREFIX = 'apps/docs/content/_partials/'
const DOCS_GUIDES_URL_PREFIX = '/docs/guides/'
const DOCS_TROUBLESHOOTING_URL_PREFIX = '/docs/guides/troubleshooting/'
const FEDERATED_CONTENT_SOURCES_DIR = 'apps/docs/scripts/federated-content/sources'
const PARTIAL_PATH_RE = /<\$Partial\b[\s\S]*?\bpath\s*=\s*"([^"]+)"[\s\S]*?\/?>/g
const SOURCE_SECTION_RE = /\bsection:\s*'([^']+)'/g
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'))
const actualSections = new Set<string>()
for (const file of sourceFiles) {
const source = await readFile(join(sourcesDir, file), 'utf8')
const matches = [...source.matchAll(SOURCE_SECTION_RE)].map((match) => match[1])
const distinctMatches = new Set(matches)
if (distinctMatches.size > 1) {
throw new Error(
`${FEDERATED_CONTENT_SOURCES_DIR}/${file} declares multiple distinct 'section:' ` +
`values (${[...distinctMatches].join(', ')}); expected exactly one per source file.`
)
}
if (distinctMatches.size === 1) actualSections.add(matches[0])
}
const expectedSections = new Set<string>(FEDERATED_SECTIONS)
const missing = [...actualSections].filter((section) => !expectedSections.has(section))
const stale = [...expectedSections].filter((section) => !actualSections.has(section))
if (missing.length > 0 || stale.length > 0) {
const details = [
missing.length > 0 ? `missing from FEDERATED_SECTIONS: ${missing.join(', ')}` : null,
stale.length > 0 ? `no longer a federated-content source: ${stale.join(', ')}` : null,
]
.filter(Boolean)
.join('; ')
throw new Error(
`FEDERATED_SECTIONS in e2e/docs/utils/resolve-docs-scope.ts is out of sync with ` +
`${FEDERATED_CONTENT_SOURCES_DIR}/*.ts (${details}). Update FEDERATED_SECTIONS to match.`
)
}
}
export type ResolveDocsScopeOptions = {
changedFiles: string[]
repoRoot: string
maxPages?: number
}
export type ResolveDocsScopeResult = {
pages: string[]
skip: boolean
}
function isFederatedGuideSlug(slug: string): boolean {
return FEDERATED_SECTIONS.some((section) => slug === section || slug.startsWith(`${section}/`))
}
function isHiddenMdx(filePath: string): boolean {
return basename(filePath).startsWith('_')
}
export function changedFileToPagePath(filePath: string): string | null {
const normalized = normalizeRepoPath(filePath)
if (normalized.startsWith(GUIDES_PREFIX) && normalized.endsWith('.mdx')) {
if (isHiddenMdx(normalized)) return null
const slug = normalized.slice(GUIDES_PREFIX.length, -'.mdx'.length)
if (!slug || isFederatedGuideSlug(slug)) return null
return `${DOCS_GUIDES_URL_PREFIX}${slug}`
}
if (normalized.startsWith(TROUBLESHOOTING_PREFIX) && normalized.endsWith('.mdx')) {
if (isHiddenMdx(normalized)) return null
const slug = basename(normalized, '.mdx')
return `${DOCS_TROUBLESHOOTING_URL_PREFIX}${slug}`
}
return null
}
function partialRelPathFromChangedFile(filePath: string): string | null {
const normalized = normalizeRepoPath(filePath)
if (!normalized.startsWith(PARTIALS_PREFIX) || !normalized.endsWith('.mdx')) {
return null
}
if (normalized.includes('/_fixtures/')) return null
const rel = normalized.slice(PARTIALS_PREFIX.length)
if (!rel || basename(rel).startsWith('_')) return null
return rel
}
function normalizePartialRef(pathAttr: string): string | null {
if (!pathAttr || pathAttr.startsWith('/') || pathAttr.startsWith('http')) {
return null
}
if (!pathAttr.endsWith('.md') && !pathAttr.endsWith('.mdx')) {
return null
}
return normalizeRepoPath(pathAttr).replace(/^\.\//, '')
}
function extractPartialRefs(source: string): string[] {
const refs: string[] = []
PARTIAL_PATH_RE.lastIndex = 0
let match: RegExpExecArray | null
while ((match = PARTIAL_PATH_RE.exec(source)) !== null) {
const normalized = normalizePartialRef(match[1])
if (normalized) refs.push(normalized)
}
return refs
}
async function walkMdxFiles(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true })
const files: string[] = []
for (const entry of entries) {
const fullPath = join(dir, entry.name)
if (entry.isDirectory()) {
if (entry.name.startsWith('_')) continue
files.push(...(await walkMdxFiles(fullPath)))
continue
}
if (!entry.isFile()) continue
if (!entry.name.endsWith('.mdx')) continue
if (entry.name.startsWith('_')) continue
files.push(fullPath)
}
return files
}
type PartialIndex = {
includedByPartials: Map<string, Set<string>>
pagePartials: Map<string, Set<string>>
}
async function buildPartialIndex(repoRoot: string): Promise<PartialIndex> {
const includedByPartials = new Map<string, Set<string>>()
const pagePartials = new Map<string, Set<string>>()
const partialsDir = join(repoRoot, 'apps/docs/content/_partials')
const guidesDir = join(repoRoot, 'apps/docs/content/guides')
const troubleshootingDir = join(repoRoot, 'apps/docs/content/troubleshooting')
const [partialFiles, guideFiles, troubleshootingFiles] = await Promise.all([
walkMdxFiles(partialsDir).catch(() => [] as string[]),
walkMdxFiles(guidesDir).catch(() => [] as string[]),
walkMdxFiles(troubleshootingDir).catch(() => [] as string[]),
])
for (const file of partialFiles) {
const rel = normalizeRepoPath(relative(partialsDir, file))
const source = await readFile(file, 'utf8')
for (const ref of extractPartialRefs(source)) {
let parents = includedByPartials.get(ref)
if (!parents) {
parents = new Set()
includedByPartials.set(ref, parents)
}
parents.add(rel)
}
}
for (const file of guideFiles) {
const relFromGuides = normalizeRepoPath(relative(guidesDir, file)).replace(/\.mdx$/, '')
if (isFederatedGuideSlug(relFromGuides)) continue
const pagePath = `${DOCS_GUIDES_URL_PREFIX}${relFromGuides}`
const source = await readFile(file, 'utf8')
const refs = extractPartialRefs(source)
if (refs.length > 0) pagePartials.set(pagePath, new Set(refs))
}
for (const file of troubleshootingFiles) {
const slug = basename(file, '.mdx')
const pagePath = `${DOCS_TROUBLESHOOTING_URL_PREFIX}${slug}`
const source = await readFile(file, 'utf8')
const refs = extractPartialRefs(source)
if (refs.length > 0) pagePartials.set(pagePath, new Set(refs))
}
return { includedByPartials, pagePartials }
}
function expandPartialClosure(
seed: string,
includedByPartials: Map<string, Set<string>>
): Set<string> {
const result = new Set<string>([seed])
const queue = [seed]
while (queue.length > 0) {
const current = queue.pop()!
const parents = includedByPartials.get(current)
if (!parents) continue
for (const parent of parents) {
if (result.has(parent)) continue
result.add(parent)
queue.push(parent)
}
}
return result
}
function pagesUsingPartials(
targetPartials: Set<string>,
pagePartials: Map<string, Set<string>>
): string[] {
const pages: string[] = []
for (const [page, refs] of pagePartials) {
for (const ref of refs) {
if (targetPartials.has(ref)) {
pages.push(page)
break
}
}
}
return pages
}
export async function resolveDocsScope(
options: ResolveDocsScopeOptions
): Promise<ResolveDocsScopeResult> {
await assertFederatedSectionsInSync(options.repoRoot)
const maxPages = options.maxPages ?? MAX_SCOPED_PAGES
const pages = new Set<string>()
const changedPartials: string[] = []
for (const file of options.changedFiles) {
const page = changedFileToPagePath(file)
if (page) {
pages.add(page)
continue
}
const partial = partialRelPathFromChangedFile(file)
if (partial) changedPartials.push(partial)
}
if (changedPartials.length > 0) {
const index = await buildPartialIndex(options.repoRoot)
const targets = new Set<string>()
for (const partial of changedPartials) {
for (const expanded of expandPartialClosure(partial, index.includedByPartials)) {
targets.add(expanded)
}
}
for (const page of pagesUsingPartials(targets, index.pagePartials)) {
pages.add(page)
}
}
const sorted = [...pages].sort().slice(0, maxPages)
return {
pages: sorted,
skip: sorted.length === 0,
}
}
export async function resolveAllDocsPages(repoRoot: string): Promise<string[]> {
await assertFederatedSectionsInSync(repoRoot)
const guidesDir = join(repoRoot, 'apps/docs/content/guides')
const troubleshootingDir = join(repoRoot, 'apps/docs/content/troubleshooting')
const [guideFiles, troubleshootingFiles] = await Promise.all([
walkMdxFiles(guidesDir).catch(() => [] as string[]),
walkMdxFiles(troubleshootingDir).catch(() => [] as string[]),
])
const pages = new Set<string>()
for (const file of guideFiles) {
const relFromGuides = normalizeRepoPath(relative(guidesDir, file)).replace(/\.mdx$/, '')
if (isFederatedGuideSlug(relFromGuides)) continue
pages.add(`${DOCS_GUIDES_URL_PREFIX}${relFromGuides}`)
}
for (const file of troubleshootingFiles) {
const slug = basename(file, '.mdx')
pages.add(`${DOCS_TROUBLESHOOTING_URL_PREFIX}${slug}`)
}
return [...pages].sort()
}