mirror of
https://github.com/supabase/supabase.git
synced 2026-09-06 18:11:51 +08:00
Closes DOCS-1203 ## Problem The docs E2E workflow only ever tested one hardcoded page: the Next.js quickstart. All other docs content had no E2E coverage. ## Solution This PR expands the initial scaffolding to generalize the Next.js quickstart tests, page runs and checks local links, to all pages affecting Docs content: - Add `resolveDocsScope` (`e2e/docs/utils/resolve-docs-scope.ts`) to map changed guide and troubleshooting `.mdx` files to their `/docs/...` page paths, and to expand changed `_partials` to every page that includes them (including transitively, through partials nested inside other partials). Federated guide sections (`graphql`, `database/extensions/wrappers`, `ai/python`, `deployment/terraform`, `deployment/ci`) and reference docs stay out of scope, and resolution is capped at 20 pages to keep runtime bounded. - Replace the single `quickstarts.spec.ts` test with a generic `docs-pages.spec.ts` that loads whatever pages are resolved, asserting each renders with an `<h1>` and that its docs-owned links resolve. - Add `run-e2e-docs.ts` so `pnpm e2e:docs` resolves scope locally (from commits since `origin/master`, plus staged/unstaged changes) and skips Playwright entirely when nothing in scope changed. - Update `.github/workflows/docs-e2e.yml` to widen the trigger paths to all guides/troubleshooting/partials, resolve scope in a dedicated step, skip the rest of the job when scope is empty, and accept a `page_paths` input for manual `workflow_dispatch` runs. - Rewrite `e2e/docs/README.md` to document the new scoping behavior, the override envs (`DOCS_E2E_PAGE_PATHS`, `DOCS_E2E_BASE_REF`), and how CI uses the suite. - `pnpm e2e:docs:all` is also added to run tests on every page locally. Good for scoping issues but should not be included in CI. ## Manual testing Walk through the following steps to verify this works: - [x] `pnpm e2e:docs` from repo root resolves the expected pages for a local guide edit and can run against local dev **Note:** Challenges with testing on local in part because of the long lag for first page load. Recommendation to use a hosted URL is added to docs. - [x] Editing a shared `_partials` file resolves to every page that includes it (including through nested partials) - [x] `pnpm e2e:docs` exits cleanly with no Playwright run when no in-scope files changed - [x] `git diff --name-only ... | pnpm -C e2e/docs resolve-docs-scope` prints the expected page list for a sample diff - [x] Workflow run on a PR that only touches `e2e/docs`/workflow files skips the Playwright steps - [x] Manual `workflow_dispatch` run with `page_paths` set tests only those pages - [x] Run `pnpm e2e:docs:all` to run the suite on all docs content, which takes awhile ## Next steps After this PR merges, we have the scaffolding to add more fun tests like a11y 😁 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added scoped Docs E2E runs that target eligible doc pages based on changes, plus manual page-targeted runs and an “all eligible pages” mode. * Introduced `DOCS_E2E_PAGE_PATHS` (and updated base ref/base URL behavior) to control which pages are tested. * **Bug Fixes** * Automatically skips Playwright setup when no relevant pages are in scope; Playwright reporting now uploads only on failure. * **Documentation** * Updated the Docs E2E README with new run/CI behavior, troubleshooting notes, and commands to inspect the resolved page list. * **Tests** * Added a Docs-owned pages E2E suite; removed the Next.js quickstart E2E spec. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
354 lines
12 KiB
TypeScript
354 lines
12 KiB
TypeScript
import { readdir, readFile } from 'node:fs/promises'
|
|
import { basename, join, relative, sep } from 'node:path'
|
|
|
|
/**
|
|
* 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',
|
|
'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
|
|
|
|
/**
|
|
* 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'))
|
|
|
|
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 = {
|
|
/** 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
|
|
}
|
|
|
|
export type ResolveDocsScopeResult = {
|
|
pages: string[]
|
|
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}/`))
|
|
}
|
|
|
|
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)
|
|
|
|
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 {
|
|
// $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
|
|
}
|
|
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 = {
|
|
/** 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>>
|
|
}
|
|
|
|
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 }
|
|
}
|
|
|
|
/**
|
|
* Expand a changed partial to all partials that transitively include it
|
|
* (including itself).
|
|
*/
|
|
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
|
|
}
|
|
|
|
/**
|
|
* 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> {
|
|
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,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
|
|
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()
|
|
}
|
|
|
|
/** 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('/')))
|
|
}
|