diff --git a/.appveyor.yml b/.appveyor.yml index 10109c9f80f7..499745279458 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -63,6 +63,8 @@ install: - micromamba env create -f environment.yml python=%PYTHON_VERSION% %EXTRA_PACKAGES% - micromamba activate mpl-dev + # Install browsers for testing + - playwright install --with-deps build_script: # Now build the thing.. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bf75c80ddae0..3d553b110260 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -212,10 +212,10 @@ jobs: ~/.cache/matplotlib !~/.cache/matplotlib/tex.cache !~/.cache/matplotlib/test_cache - key: 6-${{ matrix.os }}-py${{ matrix.python-version }}-mpl-${{ github.ref }}-${{ github.sha }} + key: 7-${{ matrix.os }}-py${{ matrix.python-version }}-mpl-${{ github.ref }}-${{ github.sha }} restore-keys: | - 6-${{ matrix.os }}-py${{ matrix.python-version }}-mpl-${{ github.ref }}- - 6-${{ matrix.os }}-py${{ matrix.python-version }}-mpl- + 7-${{ matrix.os }}-py${{ matrix.python-version }}-mpl-${{ github.ref }}- + 7-${{ matrix.os }}-py${{ matrix.python-version }}-mpl- - name: Install Python dependencies run: | @@ -288,6 +288,9 @@ jobs: --index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple \ --upgrade --only-binary=:all: numpy pandas + - name: Install browsers for testing + run: playwright install --with-deps + - name: Install Matplotlib run: | ccache -s @@ -321,6 +324,8 @@ jobs: export PYTHON_GIL=0 fi pytest -rfEsXR -n auto \ + --browser chromium --browser firefox --browser webkit \ + --slowmo=100 --tracing=on --video=on \ --maxfail=50 --timeout=300 --durations=25 \ --cov-report=xml --cov=lib --log-level=DEBUG --color=yes @@ -391,6 +396,12 @@ jobs: name: "${{ matrix.python-version }} ${{ matrix.os }} ${{ matrix.name-suffix }} result images" path: ./result_images + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: failure() + with: + name: "${{ matrix.python-version }} ${{ matrix.os }} ${{ matrix.name-suffix }} playwright" + path: ./test-results + # Separate dependent job to only upload one issue from the matrix of jobs create-issue: if: ${{ failure() && github.event_name == 'schedule' }} diff --git a/azure-pipelines.yml b/azure-pipelines.yml index c8df751f2419..b51378d26e3d 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -85,6 +85,9 @@ stages: --verbose --editable .[dev] displayName: "Install self" + - bash: playwright install --with-deps + displayName: 'Install browsers for testing' + - script: env displayName: 'print env' @@ -119,6 +122,7 @@ stages: PYTHONFAULTHANDLER=1 pytest -rfEsXR -n 2 \ --maxfail=50 --timeout=300 --durations=25 \ + --browser chromium --browser firefox --browser webkit \ --junitxml=junit/test-results.xml --cov-report=xml --cov=lib if [[ $VS_VER == 2022 ]]; then diff --git a/environment.yml b/environment.yml index 573e93823f88..7d4480aa564c 100644 --- a/environment.yml +++ b/environment.yml @@ -65,6 +65,7 @@ dependencies: - pydocstyle>=5.1.0 - pytest!=4.6.0,!=5.4.0,!=8.1.0 - pytest-cov + - pytest-playwright - pytest-rerunfailures - pytest-timeout - pytest-xdist diff --git a/lib/matplotlib/tests/baseline_images/test_backend_webagg/chromium.png b/lib/matplotlib/tests/baseline_images/test_backend_webagg/chromium.png new file mode 100644 index 000000000000..d172a625ad06 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_backend_webagg/chromium.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_backend_webagg/firefox.png b/lib/matplotlib/tests/baseline_images/test_backend_webagg/firefox.png new file mode 100644 index 000000000000..f22ac6dd6352 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_backend_webagg/firefox.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_backend_webagg/webkit.png b/lib/matplotlib/tests/baseline_images/test_backend_webagg/webkit.png new file mode 100644 index 000000000000..a55474bbbaa1 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_backend_webagg/webkit.png differ diff --git a/lib/matplotlib/tests/test_backend_webagg.py b/lib/matplotlib/tests/test_backend_webagg.py index c63534ad20e3..43e9aa2fba41 100644 --- a/lib/matplotlib/tests/test_backend_webagg.py +++ b/lib/matplotlib/tests/test_backend_webagg.py @@ -1,7 +1,11 @@ +import io import os +import re +import shutil import sys from unittest.mock import MagicMock +from PIL import Image import pytest import matplotlib.backends.backend_webagg_core @@ -10,10 +14,26 @@ ) from matplotlib.testing import subprocess_run_for_testing +import matplotlib.pyplot as plt +from matplotlib.backends.backend_webagg import WebAggApplication +from matplotlib.testing.compare import compare_images +from matplotlib.testing.decorators import _image_directories +from matplotlib.testing.exceptions import ImageComparisonFailure + + +pytest.importorskip('tornado') + + +try: + import pytest_playwright # noqa +except ImportError: + @pytest.fixture + def page(): + pytest.skip(reason='Missing pytest-playwright') + @pytest.mark.parametrize("backend", ["webagg", "nbagg"]) def test_webagg_fallback(backend): - pytest.importorskip("tornado") if backend == "nbagg": pytest.importorskip("IPython") env = dict(os.environ) @@ -71,10 +91,404 @@ def test_toolbar_button_dispatch_allowlist(): ]) def test_websocket_rejects_cross_origin(host, origin, allowed): """Verify Tornado's default check_origin rejects cross-origin requests.""" - pytest.importorskip("tornado") from matplotlib.backends.backend_webagg import WebAggApplication ws = WebAggApplication.WebSocket.__new__(WebAggApplication.WebSocket) ws.request = MagicMock() ws.request.headers = {"Host": host} assert ws.check_origin(origin) is allowed + + +@pytest.mark.backend('webagg') +def test_webagg_general(page): + from playwright.sync_api import expect + + # Listen for all console logs. + page.on('console', lambda msg: print(f'CONSOLE: {msg.text}')) + + fig, ax = plt.subplots(facecolor='w') + + # Don't start the Tornado event loop, but use the existing event loop + # started by the `page` fixture. + WebAggApplication.initialize() + WebAggApplication.started = True + + page.goto(f'http://{WebAggApplication.address}:{WebAggApplication.port}/') + expect(page).to_have_title('MPL | WebAgg current figures') + + # Check title. + expect(page.locator('div.ui-dialog-title')).to_have_text('Figure 1') + + # Check canvas actually contains something. + baseline_dir, result_dir = _image_directories(test_webagg_general) + browser = page.context.browser.browser_type.name + actual = result_dir / f'{browser}.png' + expected = result_dir / f'{browser}-expected.png' + + canvas = page.locator('canvas.mpl-canvas') + actual_bytes = canvas.screenshot() + im = Image.open(io.BytesIO(actual_bytes)) + # Hide the resize grip, which varies across OS/browser. + if browser == 'firefox': + im.paste((255, 255, 255), + box=(im.width - 20, im.height - 20, im.width, im.height)) + im.save(actual) + shutil.copyfile(baseline_dir / f'{browser}.png', expected) + + err = compare_images(expected, actual, tol=0) + if err: + raise ImageComparisonFailure(err) + + +@pytest.mark.backend('webagg') +def test_webagg_resize(page): + # Listen for all console logs. + page.on('console', lambda msg: print(f'CONSOLE: {msg.text}')) + # Increase the viewport to at least twice the size of the default figure. + # Playwright seems to have a bug with mouse movement in Firefox that places the + # cursor somewhere near the origin when outside the viewport size. + page.set_viewport_size({'width': 1600, 'height': 1200}) + + fig, ax = plt.subplots(facecolor='w') + orig_bbox = fig.bbox.frozen() + + # Don't start the Tornado event loop, but use the existing event loop + # started by the `page` fixture. + WebAggApplication.initialize() + WebAggApplication.started = True + + page.goto(f'http://{WebAggApplication.address}:{WebAggApplication.port}/') + + canvas = page.locator('canvas.mpl-canvas') + + print(f'{orig_bbox=}') + # Resize the canvas to be twice as big. + bbox = canvas.bounding_box() + print(f'{bbox=}') + x, y = bbox['x'] + bbox['width'] - 1, bbox['y'] + bbox['height'] - 1 + print(f'{x=} {y=}') + page.mouse.move(x, y) + page.mouse.down() + page.mouse.move(x + bbox['width'], y + bbox['height']) + print(f'{x + bbox["width"]=} {y + bbox["height"]=}') + page.mouse.up() + + assert fig.bbox.height == orig_bbox.height * 2 + assert fig.bbox.width == orig_bbox.width * 2 + + +@pytest.mark.filterwarnings('ignore:Treat the new Tool classes:UserWarning') +@pytest.mark.backend('webagg') +@pytest.mark.parametrize('toolbar', ['toolbar2', 'toolmanager']) +def test_webagg_toolbar(page, toolbar): + from playwright.sync_api import expect + + # Listen for all console logs. + page.on('console', lambda msg: print(f'CONSOLE: {msg.text}')) + + plt.rcParams['toolbar'] = toolbar + + fig, ax = plt.subplots(facecolor='w') + + # Don't start the Tornado event loop, but use the existing event loop + # started by the `page` fixture. + WebAggApplication.initialize() + WebAggApplication.started = True + + page.goto(f'http://{WebAggApplication.address}:{WebAggApplication.port}/') + + expect(page.locator('button.mpl-widget')).to_have_count( + len([ + name for name, *_ in fig.canvas.manager.ToolbarCls.toolitems + if name is not None])) + + home = page.locator('button.mpl-widget').nth(0) + expect(home).to_be_visible() + + back = page.locator('button.mpl-widget').nth(1) + expect(back).to_be_visible() + forward = page.locator('button.mpl-widget').nth(2) + expect(forward).to_be_visible() + if toolbar == 'toolbar2': + # ToolManager doesn't implement history button disabling. + # https://github.com/matplotlib/matplotlib/issues/17979 + expect(back).to_be_disabled() + expect(forward).to_be_disabled() + + pan = page.locator('button.mpl-widget').nth(3) + expect(pan).to_be_visible() + zoom = page.locator('button.mpl-widget').nth(4) + expect(zoom).to_be_visible() + + save = page.locator('button.mpl-widget').nth(5) + expect(save).to_be_visible() + format_dropdown = page.locator('select.mpl-widget') + expect(format_dropdown).to_be_visible() + + if toolbar == 'toolmanager': + # Location in status bar is not supported by ToolManager. + return + + ax.set_position([0, 0, 1, 1]) + bbox = page.locator('canvas.mpl-canvas').bounding_box() + x, y = bbox['x'] + bbox['width'] / 2, bbox['y'] + bbox['height'] / 2 + page.mouse.move(x, y, steps=2) + message = page.locator('span.mpl-message') + expect(message).to_have_text('(x, y) = (0.500, 0.500)') + + +@pytest.mark.backend('webagg') +def test_webagg_toolbar_save(page): + from playwright.sync_api import expect + + # Listen for all console logs. + page.on('console', lambda msg: print(f'CONSOLE: {msg.text}')) + + fig, ax = plt.subplots(facecolor='w') + + # Don't start the Tornado event loop, but use the existing event loop + # started by the `page` fixture. + WebAggApplication.initialize() + WebAggApplication.started = True + + page.goto(f'http://{WebAggApplication.address}:{WebAggApplication.port}/') + + save = page.locator('button.mpl-widget').nth(5) + expect(save).to_be_visible() + + with page.context.expect_page() as new_page_info: + save.click() + new_page = new_page_info.value + + new_page.wait_for_load_state() + assert new_page.url.endswith('download.png') + + +@pytest.mark.backend('webagg') +def test_webagg_toolbar_pan(page): + from playwright.sync_api import expect + + # Listen for all console logs. + page.on('console', lambda msg: print(f'CONSOLE: {msg.text}')) + + fig, ax = plt.subplots(facecolor='w') + ax.plot([3, 2, 1]) + orig_lim = ax.viewLim.frozen() + # Make figure coords ~= axes coords, with ticks visible for inspection. + ax.set_position([0, 0, 1, 1]) + ax.tick_params(axis='y', direction='in', pad=-22) + ax.tick_params(axis='x', direction='in', pad=-15) + + # Don't start the Tornado event loop, but use the existing event loop + # started by the `page` fixture. + WebAggApplication.initialize() + WebAggApplication.started = True + + page.goto(f'http://{WebAggApplication.address}:{WebAggApplication.port}/') + + canvas = page.locator('canvas.mpl-canvas') + expect(canvas).to_be_visible() + home = page.locator('button.mpl-widget').nth(0) + expect(home).to_be_visible() + pan = page.locator('button.mpl-widget').nth(3) + expect(pan).to_be_visible() + zoom = page.locator('button.mpl-widget').nth(4) + expect(zoom).to_be_visible() + + active_re = re.compile(r'active') + expect(pan).not_to_have_class(active_re) + expect(zoom).not_to_have_class(active_re) + assert ax.get_navigate_mode() is None + pan.click() + expect(pan).to_have_class(active_re) + expect(zoom).not_to_have_class(active_re) + assert ax.get_navigate_mode() == 'PAN' + + # Pan 50% of the figure diagonally toward bottom-right. + bbox = canvas.bounding_box() + x, y = bbox['x'] + bbox['width'] / 4, bbox['y'] + bbox['height'] / 4 + page.mouse.move(x, y) + page.mouse.down() + page.mouse.move(x + bbox['width'] / 2, y + bbox['height'] / 2, + steps=20) + page.mouse.up() + + assert ax.get_xlim() == (orig_lim.x0 - orig_lim.width / 2, + orig_lim.x1 - orig_lim.width / 2) + assert ax.get_ylim() == (orig_lim.y0 + orig_lim.height / 2, + orig_lim.y1 + orig_lim.height / 2) + + # Reset. + home.click() + assert ax.viewLim.bounds == orig_lim.bounds + + # Pan 50% of the figure diagonally toward bottom-right, while holding 'x' + # key, to constrain the pan horizontally. + bbox = canvas.bounding_box() + x, y = bbox['x'] + bbox['width'] / 4, bbox['y'] + bbox['height'] / 4 + page.mouse.move(x, y) + page.mouse.down() + page.keyboard.down('x') + page.mouse.move(x + bbox['width'] / 2, y + bbox['height'] / 2, + steps=20) + page.mouse.up() + page.keyboard.up('x') + + assert ax.get_xlim() == (orig_lim.x0 - orig_lim.width / 2, + orig_lim.x1 - orig_lim.width / 2) + assert ax.get_ylim() == (orig_lim.y0, orig_lim.y1) + + # Reset. + home.click() + assert ax.viewLim.bounds == orig_lim.bounds + + # Pan 50% of the figure diagonally toward bottom-right, while holding 'y' + # key, to constrain the pan vertically. + bbox = canvas.bounding_box() + x, y = bbox['x'] + bbox['width'] / 4, bbox['y'] + bbox['height'] / 4 + page.mouse.move(x, y) + page.mouse.down() + page.keyboard.down('y') + page.mouse.move(x + bbox['width'] / 2, y + bbox['height'] / 2, + steps=20) + page.mouse.up() + page.keyboard.up('y') + + assert ax.get_xlim() == (orig_lim.x0, orig_lim.x1) + assert ax.get_ylim() == (orig_lim.y0 + orig_lim.height / 2, + orig_lim.y1 + orig_lim.height / 2) + + # Reset. + home.click() + assert ax.viewLim.bounds == orig_lim.bounds + + # Zoom 50% of the figure diagonally toward bottom-right. + bbox = canvas.bounding_box() + x, y = bbox['x'], bbox['y'] + page.mouse.move(x, y) + page.mouse.down(button='right') + page.mouse.move(x + bbox['width'] / 2, y + bbox['height'] / 2, + steps=20) + page.mouse.up(button='right') + + # Expands in x-direction. + assert ax.viewLim.x0 == orig_lim.x0 + assert ax.viewLim.x1 < orig_lim.x1 - orig_lim.width / 2 + # Contracts in y-direction. + assert ax.viewLim.y1 == orig_lim.y1 + assert ax.viewLim.y0 < orig_lim.y0 - orig_lim.height / 2 + + +@pytest.mark.backend('webagg') +def test_webagg_toolbar_zoom(page): + from playwright.sync_api import expect + + # Listen for all console logs. + page.on('console', lambda msg: print(f'CONSOLE: {msg.text}')) + + fig, ax = plt.subplots(facecolor='w') + ax.plot([3, 2, 1]) + orig_lim = ax.viewLim.frozen() + # Make figure coords ~= axes coords, with ticks visible for inspection. + ax.set_position([0, 0, 1, 1]) + ax.tick_params(axis='y', direction='in', pad=-22) + ax.tick_params(axis='x', direction='in', pad=-15) + + # Don't start the Tornado event loop, but use the existing event loop + # started by the `page` fixture. + WebAggApplication.initialize() + WebAggApplication.started = True + + page.goto(f'http://{WebAggApplication.address}:{WebAggApplication.port}/') + + canvas = page.locator('canvas.mpl-canvas') + expect(canvas).to_be_visible() + home = page.locator('button.mpl-widget').nth(0) + expect(home).to_be_visible() + pan = page.locator('button.mpl-widget').nth(3) + expect(pan).to_be_visible() + zoom = page.locator('button.mpl-widget').nth(4) + expect(zoom).to_be_visible() + + active_re = re.compile(r'active') + expect(pan).not_to_have_class(active_re) + expect(zoom).not_to_have_class(active_re) + assert ax.get_navigate_mode() is None + zoom.click() + expect(pan).not_to_have_class(active_re) + expect(zoom).to_have_class(active_re) + assert ax.get_navigate_mode() == 'ZOOM' + + # Zoom 25% in on each side. + bbox = canvas.bounding_box() + x, y = bbox['x'] + bbox['width'] / 4, bbox['y'] + bbox['height'] / 4 + page.mouse.move(x, y) + page.mouse.down() + page.mouse.move(x + bbox['width'] / 2, y + bbox['height'] / 2, + steps=20) + page.mouse.up() + + assert ax.get_xlim() == (orig_lim.x0 + orig_lim.width / 4, + orig_lim.x1 - orig_lim.width / 4) + assert ax.get_ylim() == (orig_lim.y0 + orig_lim.height / 4, + orig_lim.y1 - orig_lim.height / 4) + + # Reset. + home.click() + + # Zoom 25% in on each side, while holding 'x' key, to constrain the zoom + # horizontally.. + bbox = canvas.bounding_box() + x, y = bbox['x'] + bbox['width'] / 4, bbox['y'] + bbox['height'] / 4 + page.mouse.move(x, y) + page.mouse.down() + page.keyboard.down('x') + page.mouse.move(x + bbox['width'] / 2, y + bbox['height'] / 2, + steps=20) + page.mouse.up() + page.keyboard.up('x') + + assert ax.get_xlim() == (orig_lim.x0 + orig_lim.width / 4, + orig_lim.x1 - orig_lim.width / 4) + assert ax.get_ylim() == (orig_lim.y0, orig_lim.y1) + + # Reset. + home.click() + + # Zoom 25% in on each side, while holding 'y' key, to constrain the zoom + # vertically. + bbox = canvas.bounding_box() + x, y = bbox['x'] + bbox['width'] / 4, bbox['y'] + bbox['height'] / 4 + page.mouse.move(x, y) + page.mouse.down() + page.keyboard.down('y') + page.mouse.move(x + bbox['width'] / 2, y + bbox['height'] / 2, + steps=20) + page.mouse.up() + page.keyboard.up('y') + + assert ax.get_xlim() == (orig_lim.x0, orig_lim.x1) + assert ax.get_ylim() == (orig_lim.y0 + orig_lim.height / 4, + orig_lim.y1 - orig_lim.height / 4) + + # Reset. + home.click() + + # Zoom 25% out on each side. + bbox = canvas.bounding_box() + x, y = bbox['x'] + bbox['width'] / 4, bbox['y'] + bbox['height'] / 4 + page.mouse.move(x, y) + page.mouse.down(button='right') + page.mouse.move(x + bbox['width'] / 2, y + bbox['height'] / 2, + steps=20) + page.mouse.up(button='right') + + # Limits were doubled, but based on the central point. + cx = orig_lim.x0 + orig_lim.width / 2 + x0 = cx - orig_lim.width + x1 = cx + orig_lim.width + assert ax.get_xlim() == (x0, x1) + cy = orig_lim.y0 + orig_lim.height / 2 + y0 = cy - orig_lim.height + y1 = cy + orig_lim.height + assert ax.get_ylim() == (y0, y1) diff --git a/pyproject.toml b/pyproject.toml index a487d45e27d6..46192cd5a135 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -131,6 +131,7 @@ test = [ "psutil; sys_platform != 'cygwin'", "pytest!=4.6.0,!=5.4.0,!=8.1.0", "pytest-cov", + "pytest-playwright", "pytest-rerunfailures!=16.0", "pytest-timeout", "pytest-xdist",