diff --git a/.claude/skills/author-e2e-tests/SKILL.md b/.claude/skills/author-e2e-tests/SKILL.md index 6937b000299e..075b8f23c92e 100644 --- a/.claude/skills/author-e2e-tests/SKILL.md +++ b/.claude/skills/author-e2e-tests/SKILL.md @@ -1,6 +1,6 @@ --- name: author-e2e-tests -description: Use when writing, debugging, or maintaining Playwright e2e tests for Positron -- new test files, test cases, flaky-test fixes, test infrastructure, or performance/metric tests. +description: Use when writing, debugging, or maintaining Playwright e2e tests for Positron -- new test files, test cases, flaky-test fixes, test infrastructure, or performance/metric tests. EDIT --- # Positron Playwright E2E Testing diff --git a/.github/workflows/test-e2e-ubuntu.yml b/.github/workflows/test-e2e-ubuntu.yml index 8f6e75f614c0..2425cc1f647c 100644 --- a/.github/workflows/test-e2e-ubuntu.yml +++ b/.github/workflows/test-e2e-ubuntu.yml @@ -53,6 +53,11 @@ on: description: "Whether to allow tests marked with :soft-fail to fail without failing the job." type: boolean default: false + docs_only: + required: false + description: "When true, skip all build/test steps and report success (docs-only change)." + type: boolean + default: false workflow_dispatch: inputs: @@ -134,12 +139,18 @@ jobs: DATABRICKS_WORKSPACE: ${{ secrets.DATABRICKS_WORKSPACE }} DATABRICKS_PAT: ${{ secrets.DATABRICKS_PAT }} steps: + - name: Docs-only change -- skipping e2e tests + if: ${{ inputs.docs_only }} + run: echo "Docs-only change detected; skipping e2e test suite." + - uses: actions/checkout@v7 + if: ${{ !inputs.docs_only }} with: fetch-depth: 0 submodules: recursive - name: Load secret + if: ${{ !inputs.docs_only }} uses: 1password/load-secrets-action@v4 with: # Export loaded secrets as environment variables @@ -155,17 +166,19 @@ jobs: POSIT_PASSWORD: "op://Positron/Posit-AI-Login/password" - name: Transform to Playwright tags $PW_TAGS + if: ${{ !inputs.docs_only }} run: bash scripts/pr-tags-transform.sh ${{ inputs.project}} "${{ inputs.grep }}" shell: bash - name: 📦 Restore caches id: restore-caches + if: ${{ !inputs.docs_only }} uses: ./.github/actions/restore-build-caches - name: Install node dependencies - if: steps.restore-caches.outputs.cache-npm-core-hit != 'true' || + if: ${{ !inputs.docs_only && (steps.restore-caches.outputs.cache-npm-core-hit != 'true' || steps.restore-caches.outputs.cache-npm-extensions-volatile-hit != 'true' || - steps.restore-caches.outputs.cache-npm-extensions-stable-hit != 'true' + steps.restore-caches.outputs.cache-npm-extensions-stable-hit != 'true') }} uses: nick-fields/retry@v4 env: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 @@ -191,29 +204,35 @@ jobs: command: bash scripts/install-npm-parallel.sh - name: Download binaries (Ark, Kallichore) + if: ${{ !inputs.docs_only }} uses: ./.github/actions/download-binaries with: cache-hit: ${{ steps.restore-caches.outputs.cache-npm-extensions-volatile-hit == 'true' || steps.restore-caches.outputs.cache-npm-extensions-stable-hit == 'true' }} - name: Install E2E test dependencies + if: ${{ !inputs.docs_only }} run: npm --prefix test/e2e ci --prefer-offline --no-audit --no-fund - name: Compile Positron and Download Electron + if: ${{ !inputs.docs_only }} run: npm exec -- npm-run-all --max-old-space-size=8192 -p compile "electron x64" - name: Install Playwright browsers + if: ${{ !inputs.docs_only }} env: PLAYWRIGHT_BROWSERS_PATH: .playwright-browsers run: npx playwright install # Downloads Builtin Extensions (needed for integration & e2e testing) - name: Prelaunch + if: ${{ !inputs.docs_only }} run: npm run prelaunch # Must run AFTER Prelaunch since preLaunch.ts runs `npm run electron` # which can re-extract the Electron binary and reset permissions - name: Set permissions on SUID sandbox helper + if: ${{ !inputs.docs_only }} run: | ELECTRON_ROOT=.build/electron sudo chown root $ELECTRON_ROOT/chrome-sandbox @@ -221,12 +240,13 @@ jobs: stat $ELECTRON_ROOT/chrome-sandbox - name: Move Positron License - if: ${{ inputs.install_license }} + if: ${{ !inputs.docs_only && inputs.install_license }} run: | mv /positron-license /__w/positron && printf "%s" "${{ secrets.POSITRON_DEV_LICENSE }}" > /__w/positron/positron-license/pdol/target/debug/pdol_rsa - name: Setup E2E Test Environment + if: ${{ !inputs.docs_only }} uses: ./.github/actions/setup-test-env with: aws-role-to-assume: ${{ secrets.QA_AWS_RO_ROLE }} @@ -235,18 +255,21 @@ jobs: # Preloading ensures the Node.js binary is fully built and ready before # any parallel processes start, preventing runtime conflicts - name: Preload Node.js Binary - if: ${{ inputs.project == 'e2e-chromium' }} + if: ${{ !inputs.docs_only && inputs.project == 'e2e-chromium' }} run: npm run gulp node - name: Send Results to GH Summary + if: ${{ !inputs.docs_only }} uses: ./.github/actions/gen-report-dir with: identifier: ${{ inputs.project }} - name: Alter AppArmor Restrictions for Playwright + if: ${{ !inputs.docs_only }} run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - name: 🧪 Run Playwright Tests + if: ${{ !inputs.docs_only }} shell: bash env: PLAYWRIGHT_BROWSERS_PATH: .playwright-browsers @@ -296,7 +319,7 @@ jobs: fi - name: Upload Playwright Report to S3 - if: ${{ success() || failure() }} + if: ${{ !inputs.docs_only && (success() || failure()) }} continue-on-error: true uses: ./.github/actions/upload-report-to-s3 with: @@ -304,7 +327,7 @@ jobs: report-dir: ${{ env.REPORT_DIR }} - name: Upload Test Logs - if: ${{ always() && inputs.upload_logs }} + if: ${{ !inputs.docs_only && always() && inputs.upload_logs }} uses: actions/upload-artifact@v7 with: name: ${{ inputs.project }}-ubuntu-logs @@ -312,7 +335,7 @@ jobs: if-no-files-found: ignore - name: Upload Test Results File - if: ${{ always() }} + if: ${{ !inputs.docs_only && always() }} uses: actions/upload-artifact@v7 with: name: ${{ inputs.project }}-ubuntu-json-results @@ -320,7 +343,7 @@ jobs: if-no-files-found: ignore - name: Upload inspect-ai JSON Responses - if: ${{ always() && inputs.project == 'inspect-ai' }} + if: ${{ !inputs.docs_only && always() && inputs.project == 'inspect-ai' }} uses: actions/upload-artifact@v7 with: name: inspect-ai-responses diff --git a/.github/workflows/test-ext-host.yml b/.github/workflows/test-ext-host.yml index dd1cac730cc5..391317b78abc 100644 --- a/.github/workflows/test-ext-host.yml +++ b/.github/workflows/test-ext-host.yml @@ -11,6 +11,11 @@ on: pull_request: required: false type: boolean + docs_only: + required: false + description: "When true, skip all build/test steps and report success (docs-only change)." + type: boolean + default: false workflow_dispatch: inputs: pull_request: @@ -54,24 +59,30 @@ jobs: R_LIBS_USER: /usr/local/lib/R/site-library RETICULATE_PYTHON: /root/.venv/bin/python steps: + - name: Docs-only change -- skipping ext-host tests + if: ${{ inputs.docs_only }} + run: echo "Docs-only change detected; skipping extension-host test suite." + - uses: actions/checkout@v7 + if: ${{ !inputs.docs_only }} with: submodules: recursive - name: Checkout specific commit - if: ${{ inputs.commit != '' }} + if: ${{ !inputs.docs_only && inputs.commit != '' }} run: | git checkout ${{ inputs.commit }} git submodule update --init --recursive - name: 📦 Restore caches id: restore-caches + if: ${{ !inputs.docs_only }} uses: ./.github/actions/restore-build-caches - name: Install node dependencies - if: steps.restore-caches.outputs.cache-npm-core-hit != 'true' || + if: ${{ !inputs.docs_only && (steps.restore-caches.outputs.cache-npm-core-hit != 'true' || steps.restore-caches.outputs.cache-npm-extensions-volatile-hit != 'true' || - steps.restore-caches.outputs.cache-npm-extensions-stable-hit != 'true' + steps.restore-caches.outputs.cache-npm-extensions-stable-hit != 'true') }} uses: nick-fields/retry@v4 env: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 @@ -104,31 +115,37 @@ jobs: bash scripts/install-npm-parallel.sh - name: Download binaries (Ark, Kallichore) + if: ${{ !inputs.docs_only }} uses: ./.github/actions/download-binaries with: cache-hit: ${{ steps.restore-caches.outputs.cache-npm-extensions-volatile-hit == 'true' || steps.restore-caches.outputs.cache-npm-extensions-stable-hit == 'true' }} - name: Compile Positron and Download Electron + if: ${{ !inputs.docs_only }} run: npm exec -- npm-run-all --max-old-space-size=8192 -p compile "electron x64" # System deps are baked into the container image, so install browsers only. - name: Install Playwright browsers + if: ${{ !inputs.docs_only }} env: PLAYWRIGHT_BROWSERS_PATH: .playwright-browsers run: npx playwright install - name: Setup Xvfb + if: ${{ !inputs.docs_only }} uses: ./.github/actions/setup-xvfb # Downloads Builtin Extensions (needed for integration & e2e testing) - name: Prelaunch + if: ${{ !inputs.docs_only }} run: npm run prelaunch # Must run AFTER Prelaunch since preLaunch.ts runs `npm run electron` # which can re-extract the Electron binary and reset permissions - name: Set permissions on SUID sandbox helper + if: ${{ !inputs.docs_only }} run: | ELECTRON_ROOT=.build/electron sudo chown root $ELECTRON_ROOT/chrome-sandbox @@ -136,6 +153,7 @@ jobs: stat $ELECTRON_ROOT/chrome-sandbox - name: Install Positron License + if: ${{ !inputs.docs_only }} uses: ./.github/actions/install-license with: github-token: ${{ secrets.POSITRON_GITHUB_RO_PAT }} @@ -145,13 +163,16 @@ jobs: # integration test ("Connections pane works for R") reads a DESCRIPTION # file from the repo root, so stage that from test-files. - name: Stage R DESCRIPTION file + if: ${{ !inputs.docs_only }} run: cp test/e2e/test-files/DESCRIPTION DESCRIPTION - name: Compile Integration Tests + if: ${{ !inputs.docs_only }} run: npm run --prefix test/integration/browser compile - name: 🧪 Run Extension Host Tests (Electron) id: electron-integration-tests + if: ${{ !inputs.docs_only }} env: PLAYWRIGHT_BROWSERS_PATH: .playwright-browsers run: | @@ -162,14 +183,14 @@ jobs: fi - name: 🧪 Run Extension Host Tests (Remote) - if: ${{ job.status != 'cancelled' && (success() || failure()) }} + if: ${{ !inputs.docs_only && job.status != 'cancelled' && (success() || failure()) }} id: electron-remote-integration-tests env: PLAYWRIGHT_BROWSERS_PATH: .playwright-browsers run: DISPLAY=:10 ./scripts/test-remote-integration.sh - name: 🧪 Run Extension Host Tests (Chromium) - if: ${{ job.status != 'cancelled' && (success() || failure()) }} + if: ${{ !inputs.docs_only && job.status != 'cancelled' && (success() || failure()) }} id: browser-integration-tests env: PLAYWRIGHT_BROWSERS_PATH: .playwright-browsers diff --git a/.github/workflows/test-merge.yml b/.github/workflows/test-merge.yml index d8f4c0b5c4ae..f0b2e1cba1c1 100644 --- a/.github/workflows/test-merge.yml +++ b/.github/workflows/test-merge.yml @@ -8,14 +8,46 @@ on: - "release/**" jobs: + docs-only: + name: docs-only check + runs-on: ubuntu-latest + outputs: + docs_only: ${{ steps.check.outputs.docs_only }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Compute docs-only flag + id: check + env: + BEFORE: ${{ github.event.before }} + AFTER: ${{ github.event.after }} + run: | + # Skip heavy suites when a push changed only docs. Fails safe: a + # branch-creation / force-push where BEFORE is all-zeros (no valid + # diff base), or any git error, yields docs_only=false -> full CI. + ZERO="0000000000000000000000000000000000000000" + if [ -z "$BEFORE" ] || [ "$BEFORE" = "$ZERO" ]; then + echo "No valid diff base; running full CI." + echo "docs_only=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + CHANGED_FILES=$(git diff --name-only "$BEFORE" "$AFTER" || true) + DOCS_ONLY=$(printf '%s' "$CHANGED_FILES" | bash scripts/is-docs-only-change.sh) + echo "docs_only=$DOCS_ONLY" + echo "docs_only=$DOCS_ONLY" >> "$GITHUB_OUTPUT" + setup: name: setup + needs: [docs-only] + if: ${{ needs.docs-only.outputs.docs_only != 'true' }} uses: ./.github/workflows/test-e2e-ubuntu-build.yml secrets: inherit e2e-electron: name: e2e - needs: [setup] + needs: [setup, docs-only] + if: ${{ needs.docs-only.outputs.docs_only != 'true' }} uses: ./.github/workflows/test-e2e-ubuntu-run.yml with: grep: "" @@ -33,6 +65,8 @@ jobs: e2e-windows: name: e2e + needs: [docs-only] + if: ${{ needs.docs-only.outputs.docs_only != 'true' }} uses: ./.github/workflows/test-e2e-windows-run.yml secrets: inherit with: @@ -48,7 +82,8 @@ jobs: e2e-ubuntu-chromium: name: e2e - needs: [setup] + needs: [setup, docs-only] + if: ${{ needs.docs-only.outputs.docs_only != 'true' }} uses: ./.github/workflows/test-e2e-ubuntu-run.yml with: grep: "" @@ -66,6 +101,8 @@ jobs: unit-tests: name: test + needs: [docs-only] + if: ${{ needs.docs-only.outputs.docs_only != 'true' }} uses: ./.github/workflows/test-unit.yml secrets: inherit with: @@ -73,15 +110,18 @@ jobs: ext-host-tests: name: test + needs: [docs-only] + if: ${{ needs.docs-only.outputs.docs_only != 'true' }} uses: ./.github/workflows/test-ext-host.yml secrets: inherit with: pull_request: false slack-notify: - if: always() + if: ${{ always() && needs.docs-only.outputs.docs_only != 'true' }} needs: [ + docs-only, setup, unit-tests, ext-host-tests, diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index 6cdcae951512..1b1edd30a0e2 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -33,6 +33,7 @@ jobs: jupyter_tag_found: ${{ steps.pr-tags.outputs.jupyter_tag_found }} remote_ssh_tag_found: ${{ steps.pr-tags.outputs.remote_ssh_tag_found }} connect_tag_found: ${{ steps.pr-tags.outputs.connect_tag_found }} + docs_only: ${{ steps.docs-only.outputs.docs_only }} steps: - uses: actions/checkout@v7 @@ -49,6 +50,8 @@ jobs: # Guardrail for the tag-derivation scripts; fail fast before relying on them below. - name: Unit test the tag-derivation helpers run: bash scripts/test/pr-tags-lib-test.sh + - name: Unit test the docs-only detection helper + run: bash scripts/test/is-docs-only-change-test.sh - name: Validate test-tag-paths-map.json tags against test-tags.ts run: bash scripts/check-test-tag-map.sh --tags-only - name: Parse Tags from PR Body @@ -60,6 +63,38 @@ jobs: GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} # The advisory comment (no-match / unmapped-dir warnings) is posted from # the pr-e2e-comment workflow, folded into the single "E2E Tests" comment. + - name: Compute docs-only flag + id: docs-only + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + # Docs-only detection: skip heavy suites when every changed file is a + # doc. Fails safe -- any error or non-doc file yields docs_only=false. + # Fail safe: if the changed-files fetch errors (incl. mid-pagination), + # force full CI rather than trusting a partial file list. + if ! CHANGED_FILES=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" --paginate --jq '.[].filename'); then + echo "Could not fetch changed files; forcing full CI." + DOCS_ONLY=false + else + DOCS_ONLY=$(printf '%s' "$CHANGED_FILES" | bash scripts/is-docs-only-change.sh) + fi + + # One-way override: @:full-ci in the PR body forces full CI. Matches the + # existing loose @: tag convention in pr-tags-parse.sh (substring match, + # can trigger from backtick-quoted prose -- acceptable, only forces MORE + # testing). Fail safe: if the body fetch errors, force full CI too. + if ! PR_BODY=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.body'); then + echo "Could not fetch PR body; forcing full CI." + DOCS_ONLY=false + elif printf '%s' "$PR_BODY" | grep -q "@:full-ci"; then + echo "Found @:full-ci in PR body; forcing full CI." + DOCS_ONLY=false + fi + + echo "docs_only=$DOCS_ONLY" + echo "docs_only=$DOCS_ONLY" >> "$GITHUB_OUTPUT" e2e-ubuntu-electron: name: e2e @@ -73,6 +108,7 @@ jobs: upload_logs: false workers: 2 allow_soft_fail: false + docs_only: ${{ needs.pr-tags.outputs.docs_only == 'true' }} secrets: inherit e2e-windows-electron: @@ -319,14 +355,18 @@ jobs: unit-tests: name: test + needs: pr-tags uses: ./.github/workflows/test-unit.yml secrets: inherit with: save_cache: false # PRs restore from main only; test-merge.yml saves + docs_only: ${{ needs.pr-tags.outputs.docs_only == 'true' }} ext-host-tests: name: test + needs: pr-tags uses: ./.github/workflows/test-ext-host.yml secrets: inherit with: pull_request: true + docs_only: ${{ needs.pr-tags.outputs.docs_only == 'true' }} diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index 8137a4926f8a..e5ead47a25e6 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -24,6 +24,11 @@ on: description: "Whether to save caches after the run (only one job per workflow should save to avoid race conditions)" type: boolean default: false + docs_only: + required: false + description: "When true, skip all build/test steps and report success (docs-only change)." + type: boolean + default: false workflow_dispatch: permissions: @@ -62,26 +67,32 @@ jobs: R_LIBS_USER: /usr/local/lib/R/site-library RETICULATE_PYTHON: /root/.venv/bin/python steps: + - name: Docs-only change -- skipping unit tests + if: ${{ inputs.docs_only }} + run: echo "Docs-only change detected; skipping unit test suite." + - uses: actions/checkout@v7 + if: ${{ !inputs.docs_only }} with: submodules: recursive - name: Checkout specific commit - if: ${{ inputs.commit != '' }} + if: ${{ !inputs.docs_only && inputs.commit != '' }} run: | git checkout ${{ inputs.commit }} git submodule update --init --recursive - name: 📦 Restore caches id: restore-caches + if: ${{ !inputs.docs_only }} uses: ./.github/actions/restore-build-caches with: emit-summary: 'true' - name: Install node dependencies - if: steps.restore-caches.outputs.cache-npm-core-hit != 'true' || + if: ${{ !inputs.docs_only && (steps.restore-caches.outputs.cache-npm-core-hit != 'true' || steps.restore-caches.outputs.cache-npm-extensions-volatile-hit != 'true' || - steps.restore-caches.outputs.cache-npm-extensions-stable-hit != 'true' + steps.restore-caches.outputs.cache-npm-extensions-stable-hit != 'true') }} uses: nick-fields/retry@v4 env: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 @@ -117,37 +128,42 @@ jobs: bash scripts/install-npm-parallel.sh - name: Download binaries (Ark, Kallichore) + if: ${{ !inputs.docs_only }} uses: ./.github/actions/download-binaries with: cache-hit: ${{ steps.restore-caches.outputs.cache-npm-extensions-volatile-hit == 'true' || steps.restore-caches.outputs.cache-npm-extensions-stable-hit == 'true' }} - name: 🔍 Check for uncached postinstall artifacts - if: steps.restore-caches.outputs.cache-npm-core-hit != 'true' || + if: ${{ !inputs.docs_only && (steps.restore-caches.outputs.cache-npm-core-hit != 'true' || steps.restore-caches.outputs.cache-npm-extensions-volatile-hit != 'true' || - steps.restore-caches.outputs.cache-npm-extensions-stable-hit != 'true' + steps.restore-caches.outputs.cache-npm-extensions-stable-hit != 'true') }} run: | find . -type f -o -type l | grep -v "node_modules/" | sort > /tmp/files-after-npm-install.txt .github/cache-scripts/check-uncached-artifacts.sh /tmp/files-before-npm-install.txt /tmp/files-after-npm-install.txt - name: 🧪 Run Vitest Tests + if: ${{ !inputs.docs_only }} run: npm run test:positron - name: Compile Positron and Download Electron + if: ${{ !inputs.docs_only }} run: npm exec -- npm-run-all --max-old-space-size=8192 -p compile "electron x64" # System deps are baked into the container image, so install browsers only. - name: Install Playwright browsers + if: ${{ !inputs.docs_only }} env: PLAYWRIGHT_BROWSERS_PATH: .playwright-browsers run: npx playwright install # Downloads Builtin Extensions - name: Prelaunch + if: ${{ !inputs.docs_only }} run: npm run prelaunch - name: 💾 Save caches - if: ${{ inputs.save_cache }} + if: ${{ !inputs.docs_only && inputs.save_cache }} uses: ./.github/actions/save-build-caches with: cache-npm-core-hit: ${{ steps.restore-caches.outputs.cache-npm-core-hit }} @@ -162,6 +178,7 @@ jobs: playwright-version: ${{ steps.restore-caches.outputs.playwright-version }} - name: Set permissions on SUID sandbox helper + if: ${{ !inputs.docs_only }} run: | ELECTRON_ROOT=.build/electron sudo chown root $ELECTRON_ROOT/chrome-sandbox @@ -169,9 +186,11 @@ jobs: stat $ELECTRON_ROOT/chrome-sandbox - name: Setup Xvfb + if: ${{ !inputs.docs_only }} uses: ./.github/actions/setup-xvfb - name: Install Positron License + if: ${{ !inputs.docs_only }} uses: ./.github/actions/install-license with: github-token: ${{ secrets.POSITRON_GITHUB_RO_PAT }} @@ -181,20 +200,24 @@ jobs: # unit test ("Can list tables and fields from R connections") reads a # DESCRIPTION file from the repo root, so stage that from test-files. - name: Stage R DESCRIPTION file + if: ${{ !inputs.docs_only }} run: cp test/e2e/test-files/DESCRIPTION DESCRIPTION - name: 🧪 Run VS Code Tests (Electron) id: electron-unit-tests + if: ${{ !inputs.docs_only }} run: DISPLAY=:10 ./scripts/test.sh - name: 🧪 Run VS Code Tests (Node.js) id: nodejs-unit-tests + if: ${{ !inputs.docs_only }} env: PLAYWRIGHT_BROWSERS_PATH: .playwright-browsers run: npm run test-node - name: 🧪 Run VS Code Tests (Chromium) id: browser-unit-tests + if: ${{ !inputs.docs_only }} env: PLAYWRIGHT_BROWSERS_PATH: .playwright-browsers run: DISPLAY=:10 npm run test-browser-no-install -- --browser chromium diff --git a/scripts/is-docs-only-change.sh b/scripts/is-docs-only-change.sh new file mode 100644 index 000000000000..5a832232e26d --- /dev/null +++ b/scripts/is-docs-only-change.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Reads a newline-delimited list of repo-relative changed file paths on stdin. +# Prints "true" iff the list is non-empty AND every path is a docs-only file +# (Markdown anywhere, docs/** tree, or root LICENSE*/CHANGELOG*/NOTICE*). +# Prints "false" otherwise. Used by CI to skip heavy test suites for changes +# that no test could exercise. Conservative by design: any non-doc file, or an +# empty diff, yields "false" so full CI runs. +set -uo pipefail + +# Return 0 (docs-only) if the single path matches the allowlist. +is_doc_path() { + local path="$1" + case "$path" in + *.md) return 0 ;; # Markdown anywhere + docs/*) return 0 ;; # docs/ tree (segment-anchored) + */*) return 1 ;; # any other nested path is not a root doc + LICENSE*|CHANGELOG*|NOTICE*) return 0 ;; # root license/changelog/notice only + *) return 1 ;; + esac +} + +saw_any=false +result=true + +# Check one path, but only while we still believe the change is docs-only. +# Do NOT break out of the loop on the first non-doc file: an early break +# leaves stdin undrained, which SIGPIPEs the upstream producer (printf / git +# diff) under `set -o pipefail` on large inputs. Read to EOF unconditionally. +check_path() { + if [[ "$result" == "true" ]] && ! is_doc_path "$1"; then + result=false + fi +} + +while IFS= read -r path; do + # Skip blank lines (trailing newline, empty stdin). + [[ -z "$path" ]] && continue + saw_any=true + check_path "$path" +done + +# Handle the final line when input has no trailing newline (the while-read +# loop does not execute its body for an unterminated last line). +if [[ -n "$path" ]]; then + saw_any=true + check_path "$path" +fi + +if [[ "$saw_any" != "true" ]]; then + result=false +fi + +printf '%s' "$result" diff --git a/scripts/test/is-docs-only-change-test.sh b/scripts/test/is-docs-only-change-test.sh new file mode 100644 index 000000000000..4e0fb2d9a037 --- /dev/null +++ b/scripts/test/is-docs-only-change-test.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Unit tests for scripts/is-docs-only-change.sh. +# Plain bash (no bats) so it runs in CI with zero install. Prints PASS/FAIL per +# check and exits non-zero if any check fails. +set -uo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +SCRIPT="$HERE/../is-docs-only-change.sh" + +fail=0 +assert_eq() { + local desc="$1" expected="$2" actual="$3" + if [[ "$expected" == "$actual" ]]; then + echo "PASS: $desc" + else + echo "FAIL: $desc" + echo " expected: [$expected]" + echo " actual: [$actual]" + fail=1 + fi +} + +# Helper: run the script with the given newline-delimited paths on stdin. +run() { printf '%s' "$1" | bash "$SCRIPT"; } + +assert_eq "all markdown -> true" "true" \ + "$(run "$(printf 'README.md\ndocs/foo/bar.md')")" +assert_eq "docs dir only -> true" "true" \ + "$(run "$(printf 'docs/guide.txt\ndocs/img/x.png')")" +assert_eq "root license/changelog -> true" "true" \ + "$(run "$(printf 'LICENSE\nCHANGELOG.md\nNOTICE')")" +assert_eq "mixed docs + code -> false" "false" \ + "$(run "$(printf 'README.md\nsrc/vs/foo.ts')")" +assert_eq "single code file -> false" "false" \ + "$(run "src/vs/foo.ts")" +# 'docs/' as a substring inside a code path must NOT count as docs-only. +assert_eq "docs substring in code path -> false" "false" \ + "$(run "src/docsviewer/foo.ts")" +# A .md deeper in the tree still counts. +assert_eq "nested markdown -> true" "true" \ + "$(run "src/vs/workbench/contrib/foo/README.md")" +assert_eq "empty input -> false" "false" \ + "$(run "")" +# A root LICENSE*-style glob must not match nested paths (e.g. a code file +# living under a directory that happens to start with LICENSE). +assert_eq "nested path matching root glob prefix -> false" "false" \ + "$(run "LICENSE_TOOLS/foo.ts")" + +# Regression: a large list with an early non-doc path must not SIGPIPE the +# upstream producer under `set -o pipefail` -- the script must drain all of +# stdin. Build a >64KB list whose first entry is code. +big_list() { + printf 'src/vs/first.ts\n' + # ~6000 lines * ~14 bytes ~= 84KB, safely over the pipe buffer. + for i in $(seq 1 6000); do printf 'docs/page-%04d.md\n' "$i"; done +} +assert_eq "large list, early non-doc, no SIGPIPE -> false" "false" \ + "$(big_list | bash "$SCRIPT")" +# Also assert the pipeline itself exits 0 (no 141/SIGPIPE) under pipefail. +(set -o pipefail; big_list | bash "$SCRIPT" >/dev/null) +assert_eq "large list pipeline exit status" "0" "$?" + +if [[ "$fail" -ne 0 ]]; then + echo "SOME TESTS FAILED" + exit 1 +fi +echo "ALL TESTS PASSED"