From c9ed51c99ea088b9cafbddcf4d0881445ffc7985 Mon Sep 17 00:00:00 2001
From: Danny White <3104761+dnywh@users.noreply.github.com>
Date: Mon, 27 Jul 2026 18:14:01 -0400
Subject: [PATCH 1/4] fix(studio): add return to Vercel escape hatch (#48311)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## 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?
Bug fix / UX improvement for the Vercel Deploy Button create-project
interstitial.
## What is the current behavior?
On the Vercel create-project step, the organization picker is locked
(correct — the integration is bound to that org) and Cancel is hidden.
If the org can't create a free project (member free-project limits),
users hit a dead end: Upgrade may not help, and there's no way out of
the popup.
Also includes a small capitalisation nit on the Vercel install page.
| Before |
| --- |
| |
## What is the new behavior?
- Replaces `hideCancelButton` with `cancelAction: 'studio' | 'vercel' |
'hidden'`
- Vercel create flow shows **Return to Vercel**, which redirects to the
install `next` URL (closing the popup cleanly)
- Free-project-limit admonition adds a Vercel-only hint pointing at that
button: “Or return to Vercel and restart with a different organization.”
- Main `/new` Cancel behaviour is unchanged
- Org picker stays disabled
## Additional context
Org switching mid-create is intentionally not allowed. That would orphan
the Vercel install. Returning to Vercel is the safe escape hatch so
users can restart Deploy Button with another org, or free a project slot
/ upgrade and try again.
## To test
As far as I can tell, this is impossible to test on prod. Shortly after
merge though, you could test the following:
- [ ] Happy path: create still works; Return to Vercel is secondary and
does not block submit
- [ ] Free-limit blocked org: Create disabled, Return to Vercel visible
and redirects to `next`
- [ ] Main `/new`: Cancel still goes to last org / organizations
## Summary by CodeRabbit
- **New Features**
- Enhanced project creation flow for Vercel: when a valid return
destination is available, users can choose **“Return to Vercel”**.
- Added additional messaging in the free-project-limit warning to guide
users back to Vercel and restart with a different organization (when
applicable).
- **Bug Fixes**
- Improved cancel behavior and routing consistency by only enabling
Vercel return when the destination is valid.
- **Style**
- Updated the Vercel integration interstitial title capitalization for
consistency.
---------
Co-authored-by: Joshen Lim
---
.../Vercel/VercelIntegration.utils.test.ts | 32 ++++++++++++++++++
.../Vercel/VercelIntegration.utils.ts | 6 ++++
.../FreeProjectLimitWarning.tsx | 11 ++++++-
.../ProjectCreation/ProjectCreationFooter.tsx | 33 ++++++++++++++-----
.../ProjectCreation/ProjectCreationForm.tsx | 13 +++++---
.../pages/integrations/vercel/install.tsx | 2 +-
6 files changed, 82 insertions(+), 15 deletions(-)
create mode 100644 apps/studio/components/interfaces/Integrations/Vercel/VercelIntegration.utils.test.ts
diff --git a/apps/studio/components/interfaces/Integrations/Vercel/VercelIntegration.utils.test.ts b/apps/studio/components/interfaces/Integrations/Vercel/VercelIntegration.utils.test.ts
new file mode 100644
index 0000000000000..00cd23ecbf713
--- /dev/null
+++ b/apps/studio/components/interfaces/Integrations/Vercel/VercelIntegration.utils.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, test } from 'vitest'
+
+import {
+ getValidVercelReturnUrl,
+ isVercelUrl,
+} from '@/components/interfaces/Integrations/Vercel/VercelIntegration.utils'
+
+describe('isVercelUrl', () => {
+ test('accepts https vercel.com urls', () => {
+ expect(isVercelUrl('https://vercel.com/callback')).toBe(true)
+ })
+
+ test('rejects non-vercel and invalid urls', () => {
+ expect(isVercelUrl('https://example.com')).toBe(false)
+ expect(isVercelUrl('http://vercel.com')).toBe(false)
+ expect(isVercelUrl('not-a-url')).toBe(false)
+ })
+})
+
+describe('getValidVercelReturnUrl', () => {
+ test('returns the url when it is a valid vercel return url', () => {
+ expect(getValidVercelReturnUrl('https://vercel.com/callback')).toBe(
+ 'https://vercel.com/callback'
+ )
+ })
+
+ test('returns undefined for missing or invalid next values', () => {
+ expect(getValidVercelReturnUrl(undefined)).toBeUndefined()
+ expect(getValidVercelReturnUrl('https://example.com')).toBeUndefined()
+ expect(getValidVercelReturnUrl('not-a-url')).toBeUndefined()
+ })
+})
diff --git a/apps/studio/components/interfaces/Integrations/Vercel/VercelIntegration.utils.ts b/apps/studio/components/interfaces/Integrations/Vercel/VercelIntegration.utils.ts
index a6802341ab751..05db949804dd3 100644
--- a/apps/studio/components/interfaces/Integrations/Vercel/VercelIntegration.utils.ts
+++ b/apps/studio/components/interfaces/Integrations/Vercel/VercelIntegration.utils.ts
@@ -10,6 +10,12 @@ export function isVercelUrl(url: string): boolean {
}
}
+/** Returns `next` when it is a safe Vercel return URL; otherwise undefined. */
+export function getValidVercelReturnUrl(next: string | undefined): string | undefined {
+ if (typeof next === 'string' && isVercelUrl(next)) return next
+ return undefined
+}
+
export function findVercelIntegrationByConfigurationId(
integrations: Integration[] | undefined,
configurationId: string | undefined
diff --git a/apps/studio/components/interfaces/ProjectCreation/FreeProjectLimitWarning.tsx b/apps/studio/components/interfaces/ProjectCreation/FreeProjectLimitWarning.tsx
index e3b8389232c73..ba554365f5a95 100644
--- a/apps/studio/components/interfaces/ProjectCreation/FreeProjectLimitWarning.tsx
+++ b/apps/studio/components/interfaces/ProjectCreation/FreeProjectLimitWarning.tsx
@@ -6,9 +6,13 @@ import type { MemberWithFreeProjectLimit } from '@/data/organizations/free-proje
interface FreeProjectLimitWarningProps {
membersExceededLimit: MemberWithFreeProjectLimit[]
+ showVercelReturnHint?: boolean
}
-export const FreeProjectLimitWarning = ({ membersExceededLimit }: FreeProjectLimitWarningProps) => {
+export const FreeProjectLimitWarning = ({
+ membersExceededLimit,
+ showVercelReturnHint = false,
+}: FreeProjectLimitWarningProps) => {
return (
+ {showVercelReturnHint && (
+
+ Or return to Vercel and restart with a different organization.
+
+ )}
canCreateProject: boolean
@@ -29,7 +32,7 @@ interface ProjectCreationFooterProps {
organizationProjects: OrgProject[]
isCreatingNewProject: boolean
isSuccessNewProject: boolean
- hideCancelButton: boolean
+ cancelAction?: ProjectCreationCancelAction
}
export const ProjectCreationFooter = ({
@@ -39,9 +42,10 @@ export const ProjectCreationFooter = ({
organizationProjects,
isCreatingNewProject,
isSuccessNewProject,
- hideCancelButton,
+ cancelAction = 'studio',
}: ProjectCreationFooterProps) => {
const router = useRouter()
+ const { next } = useParams()
const { data: currentOrg } = useSelectedOrganizationQuery()
const isFreePlan = currentOrg?.plan?.id === 'free'
const { lastVisitedOrganization } = useLastVisitedOrganization()
@@ -53,6 +57,9 @@ export const ProjectCreationFooter = ({
? 0
: monthlyInstancePrice(instanceSize) - availableComputeCredits
+ const vercelReturnUrl = getValidVercelReturnUrl(next)
+ const canReturnToVercel = cancelAction === 'vercel' && vercelReturnUrl !== undefined
+
// [kevin] This will eventually all be provided by a new API endpoint to preview and validate project creation, this is just for kaizen now
const monthlyComputeCosts =
// current project costs
@@ -66,6 +73,17 @@ export const ProjectCreationFooter = ({
// compute credits
10
+ const onCancel = () => {
+ if (canReturnToVercel && vercelReturnUrl) {
+ window.location.href = vercelReturnUrl
+ return
+ }
+
+ // Fall back to Studio when cancelAction is studio, or when vercel next is missing/invalid
+ if (!!lastVisitedOrganization) router.push(`/org/${lastVisitedOrganization}`)
+ else router.push('/organizations')
+ }
+
return (
- {!hideCancelButton && (
+ {cancelAction !== 'hidden' && (
)}
}
>
@@ -700,7 +702,10 @@ export const ProjectCreationForm = ({
{freePlanWithExceedingLimits ? (
isAdmin &&
slug && (
-
+
)
) : hasOutstandingInvoices ? (
diff --git a/apps/studio/pages/integrations/vercel/install.tsx b/apps/studio/pages/integrations/vercel/install.tsx
index abc1c7110217c..7eea86d25b10c 100644
--- a/apps/studio/pages/integrations/vercel/install.tsx
+++ b/apps/studio/pages/integrations/vercel/install.tsx
@@ -250,7 +250,7 @@ const VercelIntegration: NextPageWithLayout = () => {
}
- title="Install Vercel Integration"
+ title="Install Vercel integration"
description="Choose the Supabase organization Vercel can connect to"
footer={}
>
From 52cb1c2600a372cbd0b274c6eeec8813bf10b919 Mon Sep 17 00:00:00 2001
From: Miranda Limonczenko
Date: Mon, 27 Jul 2026 17:04:58 -0700
Subject: [PATCH 2/4] feat(docs) Dynamically E2E test all docs-owned content
(#48320)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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 `
` 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 😁
## 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.
---------
Co-authored-by: Claude Sonnet 5
---
.github/workflows/docs-e2e.yml | 67 ++++-
e2e/docs/README.md | 203 ++++++++++----
e2e/docs/features/docs-pages.spec.ts | 67 +++++
e2e/docs/features/quickstarts.spec.ts | 45 ----
e2e/docs/package.json | 11 +-
e2e/docs/scripts/resolve-docs-scope.ts | 82 ++++++
e2e/docs/scripts/run-e2e-docs.ts | 180 +++++++++++++
e2e/docs/tsconfig.json | 5 +-
e2e/docs/utils/docs-links.ts | 55 +++-
e2e/docs/utils/resolve-docs-scope.ts | 353 +++++++++++++++++++++++++
package.json | 10 +-
pnpm-lock.yaml | 4 +
12 files changed, 968 insertions(+), 114 deletions(-)
create mode 100644 e2e/docs/features/docs-pages.spec.ts
delete mode 100644 e2e/docs/features/quickstarts.spec.ts
create mode 100644 e2e/docs/scripts/resolve-docs-scope.ts
create mode 100644 e2e/docs/scripts/run-e2e-docs.ts
create mode 100644 e2e/docs/utils/resolve-docs-scope.ts
diff --git a/.github/workflows/docs-e2e.yml b/.github/workflows/docs-e2e.yml
index 2db833df0705d..7a6b299186afe 100644
--- a/.github/workflows/docs-e2e.yml
+++ b/.github/workflows/docs-e2e.yml
@@ -5,11 +5,12 @@ on:
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
branches: ['master']
paths:
- - 'apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx'
- - 'apps/docs/content/_partials/quickstart_db_setup.mdx'
- - 'apps/docs/content/_partials/api_settings.mdx'
+ - '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'
@@ -22,6 +23,11 @@ on:
required: false
default: 'https://supabase.com'
type: string
+ page_paths:
+ description: 'Comma-separated /docs/... paths to test (required for manual runs)'
+ required: false
+ default: ''
+ type: string
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -46,17 +52,59 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
+ # Need full history on PRs so we can diff against the base branch.
+ # Use string '0' — numeric 0 is falsy in GitHub Actions expressions.
+ fetch-depth: ${{ github.event_name == 'pull_request' && '0' || '1' }}
sparse-checkout: |
e2e/docs
scripts
patches
+ apps/docs/content/guides
+ apps/docs/content/troubleshooting
+ apps/docs/content/_partials
+ apps/docs/scripts/federated-content/sources
+
+ - name: Use Node.js
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
+ with:
+ node-version-file: '.nvmrc'
+
+ # Map changed owned content (guides, troubleshooting, partials) to page
+ # URLs. Harness-only PRs resolve to skip=true and exit before Playwright.
+ - name: Resolve docs E2E scope
+ id: scope
+ 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/docs/scripts/resolve-docs-scope.ts
+
+ - name: Skip Playwright (no in-scope pages)
+ if: steps.scope.outputs.skip == 'true'
+ run: echo "No in-scope docs pages changed; skipping Playwright suite."
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
+ if: steps.scope.outputs.skip != 'true'
name: Install pnpm
with:
run_install: false
- - name: Use Node.js
+ - name: Enable pnpm store cache
+ if: steps.scope.outputs.skip != 'true'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: '.nvmrc'
@@ -65,7 +113,7 @@ jobs:
# Vercel skips the docs preview when a PR only changes the harness
# (e2e/docs, workflow). Wait for a preview only when apps/docs changed.
- name: Detect docs app changes
- if: github.event_name == 'pull_request'
+ if: steps.scope.outputs.skip != 'true' && github.event_name == 'pull_request'
id: filter
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
@@ -80,7 +128,7 @@ jobs:
# those — then resolve the deployment it points to via Vercel's own API
# to get the actual preview URL. See scripts/waitForVercelDocsPreview.js.
- name: Wait for Vercel docs preview
- if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && steps.filter.outputs.docs_app == 'true'
+ if: steps.scope.outputs.skip != 'true' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && steps.filter.outputs.docs_app == 'true'
id: deployment
run: node scripts/waitForVercelDocsPreview.js
env:
@@ -90,6 +138,7 @@ jobs:
VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }}
- name: Resolve base URL
+ if: steps.scope.outputs.skip != 'true'
id: base-url
env:
EVENT_NAME: ${{ github.event_name }}
@@ -110,20 +159,24 @@ jobs:
fi
- name: Install dependencies
+ if: steps.scope.outputs.skip != 'true'
run: pnpm install --frozen-lockfile --filter=e2e-docs...
- name: Install Playwright Chromium
+ if: steps.scope.outputs.skip != 'true'
run: pnpm -C e2e/docs exec playwright install chromium --with-deps --only-shell
- name: Run docs E2E
+ if: steps.scope.outputs.skip != 'true'
working-directory: e2e/docs
run: pnpm run e2e:docs
env:
PLAYWRIGHT_BASE_URL: ${{ steps.base-url.outputs.url }}
+ DOCS_E2E_PAGE_PATHS: ${{ steps.scope.outputs.paths }}
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ steps.base-url.outputs.use_bypass == 'true' && secrets.VERCEL_AUTOMATION_BYPASS_DOCS || '' }}
- name: Upload Playwright report
- if: failure()
+ if: failure() && steps.scope.outputs.skip != 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: docs-playwright-report
diff --git a/e2e/docs/README.md b/e2e/docs/README.md
index deb4ca41c8a5f..357f022892e19 100644
--- a/e2e/docs/README.md
+++ b/e2e/docs/README.md
@@ -1,83 +1,194 @@
-# Supabase Docs E2E Tests
+# Docs E2E tests
-Playwright end-to-end tests for the docs site under `apps/docs`.
-Add new docs journeys under `features/`. They all run together as one suite.
+This guide explains how to run Playwright end-to-end checks against docs pages
+this repo owns.
-## Setup
+Use this suite when you change guides, troubleshooting entries, or shared
+partials under `apps/docs/content`. It loads each in-scope page, checks that the
+article renders, and verifies that docs-owned links in the article resolve.
-Install the Playwright browser once from this directory:
+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 docs
+- [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/docs
+ pnpm exec playwright install chromium
+ ```
+
+## Run the tests
+
+By default, `pnpm e2e:docs` 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 docs site and run it:
+
+ ```bash
+ PLAYWRIGHT_BASE_URL=https://supabase.com pnpm e2e:docs
+ ```
+
+2. Optional: open Playwright UI mode for the same scoped run:
+
+ ```bash
+ PLAYWRIGHT_BASE_URL=https://supabase.com pnpm e2e:docs:ui
+ ```
+
+You can also run from `e2e/docs` with `pnpm run e2e:docs`.
+
+## Choose a target URL
+
+Tests use `PLAYWRIGHT_BASE_URL`. When unset, they default to the local docs
+dev server at `http://localhost:3001`.
+
+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.
+
+### Deployed site
```bash
-cd e2e/docs
-pnpm exec playwright install chromium
+PLAYWRIGHT_BASE_URL=https://supabase.com pnpm e2e:docs
```
-## Choosing a target URL
+For a protected Vercel preview, also set `VERCEL_AUTOMATION_BYPASS_SECRET`.
-Tests run against whatever `PLAYWRIGHT_BASE_URL` points to, defaulting to the
-local docs dev server at `http://localhost:3001`.
+### Local docs server
-- **Local docs server** — in a separate terminal, start docs from the repo root:
+1. From the repository root, start docs in a separate terminal:
- ```bash
- pnpm dev:docs
- ```
+ ```bash
+ pnpm dev:docs
+ ```
-- **A deployed site** — set the base URL inline:
+2. Run the suite without `PLAYWRIGHT_BASE_URL`, or set it to
+ `http://localhost:3001`.
- ```bash
- PLAYWRIGHT_BASE_URL=https://supabase.com pnpm e2e:docs
- ```
+The local server needs a full monorepo install and credentials for some content.
+
+Local runs are unreliable for pages whose docs-owned links point into
+`/docs/reference/*` or `/docs/guides/auth/server-side/*`: reference pages can
+take over a minute to compile on first request in dev mode, which exceeds the
+suite's per-test timeout, and `server-side` auth guides have a known local-only
+routing issue that 404s even though the page serves correctly in production.
+Prefer a deployed site for pages that link into either of those sections.
-If the target is a protected Vercel preview, also set
-`VERCEL_AUTOMATION_BYPASS_SECRET` so the tests can bypass deployment protection.
+## Override which pages run
-The local docs server requires a full monorepo install and credentials for some
-content, so the quickest way to run the suite is against a deployed site. Reach
-for the local server only when you need to test unpublished content changes.
+Leave `DOCS_E2E_PAGE_PATHS` unset to keep the default changed-files scope.
-## Running the tests
+To test specific pages instead of the git diff:
-From the repo root:
+```bash
+DOCS_E2E_PAGE_PATHS=/docs/guides/getting-started/quickstarts/nextjs \
+ PLAYWRIGHT_BASE_URL=https://supabase.com pnpm e2e:docs
+```
+
+To compare against a different base ref:
```bash
-pnpm e2e:docs
+DOCS_E2E_BASE_REF=origin/develop \
+ PLAYWRIGHT_BASE_URL=https://supabase.com pnpm e2e:docs
```
-Or from this directory:
+`DOCS_E2E_PAGE_PATHS` accepts a comma- or newline-separated list of `/docs/...`
+paths.
+
+### Run every in-scope page
+
+To test every guide and troubleshooting entry instead of a changed-files scope
+— for example, a periodic full-site check — run:
```bash
-pnpm run e2e:docs
+PLAYWRIGHT_BASE_URL=https://supabase.com pnpm e2e:docs:all
```
-### UI mode for debugging
+This ignores `DOCS_E2E_PAGE_PATHS` and the 20-page cap described in
+[Limits](#limits), and tests every page listed by
+`pnpm -C e2e/docs resolve-docs-scope` across the whole `guides` and
+`troubleshooting` trees — several hundred pages 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`. Expect a long run: the suite
+runs one worker by default, so pass `--workers` to parallelize it, for
+example:
```bash
-pnpm e2e:docs:ui
+PLAYWRIGHT_BASE_URL=https://supabase.com pnpm e2e:docs:all -- --workers=4
```
-### Run a single file
+Run this against a deployed site, not the local dev server — see
+[Local docs server](#local-docs-server) for why local runs are unreliable for
+pages linking into reference docs or server-side auth guides.
+
+## What the suite covers
+
+### In scope
+
+| Changed path | Behavior |
+| -------------------------------------------- | -------------------------------------------------------- |
+| `apps/docs/content/guides/**/*.mdx` | Test `/docs/guides/`, excluding federated sections |
+| `apps/docs/content/troubleshooting/**/*.mdx` | Test `/docs/guides/troubleshooting/` |
+| `apps/docs/content/_partials/**` | Test owned pages that include that partial |
+
+### Out of scope
+
+- Federated guide sections: `graphql`, `database/extensions/wrappers`,
+ `ai/python`, `deployment/terraform`, `deployment/ci`
+- Reference docs under `/docs/reference`
+- Non-docs routes such as `/dashboard` and `/ui`, which the link checker skips
+
+### Limits
+
+Resolved scope is capped at 20 pages so a widely shared partial cannot explode
+runtime. If a change resolves to more pages than that, only the first 20 in
+sorted order are tested and the rest are silently dropped from that run. To
+test beyond the cap, use `pnpm e2e:docs:all` instead of raising it. See
+[Run every in-scope page](#run-every-in-scope-page).
+
+To inspect the resolved list without running Playwright, replicate the same
+scope `pnpm e2e:docs` uses by default: commits since `origin/master`, plus
+staged and unstaged working-tree changes.
```bash
-pnpm run e2e:docs -- features/.spec.ts
+{
+ git diff --name-only --diff-filter=ACMR origin/master...HEAD
+ git diff --name-only --diff-filter=ACMR
+ git diff --name-only --diff-filter=ACMR --cached
+} | pnpm -C e2e/docs resolve-docs-scope
```
-## Debugging
+## Debug failures
+
+1. Open the HTML report after a run:
+
+ ```bash
+ pnpm -C e2e/docs exec playwright show-report
+ ```
-- View the HTML report after a run:
+2. Inspect traces and screenshots under `test-results/` for failed runs.
- ```bash
- pnpm -C e2e/docs exec playwright show-report
- ```
+## How CI uses this suite
-- Traces and screenshots for failures are saved in `test-results/`.
+The workflow at `.github/workflows/docs-e2e.yml` runs on pull requests that touch
+owned docs content, partials, or `e2e/docs`.
-## CI
+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/docs` changed, wait for the Vercel docs preview and set
+ `PLAYWRIGHT_BASE_URL` to that preview. Otherwise use production.
+4. Run the suite with `DOCS_E2E_PAGE_PATHS` set to the resolved list.
-`.github/workflows/docs-e2e.yml` runs this suite on pull requests that touch the
-tested docs pages or `e2e/docs`. When the PR changes `apps/docs`, the workflow
-waits for the matching Vercel preview and points `PLAYWRIGHT_BASE_URL` at it.
-When only the harness or workflow changes, Vercel skips the docs preview, so
-the suite falls back to production. Draft PRs are skipped until marked ready
-for review. The workflow can also be triggered manually with an optional
-`base_url` input that defaults to production.
+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.
diff --git a/e2e/docs/features/docs-pages.spec.ts b/e2e/docs/features/docs-pages.spec.ts
new file mode 100644
index 0000000000000..773ca40c9800b
--- /dev/null
+++ b/e2e/docs/features/docs-pages.spec.ts
@@ -0,0 +1,67 @@
+import { expect, test } from '@playwright/test'
+
+import {
+ articleSelectorForPagePath,
+ browserLikeUserAgent,
+ collectDocsOwnedLinks,
+ parseDocsE2EPagePaths,
+} from '../utils/docs-links.js'
+
+const pagePaths = parseDocsE2EPagePaths(process.env.DOCS_E2E_PAGE_PATHS)
+
+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.
+ test.describe.configure({ mode: 'parallel' })
+
+ test('resolved page list must not be empty', () => {
+ expect(
+ pagePaths.length,
+ 'No pages to test. `pnpm e2e:docs` resolves pages from git changes by default, ' +
+ 'or set DOCS_E2E_PAGE_PATHS explicitly.'
+ ).toBeGreaterThan(0)
+ })
+
+ for (const pagePath of pagePaths) {
+ test(`${pagePath} loads and docs-owned article links resolve`, async ({ page }, testInfo) => {
+ const baseURL = testInfo.project.use.baseURL
+ expect(baseURL, 'A Playwright base URL should be configured').toBeTruthy()
+
+ const articleSelector = articleSelectorForPagePath(pagePath)
+ const response = await page.goto(pagePath)
+ expect(response, `Expected a response for ${pagePath}`).not.toBeNull()
+ expect(
+ response!.ok(),
+ `Page should return a successful status, got ${response!.status()}`
+ ).toBeTruthy()
+
+ const article = page.locator(articleSelector)
+ await expect(article, 'Page article should be present').toBeVisible()
+ await expect(
+ article.getByRole('heading', { level: 1 }),
+ 'Page article should include an h1'
+ ).toBeVisible()
+
+ const links = await collectDocsOwnedLinks(page, baseURL!, articleSelector)
+ const userAgent = await browserLikeUserAgent(page)
+
+ for (const url of links) {
+ try {
+ const linkResponse = await page.request.get(url, { headers: { 'user-agent': userAgent } })
+ expect
+ .soft(linkResponse.ok(), `${url} should resolve (status ${linkResponse.status()})`)
+ .toBeTruthy()
+ } catch (error) {
+ expect
+ .soft(
+ null,
+ `${url} should be reachable (${error instanceof Error ? error.message : error})`
+ )
+ .toBeTruthy()
+ }
+ }
+ })
+ }
+})
diff --git a/e2e/docs/features/quickstarts.spec.ts b/e2e/docs/features/quickstarts.spec.ts
deleted file mode 100644
index afc09c4637067..0000000000000
--- a/e2e/docs/features/quickstarts.spec.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { expect, test } from '@playwright/test'
-
-import { collectDocsOwnedLinks } from '../utils/docs-links.js'
-
-const QUICKSTART_PATH = '/docs/guides/getting-started/quickstarts/nextjs'
-const ARTICLE_SELECTOR = '#sb-docs-guide-main-article'
-
-test.describe('Next.js quickstart', () => {
- test('loads and docs-owned article links resolve', async ({ page }, testInfo) => {
- const baseURL = testInfo.project.use.baseURL
- expect(baseURL, 'A Playwright base URL should be configured').toBeTruthy()
-
- const response = await page.goto(QUICKSTART_PATH)
- expect(response, `Expected a response for ${QUICKSTART_PATH}`).not.toBeNull()
- expect(
- response!.ok(),
- `Quickstart page should return a successful status, got ${response!.status()}`
- ).toBeTruthy()
-
- const article = page.locator(ARTICLE_SELECTOR)
- await expect(article, 'Guide article should be present').toBeVisible()
- await expect(
- article.getByRole('heading', { level: 1 }),
- 'Guide article should include an h1'
- ).toBeVisible()
-
- const links = await collectDocsOwnedLinks(page, baseURL!)
-
- for (const url of links) {
- try {
- const linkResponse = await page.request.get(url)
- expect
- .soft(linkResponse.ok(), `${url} should resolve (status ${linkResponse.status()})`)
- .toBeTruthy()
- } catch (error) {
- expect
- .soft(
- null,
- `${url} should be reachable (${error instanceof Error ? error.message : error})`
- )
- .toBeTruthy()
- }
- }
- })
-})
diff --git a/e2e/docs/package.json b/e2e/docs/package.json
index aa0812855d256..e54703326c02a 100644
--- a/e2e/docs/package.json
+++ b/e2e/docs/package.json
@@ -4,11 +4,16 @@
"private": true,
"type": "module",
"scripts": {
- "e2e:docs": "playwright test",
- "e2e:ui": "playwright test --ui",
- "e2e:docs:local-smoke": "playwright test --config=playwright.local-smoke.config.ts"
+ "e2e:docs": "node --experimental-strip-types scripts/run-e2e-docs.ts",
+ "e2e:docs:all": "node --experimental-strip-types scripts/run-e2e-docs.ts --all",
+ "e2e:ui": "node --experimental-strip-types scripts/run-e2e-docs.ts --ui",
+ "e2e:docs:local-smoke": "playwright test --config=playwright.local-smoke.config.ts",
+ "resolve-docs-scope": "node --experimental-strip-types scripts/resolve-docs-scope.ts"
},
"dependencies": {
"@playwright/test": "^1.59.1"
+ },
+ "devDependencies": {
+ "@types/node": "catalog:"
}
}
diff --git a/e2e/docs/scripts/resolve-docs-scope.ts b/e2e/docs/scripts/resolve-docs-scope.ts
new file mode 100644
index 0000000000000..c026527ce38d7
--- /dev/null
+++ b/e2e/docs/scripts/resolve-docs-scope.ts
@@ -0,0 +1,82 @@
+#!/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=
+ * 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'
+
+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 {
+ 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) => {
+ console.error(error instanceof Error ? error.message : error)
+ process.exit(1)
+})
diff --git a/e2e/docs/scripts/run-e2e-docs.ts b/e2e/docs/scripts/run-e2e-docs.ts
new file mode 100644
index 0000000000000..f13ddb76494e3
--- /dev/null
+++ b/e2e/docs/scripts/run-e2e-docs.ts
@@ -0,0 +1,180 @@
+#!/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'
+
+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 {
+ 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()
+ 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 {
+ 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 {
+ 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 withoutAll = rawArgs.filter((arg) => arg !== '--all')
+ // pnpm's `--` separator (from `pnpm run ... -- --list`) can land at index 0
+ // or, once `--all` is stripped, wherever `--all` used to precede it.
+ const playwrightArgs = withoutAll[0] === '--' ? withoutAll.slice(1) : withoutAll
+
+ 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) => {
+ console.error(error instanceof Error ? error.message : error)
+ process.exit(1)
+})
diff --git a/e2e/docs/tsconfig.json b/e2e/docs/tsconfig.json
index d59602689dc22..cecfba3008ee1 100644
--- a/e2e/docs/tsconfig.json
+++ b/e2e/docs/tsconfig.json
@@ -4,6 +4,9 @@
"module": "nodenext",
"skipLibCheck": true,
"esModuleInterop": true,
- "strict": true
+ "strict": true,
+ "noEmit": true,
+ "allowImportingTsExtensions": true,
+ "types": ["node"]
}
}
diff --git a/e2e/docs/utils/docs-links.ts b/e2e/docs/utils/docs-links.ts
index 5796b0b84ce4d..3c4a8471c09da 100644
--- a/e2e/docs/utils/docs-links.ts
+++ b/e2e/docs/utils/docs-links.ts
@@ -1,18 +1,41 @@
import type { Page } from '@playwright/test'
-const ARTICLE_SELECTOR = '#sb-docs-guide-main-article'
+export const GUIDE_ARTICLE_SELECTOR = '#sb-docs-guide-main-article'
+export const TROUBLESHOOTING_ARTICLE_SELECTOR = 'article.prose'
const DOCS_PATH_PREFIX = '/docs'
+const TROUBLESHOOTING_PATH_PREFIX = '/docs/guides/troubleshooting/'
/**
- * Collect unique docs-owned links from the main guide article.
+ * Pick the main article selector for a docs page path.
+ * Guides use a stable id; troubleshooting entries use a plain prose article.
+ */
+export function articleSelectorForPagePath(pagePath: string): string {
+ const pathname = pagePath.startsWith('http') ? new URL(pagePath).pathname : pagePath
+
+ if (
+ pathname === TROUBLESHOOTING_PATH_PREFIX.slice(0, -1) ||
+ pathname.startsWith(TROUBLESHOOTING_PATH_PREFIX)
+ ) {
+ return TROUBLESHOOTING_ARTICLE_SELECTOR
+ }
+
+ 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): Promise {
+export async function collectDocsOwnedLinks(
+ page: Page,
+ baseURL: string,
+ articleSelector: string = GUIDE_ARTICLE_SELECTOR
+): Promise {
const origin = new URL(baseURL).origin
const hrefs = await page
- .locator(`${ARTICLE_SELECTOR} a[href]`)
+ .locator(`${articleSelector} a[href]`)
.evaluateAll((anchors) =>
anchors.map((anchor) => (anchor as HTMLAnchorElement).getAttribute('href') ?? '')
)
@@ -40,3 +63,27 @@ export async function collectDocsOwnedLinks(page: Page, baseURL: string): Promis
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.
+ */
+export async function browserLikeUserAgent(page: Page): Promise {
+ 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}`))
+}
diff --git a/e2e/docs/utils/resolve-docs-scope.ts b/e2e/docs/utils/resolve-docs-scope.ts
new file mode 100644
index 0000000000000..acaa2db73c27c
--- /dev/null
+++ b/e2e/docs/utils/resolve-docs-scope.ts
@@ -0,0 +1,353 @@
+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 {
+ const sourcesDir = join(repoRoot, FEDERATED_CONTENT_SOURCES_DIR)
+ const sourceFiles = (await readdir(sourcesDir)).filter((file) => file.endsWith('.ts'))
+
+ const actualSections = new Set()
+ 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(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 {
+ 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>
+ /** page URL → set of direct partial rel paths */
+ pagePartials: Map>
+}
+
+async function buildPartialIndex(repoRoot: string): Promise {
+ const includedByPartials = new Map>()
+ const pagePartials = new Map>()
+
+ 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>
+): Set {
+ const result = new Set([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,
+ pagePartials: Map>
+): 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 {
+ await assertFederatedSectionsInSync(options.repoRoot)
+
+ const maxPages = options.maxPages ?? MAX_SCOPED_PAGES
+ const pages = new Set()
+ 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()
+ 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 {
+ 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()
+
+ 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('/')))
+}
diff --git a/package.json b/package.json
index 0caaeb3505259..a495efbcbb61c 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,7 @@
"e2e": "pnpm --prefix e2e/studio run e2e",
"e2e:ui": "pnpm --prefix e2e/studio run e2e:ui",
"e2e:docs": "pnpm --prefix e2e/docs run e2e:docs",
+ "e2e:docs:all": "pnpm --prefix e2e/docs run e2e:docs:all",
"e2e:docs:ui": "pnpm --prefix e2e/docs run e2e:ui",
"e2e:docs:local-smoke": "pnpm --prefix e2e/docs run e2e:docs:local-smoke",
"perf:kong": "ab -t 5 -c 20 -T application/json http://localhost:8000/",
@@ -73,13 +74,6 @@
"pnpm": "11.13",
"node": ">=22.13"
},
- "keywords": [
- "postgres",
- "firebase",
- "storage",
- "functions",
- "database",
- "auth"
- ],
+ "keywords": ["postgres", "firebase", "storage", "functions", "database", "auth"],
"packageManager": "pnpm@11.13.1"
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9e144852e33d8..b64b52b7c226d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2020,6 +2020,10 @@ importers:
'@playwright/test':
specifier: ^1.59.1
version: 1.59.1
+ devDependencies:
+ '@types/node':
+ specifier: 'catalog:'
+ version: 22.13.14
e2e/studio:
dependencies:
From 0f314982dabb0df16e5549a076adfe00960b5690 Mon Sep 17 00:00:00 2001
From: Tarun Khandelwal
Date: Tue, 28 Jul 2026 08:22:13 +0530
Subject: [PATCH 3/4] Add Tarun Khandelwal to humans.txt (#48356)
Adding myself to the humans.txt as part of the onboarding
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.
YES/NO
## What kind of change does this PR introduce?
humans.txt update for `Tarun Khandelwal`
## Summary by CodeRabbit
* **Documentation**
* Added Tarun Khandelwal to the team information listed in the
documentation.
---
apps/docs/public/humans.txt | 1 +
1 file changed, 1 insertion(+)
diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt
index 43e4768008df4..47fd7062104b8 100644
--- a/apps/docs/public/humans.txt
+++ b/apps/docs/public/humans.txt
@@ -282,6 +282,7 @@ Sugu Sougoumarane
Supun Sudaraka Kalidasa
Taha Le Bras
Tanun Chalermsinsuwan
+Tarun Khandelwal
Taryn King
Terry Sutton
Terry Wilcox
From 8aeae070c2f865d17fd03964b016a1c2455aa76c Mon Sep 17 00:00:00 2001
From: Joshen Lim
Date: Tue, 28 Jul 2026 11:19:54 +0800
Subject: [PATCH 4/4] Fix import statements casing for Admonition (#48374)
## Context
Was running into typecheck errors when running the ts check locally -
this should resolve it
## Summary by CodeRabbit
* **Bug Fixes**
* Corrected component references across confirmation dialogs, error
displays, privacy settings, and SQL-to-REST views.
* Improved compatibility for environments with case-sensitive file
handling.
---
packages/ui-patterns/src/Dialogs/ConfirmationModal.tsx | 2 +-
packages/ui-patterns/src/Dialogs/TextConfirmModal.tsx | 2 +-
packages/ui-patterns/src/ErrorDisplay/ErrorDisplay.tsx | 2 +-
packages/ui-patterns/src/PrivacySettings/index.tsx | 2 +-
packages/ui-patterns/src/SqlToRest/index.tsx | 2 +-
5 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/packages/ui-patterns/src/Dialogs/ConfirmationModal.tsx b/packages/ui-patterns/src/Dialogs/ConfirmationModal.tsx
index 5b70b94d8c040..dda2a23243e69 100644
--- a/packages/ui-patterns/src/Dialogs/ConfirmationModal.tsx
+++ b/packages/ui-patterns/src/Dialogs/ConfirmationModal.tsx
@@ -16,7 +16,7 @@ import {
} from 'ui'
import { DialogDescription, DialogHeader } from 'ui/src/components/shadcn/ui/dialog'
-import { Admonition } from '../Admonition'
+import { Admonition } from '../admonition'
export interface ConfirmationModalProps {
loading?: boolean
diff --git a/packages/ui-patterns/src/Dialogs/TextConfirmModal.tsx b/packages/ui-patterns/src/Dialogs/TextConfirmModal.tsx
index c3e156a071c6f..ebfaa494e33bf 100644
--- a/packages/ui-patterns/src/Dialogs/TextConfirmModal.tsx
+++ b/packages/ui-patterns/src/Dialogs/TextConfirmModal.tsx
@@ -29,7 +29,7 @@ import {
import { DialogHeader } from 'ui/src/components/shadcn/ui/dialog'
import { z } from 'zod'
-import { Admonition } from '../Admonition'
+import { Admonition } from '../admonition'
export interface TextConfirmModalProps {
loading: boolean
diff --git a/packages/ui-patterns/src/ErrorDisplay/ErrorDisplay.tsx b/packages/ui-patterns/src/ErrorDisplay/ErrorDisplay.tsx
index 5654c5827cd09..e495a27dba181 100644
--- a/packages/ui-patterns/src/ErrorDisplay/ErrorDisplay.tsx
+++ b/packages/ui-patterns/src/ErrorDisplay/ErrorDisplay.tsx
@@ -4,7 +4,7 @@ import { HelpCircle } from 'lucide-react'
import { forwardRef, useEffect, useRef } from 'react'
import { Card, CardHeader, cn } from 'ui'
-import { WarningIcon } from '../Admonition'
+import { WarningIcon } from '../admonition'
import type { ErrorDisplayProps, SupportFormParams } from './ErrorDisplay.types'
export type { SupportFormParams } from './ErrorDisplay.types'
diff --git a/packages/ui-patterns/src/PrivacySettings/index.tsx b/packages/ui-patterns/src/PrivacySettings/index.tsx
index b13fdd0c5c37d..09ae65a0250c2 100644
--- a/packages/ui-patterns/src/PrivacySettings/index.tsx
+++ b/packages/ui-patterns/src/PrivacySettings/index.tsx
@@ -17,7 +17,7 @@ import {
Switch,
} from 'ui'
-import { Admonition } from '../Admonition'
+import { Admonition } from '../admonition'
interface PrivacySettingsProps {
className?: string
diff --git a/packages/ui-patterns/src/SqlToRest/index.tsx b/packages/ui-patterns/src/SqlToRest/index.tsx
index 97bba48883181..b9bafca284ab2 100644
--- a/packages/ui-patterns/src/SqlToRest/index.tsx
+++ b/packages/ui-patterns/src/SqlToRest/index.tsx
@@ -3,7 +3,7 @@
import dynamic from 'next/dynamic.js'
import { ErrorBoundary, FallbackProps } from 'react-error-boundary'
-import { Admonition } from '../Admonition'
+import { Admonition } from '../admonition'
import { SqlToRestProps } from './sql-to-rest'
function FallbackComponent({ error }: FallbackProps) {