From 52cb1c2600a372cbd0b274c6eeec8813bf10b919 Mon Sep 17 00:00:00 2001 From: Miranda Limonczenko Date: Mon, 27 Jul 2026 17:04:58 -0700 Subject: [PATCH] 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 | 213 +++++++++++---- 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, 973 insertions(+), 119 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 2db833df070..7a6b299186a 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 deb4ca41c8a..357f022892e 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 +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. + +## Override which pages run + +Leave `DOCS_E2E_PAGE_PATHS` unset to keep the default changed-files scope. + +To test specific pages instead of the git diff: + +```bash +DOCS_E2E_PAGE_PATHS=/docs/guides/getting-started/quickstarts/nextjs \ PLAYWRIGHT_BASE_URL=https://supabase.com pnpm e2e:docs - ``` - -If the target is a protected Vercel preview, also set -`VERCEL_AUTOMATION_BYPASS_SECRET` so the tests can bypass deployment protection. - -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. - -## Running the tests - -From the repo root: - -```bash -pnpm e2e:docs ``` -Or from this directory: +To compare against a different base ref: ```bash -pnpm run e2e:docs +DOCS_E2E_BASE_REF=origin/develop \ + PLAYWRIGHT_BASE_URL=https://supabase.com pnpm e2e:docs ``` -### UI mode for debugging +`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 e2e:docs:ui +PLAYWRIGHT_BASE_URL=https://supabase.com pnpm e2e:docs:all ``` -### Run a single file +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 run e2e:docs -- features/.spec.ts +PLAYWRIGHT_BASE_URL=https://supabase.com pnpm e2e:docs:all -- --workers=4 ``` -## Debugging +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. -- View the HTML report after a run: +## What the suite covers - ```bash - pnpm -C e2e/docs exec playwright show-report - ``` +### In scope -- Traces and screenshots for failures are saved in `test-results/`. +| 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 | -## CI +### Out of scope -`.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. +- 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 +{ + 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 +``` + +## Debug failures + +1. Open the HTML report after a run: + + ```bash + pnpm -C e2e/docs 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/docs-e2e.yml` runs on pull requests that touch +owned docs content, partials, or `e2e/docs`. + +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. + +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 00000000000..773ca40c980 --- /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 afc09c46370..00000000000 --- 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 aa0812855d2..e54703326c0 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 00000000000..c026527ce38 --- /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 00000000000..f13ddb76494 --- /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 d59602689dc..cecfba3008e 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 5796b0b84ce..3c4a8471c09 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 00000000000..acaa2db73c2 --- /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 0caaeb35052..a495efbcbb6 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 9e144852e33..b64b52b7c22 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: