diff --git a/.github/actions/percy-exec/action.yml b/.github/actions/percy-exec/action.yml deleted file mode 100644 index e1c25d5a7d..0000000000 --- a/.github/actions/percy-exec/action.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: 'Percy Exec' -description: 'Run a command with Percy, or skip Percy for fork PRs (no token access)' - -inputs: - command: - description: 'The command to run (e.g., "pytest --headless tests/")' - required: true - percy-token: - description: 'Percy token (pass secrets.PERCY_TOKEN)' - required: false - default: '' - working-directory: - description: 'Working directory for the command' - required: false - default: '.' - browser-executable: - description: 'Path to a Chrome/Chromium binary for Percy to use. Defaults to the one found on PATH.' - required: false - default: '' - -runs: - using: 'composite' - steps: - - name: Run with Percy - if: inputs.percy-token != '' - shell: bash - working-directory: ${{ inputs.working-directory }} - env: - PERCY_TOKEN: ${{ inputs.percy-token }} - PERCY_BROWSER_EXECUTABLE: ${{ inputs.browser-executable }} - run: | - # Percy otherwise downloads its own Chromium from storage.googleapis.com - # on the first snapshot. That download stalls in CI, which blocks the - # synchronous percy_snapshot() calls (so pytest never finishes and writes - # no JUnit) and leaves an empty Percy build with no snapshots. Point Percy - # at the Chrome already installed in the job so it skips the download. - if [ -z "${PERCY_BROWSER_EXECUTABLE:-}" ]; then - PERCY_BROWSER_EXECUTABLE="$(command -v google-chrome || command -v google-chrome-stable || command -v chrome || command -v chromium || command -v chromium-browser || true)" - export PERCY_BROWSER_EXECUTABLE - fi - echo "PERCY_BROWSER_EXECUTABLE=${PERCY_BROWSER_EXECUTABLE:-}" - npx percy exec -- ${{ inputs.command }} - - - name: Run without Percy (fork PR) - if: inputs.percy-token == '' - shell: bash - working-directory: ${{ inputs.working-directory }} - run: | - echo "::notice::Skipping Percy (no token available - likely a fork PR)" - ${{ inputs.command }} diff --git a/.github/workflows/nitpix-approve.yml b/.github/workflows/nitpix-approve.yml new file mode 100644 index 0000000000..649dfb77ad --- /dev/null +++ b/.github/workflows/nitpix-approve.yml @@ -0,0 +1,25 @@ +name: nitpix approve + +# Handles `/nitpix approve` comments on PRs: records the approved snapshot +# hashes on the nitpix-baselines branch and flips the `nitpix/visual` commit +# status to success — without re-running the test matrix. Only users with +# write access can approve. + +on: + issue_comment: + types: [created] + +permissions: + contents: write + pull-requests: write + statuses: write + +jobs: + approve: + name: Approve visual changes + runs-on: ubuntu-latest + if: github.event.issue.pull_request && startsWith(github.event.comment.body, '/nitpix') + steps: + - uses: plotly/nitpix/approve@main # TODO: pin to @v1 once released + with: + baseline-branch: nitpix-baselines diff --git a/.github/workflows/nitpix.yml b/.github/workflows/nitpix.yml new file mode 100644 index 0000000000..ba3346f43b --- /dev/null +++ b/.github/workflows/nitpix.yml @@ -0,0 +1,43 @@ +name: nitpix + +# Visual diffing (replaces Percy). Runs in the base-repo context via +# workflow_run so it has a full-privilege token even for fork and Dependabot +# PRs (which get a read-only token in "Dash Testing" itself). +# +# - PR-triggered test runs: diff the uploaded nitpix-snapshots-* artifacts +# against baselines// on the nitpix-baselines orphan branch, +# post/update the review comment and the `nitpix/visual` commit status. +# Approve visual changes by commenting `/nitpix approve` on the PR +# (see nitpix-approve.yml). +# - push-triggered test runs (dev/master): promote that run's snapshots to +# the branch's baselines. +# +# SECURITY: never check out or execute PR code in this workflow — it runs +# with write permissions for all PRs. nitpix only handles PNG artifacts. + +on: + workflow_run: + workflows: ["Dash Testing"] + types: [completed] + +permissions: + contents: write # push to the nitpix-baselines branch + pull-requests: write # the review comment + statuses: write # the nitpix/visual check + +concurrency: + group: nitpix-${{ github.event.workflow_run.head_sha }} + cancel-in-progress: true + +jobs: + visual-review: + name: Visual Review + runs-on: ubuntu-latest + if: | + github.event.workflow_run.conclusion == 'success' && + (github.event.workflow_run.event == 'pull_request' || + github.event.workflow_run.event == 'push') + steps: + - uses: plotly/nitpix@main # TODO: pin to @v1 once released + with: + baseline-branch: nitpix-baselines diff --git a/.github/workflows/post-test-status.yml b/.github/workflows/post-test-status.yml index 5be78d009b..91aa122eba 100644 --- a/.github/workflows/post-test-status.yml +++ b/.github/workflows/post-test-status.yml @@ -65,43 +65,9 @@ jobs: } } - - name: Post Percy status for fork/Dependabot PRs - # Percy needs PERCY_TOKEN to run, which is unavailable to fork PRs (and to - # Dependabot unless added to Dependabot secrets). Without a build, the - # Percy GitHub App never posts the required `percy/dash` status and the PR - # is blocked. Post a success status here so the check resolves — but only - # if Percy hasn't already posted one, so a real Percy result is never - # clobbered (e.g. when Dependabot does have the token). - if: > - github.event.workflow_run.head_repository.full_name != github.repository || - github.event.workflow_run.actor.login == 'dependabot[bot]' - uses: actions/github-script@v7 - with: - script: | - const { owner, repo } = context.repo; - const sha = context.payload.workflow_run.head_sha; - const statusContext = 'percy/dash'; - - const { data: { statuses } } = await github.rest.repos.getCombinedStatus({ - owner, - repo, - ref: sha, - }); - - if (statuses.some(s => s.context === statusContext)) { - console.log(`'${statusContext}' already posted — leaving it untouched.`); - return; - } - - await github.rest.repos.createCommitStatus({ - owner, - repo, - sha, - state: 'success', - context: statusContext, - description: 'Skipped — Percy unavailable for fork/Dependabot PRs', - }); - console.log(`Posted skipped status for ${statusContext}`); + # NOTE: the "Post Percy status for fork/Dependabot PRs" workaround that + # lived here was removed with the move to nitpix (nitpix.yml), which + # handles fork/Dependabot PRs natively via workflow_run. test-report: name: Consolidated Test Report (Fork PR) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index c0e6669d0a..c6c79cb422 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -327,7 +327,7 @@ jobs: timeout-minutes: 15 run: | cd bgtests - pytest --headless --nopercyfinalize --junitxml=test-reports/junit_background.xml tests/background_callback -v -s + pytest --headless --junitxml=test-reports/junit_background.xml tests/background_callback -v -s - name: Cleanup background processes if: always() @@ -340,7 +340,7 @@ jobs: timeout-minutes: 15 run: | cd bgtests - pytest --headless --nopercyfinalize --junitxml=test-reports/junit_async.xml tests/async_tests -v -s + pytest --headless --junitxml=test-reports/junit_async.xml tests/async_tests -v -s - name: Upload test results if: always() @@ -422,7 +422,7 @@ jobs: cp -r tests bgtests/tests cd bgtests touch __init__.py - pytest --headless --nopercyfinalize tests/backend_tests -v -s + pytest --headless tests/backend_tests -v -s table-unit: name: Table Unit/Lint Tests (Python ${{ matrix.python-version }}) @@ -536,7 +536,7 @@ jobs: - name: Run Table Server Tests run: | cd components/dash-table - pytest --nopercyfinalize --headless --junitxml=test-reports/junit_table.xml --splits 3 --group ${{ matrix.test-group }} + pytest --headless --junitxml=test-reports/junit_table.xml --splits 3 --group ${{ matrix.test-group }} - name: Upload test results if: always() @@ -605,7 +605,7 @@ jobs: cp -r tests wstests/tests cd wstests touch __init__.py - pytest --headless --nopercyfinalize tests/websocket -v -s + pytest --headless tests/websocket -v -s test-main: name: Main Dash Tests (Python ${{ matrix.python-version }}, Group ${{ matrix.test-group }}) @@ -619,17 +619,10 @@ jobs: test-group: ["1", "2", "3"] env: - PERCY_TOKEN: ${{ matrix.python-version == '3.12' && secrets.PERCY_TOKEN || '' }} - PERCY_ENABLE: ${{ matrix.python-version == '3.12' && '1' || '0' }} - PERCY_PARALLEL_TOTAL: -1 - # Pin the build identity so every shard joins the same parallel build and - # Percy links it to the PR. Auto-detection otherwise uses the ephemeral - # merge SHA (refs/pull/N/merge), so the build never shows up on the PR. - PERCY_PARALLEL_NONCE: ${{ github.run_id }}-${{ github.run_attempt }} - PERCY_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} - PERCY_BRANCH: ${{ github.head_ref || github.ref_name }} - PERCY_PULL_REQUEST: ${{ github.event.pull_request.number }} - PERCY_TARGET_BRANCH: ${{ github.base_ref }} + # Visual snapshots (nitpix) are captured on a single matrix leg so + # parallel legs don't produce duplicate snapshot names. When unset, + # percy_snapshot() is a no-op. + NITPIX_SNAPSHOT_DIR: ${{ matrix.python-version == '3.12' && '/tmp/nitpix-snapshots' || '' }} steps: - name: Checkout repository @@ -680,16 +673,14 @@ jobs: run: npm run setup-tests.py - name: Run main integration tests - if: matrix.python-version != '3.12' - run: pytest --headless --nopercyfinalize --junitxml=test-reports/junit_intg.xml tests/integration --splits 3 --group ${{ matrix.test-group }} + run: pytest --headless --junitxml=test-reports/junit_intg.xml tests/integration --splits 3 --group ${{ matrix.test-group }} - - name: Run main integration tests with Percy - if: matrix.python-version == '3.12' - uses: ./.github/actions/percy-exec + - name: Upload visual snapshots + if: always() && matrix.python-version == '3.12' + uses: plotly/nitpix/upload@main # TODO: pin to @v1 once released with: - command: pytest --headless --nopercyfinalize --junitxml=test-reports/junit_intg.xml tests/integration --splits 3 --group ${{ matrix.test-group }} - percy-token: ${{ secrets.PERCY_TOKEN }} - browser-executable: ${{ steps.setup-chrome.outputs.chrome-path }} + name: main-${{ matrix.test-group }} + path: /tmp/nitpix-snapshots - name: Upload test results if: always() @@ -722,17 +713,9 @@ jobs: python-version: ["3.9", "3.12"] env: - PERCY_TOKEN: ${{ matrix.python-version == '3.12' && secrets.PERCY_TOKEN || '' }} - PERCY_ENABLE: ${{ matrix.python-version == '3.12' && '1' || '0' }} - PERCY_PARALLEL_TOTAL: -1 - # Pin the build identity so every shard joins the same parallel build and - # Percy links it to the PR. Auto-detection otherwise uses the ephemeral - # merge SHA (refs/pull/N/merge), so the build never shows up on the PR. - PERCY_PARALLEL_NONCE: ${{ github.run_id }}-${{ github.run_attempt }} - PERCY_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} - PERCY_BRANCH: ${{ github.head_ref || github.ref_name }} - PERCY_PULL_REQUEST: ${{ github.event.pull_request.number }} - PERCY_TARGET_BRANCH: ${{ github.base_ref }} + # Visual snapshots (nitpix) are captured on a single matrix leg so + # parallel legs don't produce duplicate snapshot names. + NITPIX_SNAPSHOT_DIR: ${{ matrix.python-version == '3.12' && '/tmp/nitpix-snapshots' || '' }} steps: - name: Checkout repository @@ -784,18 +767,15 @@ jobs: run: npm ci - name: Run HTML components tests - if: matrix.python-version != '3.12' working-directory: components/dash-html-components - run: pytest --headless --nopercyfinalize --junitxml=test-reports/junit_html.xml + run: pytest --headless --junitxml=test-reports/junit_html.xml - - name: Run HTML components tests with Percy - if: matrix.python-version == '3.12' - uses: ./.github/actions/percy-exec + - name: Upload visual snapshots + if: always() && matrix.python-version == '3.12' + uses: plotly/nitpix/upload@main # TODO: pin to @v1 once released with: - command: pytest --headless --nopercyfinalize --junitxml=test-reports/junit_html.xml - percy-token: ${{ secrets.PERCY_TOKEN }} - browser-executable: ${{ steps.setup-chrome.outputs.chrome-path }} - working-directory: components/dash-html-components + name: html + path: /tmp/nitpix-snapshots - name: Upload test results if: always() @@ -918,17 +898,9 @@ jobs: test-group: ["1", "2", "3"] env: - PERCY_TOKEN: ${{ matrix.python-version == '3.12' && secrets.PERCY_TOKEN || '' }} - PERCY_ENABLE: ${{ matrix.python-version == '3.12' && '1' || '0' }} - PERCY_PARALLEL_TOTAL: -1 - # Pin the build identity so every shard joins the same parallel build and - # Percy links it to the PR. Auto-detection otherwise uses the ephemeral - # merge SHA (refs/pull/N/merge), so the build never shows up on the PR. - PERCY_PARALLEL_NONCE: ${{ github.run_id }}-${{ github.run_attempt }} - PERCY_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} - PERCY_BRANCH: ${{ github.head_ref || github.ref_name }} - PERCY_PULL_REQUEST: ${{ github.event.pull_request.number }} - PERCY_TARGET_BRANCH: ${{ github.base_ref }} + # Visual snapshots (nitpix) are captured on a single matrix leg so + # parallel legs don't produce duplicate snapshot names. + NITPIX_SNAPSHOT_DIR: ${{ matrix.python-version == '3.12' && '/tmp/nitpix-snapshots' || '' }} steps: - name: Checkout repository @@ -980,7 +952,14 @@ jobs: cd components/dash-core-components npm ci npm run test:jest - pytest --headless --nopercyfinalize --junitxml=test-reports/junit_intg.xml --junitprefix="components.dash-core-components" tests/integration --splits 3 --group ${{ matrix.test-group }} + pytest --headless --junitxml=test-reports/junit_intg.xml --junitprefix="components.dash-core-components" tests/integration --splits 3 --group ${{ matrix.test-group }} + + - name: Upload visual snapshots + if: always() && matrix.python-version == '3.12' + uses: plotly/nitpix/upload@main # TODO: pin to @v1 once released + with: + name: dcc-${{ matrix.test-group }} + path: /tmp/nitpix-snapshots - name: Upload test results if: always() @@ -1084,35 +1063,12 @@ jobs: retention-days: 7 if-no-files-found: ignore - percy-finalize: - name: Finalize Percy Snapshots - needs: [test-main, dcc-test, html-test] - runs-on: ubuntu-latest - if: always() - env: - PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }} - # Must match the snapshot jobs so finalize targets the same parallel build - # and the result is attached to the PR head commit, not the merge SHA. - PERCY_PARALLEL_NONCE: ${{ github.run_id }}-${{ github.run_attempt }} - PERCY_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} - PERCY_BRANCH: ${{ github.head_ref || github.ref_name }} - PERCY_PULL_REQUEST: ${{ github.event.pull_request.number }} - PERCY_TARGET_BRANCH: ${{ github.base_ref }} - steps: - - name: Finalize Main Percy Build - if: | - env.PERCY_TOKEN != '' && ( - needs.test-main.result != 'skipped' || - needs.dcc-test.result != 'skipped' || - needs.html-test.result != 'skipped' - ) - run: | - npm install -g @percy/cli - npx percy build:finalize - - - name: Skip Percy finalize (fork PR) - if: env.PERCY_TOKEN == '' - run: echo "::notice::Skipping Percy finalize (no token available - likely a fork PR)" + # Visual diffing (nitpix) runs in a separate workflow (nitpix.yml) triggered + # by workflow_run: it downloads the nitpix-snapshots-* artifacts uploaded by + # the jobs above, diffs them against the baselines on the nitpix-baselines + # branch, and posts the PR comment + nitpix/visual commit status. Running it + # over there (base-repo context) is what makes it work for fork PRs and + # Dependabot, which get a read-only token in this workflow. test-report: name: Consolidated Test Report @@ -1181,7 +1137,7 @@ jobs: artifacts: name: Store Build Artifacts - needs: [build, percy-finalize] + needs: [build, test-main] runs-on: ubuntu-latest if: | github.event_name == 'push' && diff --git a/dash/testing/browser.py b/dash/testing/browser.py index a6382c17dd..86c3357b45 100644 --- a/dash/testing/browser.py +++ b/dash/testing/browser.py @@ -1,11 +1,11 @@ # pylint: disable=missing-docstring import os +import re import sys import time import logging from typing import Union, Optional import warnings -from percy import percy_snapshot as _percy_snapshot from selenium import webdriver from selenium.webdriver.support import expected_conditions as EC @@ -75,15 +75,13 @@ def __init__( self._window_idx = 0 # switch browser tabs - if self._percy_run: - # Percy CLI handles build initialization via percy exec wrapper - self._percy_assets_root = percy_assets_root - # No explicit initialization needed + # percy_run/percy_finalize/percy_assets_root are deprecated Percy-era + # options, accepted (and ignored) so existing callers don't break. + self._percy_assets_root = percy_assets_root logger.debug("initialize browser with arguments") logger.debug(" headless => %s", self._headless) logger.debug(" download_path => %s", self._download_path) - logger.debug(" percy asset root => %s", os.path.abspath(percy_assets_root)) def __enter__(self): return self @@ -91,11 +89,6 @@ def __enter__(self): def __exit__(self, exc_type, exc_val, traceback): try: self.driver.quit() - if self._percy_run and self._percy_finalize: - logger.info("percy finalize will be handled by percy build:finalize") - # With percy CLI, finalization handled by separate command - else: - logger.info("percy finalize relies on CI job") except WebDriverException: logger.exception("webdriver quit was not successful") @@ -134,23 +127,28 @@ def visit_and_snapshot( logger.exception("snapshot at resource %s error", path) raise e - def percy_snapshot( + # convert_canvases is deprecated and ignored, kept for API compatibility + def percy_snapshot( # pylint: disable=unused-argument self, name="", wait_for_callbacks=False, convert_canvases=False, widths=None ): - """percy_snapshot - visual test api shortcut to `percy_runner.snapshot`. - It also combines the snapshot `name` with the Python version, + """percy_snapshot - visual test snapshot, captured locally for nitpix. + + Saves one PNG per width into the directory given by the + NITPIX_SNAPSHOT_DIR environment variable (does nothing when it is + unset); CI diffs them against baselines with plotly/nitpix. The method + keeps its historical name so existing tests, and component libraries + built on dash.testing, work unchanged. args: - - name: combined with the python version to give the final snapshot name + - name: the snapshot name; a `_async` suffix is added when asgiref is + installed so sync and async runs don't collide - wait_for_callbacks: default False, whether to wait for Dash callbacks, after an extra second to ensure that any relevant callbacks have been initiated - - convert_canvases: default False, whether to convert all canvas elements - in the DOM into static images for percy to see. They will be restored - after the snapshot is complete. - - widths: a list of pixel widths for percy to render the page with. Note - that this does not change the browser in which the DOM is constructed, - so the width will only affect CSS, not JS-driven layout. - Defaults to [1280] + - convert_canvases: deprecated and ignored. Real browser screenshots + capture canvas elements natively (Percy's DOM re-rendering did not). + - widths: a list of pixel widths to capture the page at. Unlike Percy, + the browser window is actually resized, so JS-driven layout + responds too, not just CSS. Defaults to [1280] """ if widths is None: widths = [1280] @@ -161,6 +159,11 @@ def percy_snapshot( except ImportError: pass + snapshot_dir = os.getenv("NITPIX_SNAPSHOT_DIR") + if not snapshot_dir: + logger.debug("visual snapshots disabled - NITPIX_SNAPSHOT_DIR not set") + return + logger.info("taking snapshot name => %s", name) try: if wait_for_callbacks: @@ -169,56 +172,37 @@ def percy_snapshot( time.sleep(1) until(self._wait_for_callbacks, timeout=40, poll=0.3) except TestingTimeoutError: - # API will log the error but this TimeoutError should not block - # the test execution to continue and it will still do a snapshot - # as diff reference for the build run. + # Log the error but this TimeoutError should not block the test + # execution; it will still take a snapshot as diff reference. logger.error( "wait_for_callbacks failed => status of invalid rqs %s", self.redux_state_rqs, ) - if convert_canvases: - self.driver.execute_script( - """ - const stash = window._canvasStash = []; - Array.from(document.querySelectorAll('canvas')).forEach(c => { - const i = document.createElement('img'); - i.src = c.toDataURL(); - i.width = c.width; - i.height = c.height; - i.setAttribute('style', c.getAttribute('style')); - i.className = c.className; - i.setAttribute('data-canvasnum', stash.length); - stash.push(c); - c.parentElement.insertBefore(i, c); - c.parentElement.removeChild(c); - }); - """ - ) - - # NEW: Use percy-python-selenium SDK + safe_name = re.sub(r"[^\w.@-]+", "_", name).strip("_") + original_size = self.driver.get_window_size() try: - if os.getenv("PERCY_TOKEN"): - percy_options = {"widths": widths, "min_height": 1024} - _percy_snapshot(self.driver, name, **percy_options) - else: - logger.debug("Percy snapshots disabled - PERCY_TOKEN not set") - except Exception as err: # pylint: disable=broad-exception-caught - logger.warning("Percy snapshot failed: %s", err) - - if convert_canvases: - self.driver.execute_script( - """ - const stash = window._canvasStash; - Array.from( - document.querySelectorAll('img[data-canvasnum]') - ).forEach(i => { - const c = stash[+i.getAttribute('data-canvasnum')]; - i.parentElement.insertBefore(c, i); - i.parentElement.removeChild(i); - }); - delete window._canvasStash; - """ + for width in widths: + self.driver.set_window_size(width, 1024) + try: + # capture the full page height (Percy rendered full pages), + # bounded to keep runaway layouts from producing huge images + doc_height = self.driver.execute_script( + "return document.documentElement.scrollHeight" + ) + height = max(1024, min(int(doc_height or 0), 4096)) + except WebDriverException: + height = 1024 + if height != 1024: + self.driver.set_window_size(width, height) + snapshot_path = os.path.join(snapshot_dir, f"{safe_name}@{width}.png") + os.makedirs(os.path.dirname(snapshot_path), exist_ok=True) + self.driver.save_screenshot(snapshot_path) + except WebDriverException as err: + logger.warning("visual snapshot %s failed: %s", name, err) + finally: + self.driver.set_window_size( + original_size["width"], original_size["height"] ) def take_snapshot(self, name: str): diff --git a/requirements/testing.txt b/requirements/testing.txt index 306ec4f0d6..9f91aeb200 100644 --- a/requirements/testing.txt +++ b/requirements/testing.txt @@ -2,7 +2,6 @@ beautifulsoup4>=4.8.2 cryptography lxml>=4.6.2 -percy-python-selenium>=1.0.0 pytest>=6.0.2 requests[security]>=2.21.0 selenium>=3.141.0,<=4.2.0