diff --git a/.appveyor.yml b/.appveyor.yml index 87f6cbde6384..e8b6c720f29c 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -62,6 +62,8 @@ install: - activate mpl-dev - conda install -c conda-forge pywin32 - echo %PYTHON_VERSION% %TARGET_ARCH% + # Install browsers for testing + - playwright install --with-deps # Show the installed packages + versions - conda list diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8875a38cc1bb..a9331ae7ebb4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -197,10 +197,10 @@ jobs: ~/.cache/matplotlib !~/.cache/matplotlib/tex.cache !~/.cache/matplotlib/test_cache - key: 4-${{ runner.os }}-py${{ matrix.python-version }}-mpl-${{ github.ref }}-${{ github.sha }} + key: 5-${{ runner.os }}-py${{ matrix.python-version }}-mpl-${{ github.ref }}-${{ github.sha }} restore-keys: | - 4-${{ runner.os }}-py${{ matrix.python-version }}-mpl-${{ github.ref }}- - 4-${{ runner.os }}-py${{ matrix.python-version }}-mpl- + 5-${{ runner.os }}-py${{ matrix.python-version }}-mpl-${{ github.ref }}- + 5-${{ runner.os }}-py${{ matrix.python-version }}-mpl- - name: Install Python dependencies run: | @@ -281,6 +281,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 @@ -311,6 +314,8 @@ jobs: - name: Run pytest run: | 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 @@ -377,6 +382,12 @@ jobs: name: "${{ matrix.python-version }} ${{ matrix.os }} ${{ matrix.name-suffix }} result images" path: ./result_images + - uses: actions/upload-artifact@v3 + 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 4c50c543846a..bc48d870740e 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -163,6 +163,9 @@ stages: --verbose --editable .[dev] displayName: "Install self" + - bash: playwright install --with-deps + displayName: 'Install browsers for testing' + - script: env displayName: 'print env' @@ -225,6 +228,7 @@ stages: fi 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 [[ -n $SESSION_ID ]]; then if [[ $VS_VER == 2022 ]]; then diff --git a/environment.yml b/environment.yml index 2930ccf17e83..edc4e9cc9946 100644 --- a/environment.yml +++ b/environment.yml @@ -56,6 +56,8 @@ dependencies: - nbconvert[execute]!=6.0.0,!=6.0.1,!=7.3.0,!=7.3.1 - nbformat!=5.0.0,!=5.0.1 - pandas!=0.25.0 + - pip: + - pytest-playwright - psutil - pre-commit - pydocstyle>=5.1.0 diff --git a/lib/matplotlib/backends/backend_webagg.py b/lib/matplotlib/backends/backend_webagg.py index dfc5747ef77c..45a50cb7cb2a 100644 --- a/lib/matplotlib/backends/backend_webagg.py +++ b/lib/matplotlib/backends/backend_webagg.py @@ -234,9 +234,9 @@ def random_ports(port, n): cls.address = mpl.rcParams['webagg.address'] else: cls.address = address - cls.port = mpl.rcParams['webagg.port'] - for port in random_ports(cls.port, - mpl.rcParams['webagg.port_retries']): + if port is None: + port = mpl.rcParams['webagg.port'] + for port in random_ports(port, mpl.rcParams['webagg.port_retries']): try: app.listen(port, cls.address) except OSError as e: 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..3d1f0a89a631 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..0ba9183c61a0 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..08f512fb9ebe Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_backend_webagg/webkit.png differ diff --git a/lib/matplotlib/tests/conftest.py b/lib/matplotlib/tests/conftest.py index 54a1bc6cae94..a49a97d51c32 100644 --- a/lib/matplotlib/tests/conftest.py +++ b/lib/matplotlib/tests/conftest.py @@ -1,2 +1,20 @@ +import contextlib +import socket + +import pytest + from matplotlib.testing.conftest import ( # noqa mpl_test_settings, pytest_configure, pytest_unconfigure, pd, xr) + + +@pytest.fixture +def random_port(): + with contextlib.closing( + socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind(('', 0)) + s.listen(1) + addr = s.getsockname() + port = addr[1] + + return port diff --git a/lib/matplotlib/tests/test_backend_webagg.py b/lib/matplotlib/tests/test_backend_webagg.py index 1d6769494ef9..cc1f9d2e5a4c 100644 --- a/lib/matplotlib/tests/test_backend_webagg.py +++ b/lib/matplotlib/tests/test_backend_webagg.py @@ -1,14 +1,35 @@ import os +import re +import shutil +import subprocess import sys +import warnings + import pytest import matplotlib.backends.backend_webagg_core 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) @@ -30,3 +51,390 @@ def test_webagg_fallback(backend): def test_webagg_core_no_toolbar(): fm = matplotlib.backends.backend_webagg_core.FigureManagerWebAgg assert fm._toolbar2_class is None + + +@pytest.mark.backend('webagg') +def test_webagg_general(random_port, 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(port=random_port) + 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') + canvas.screenshot(path=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(random_port, page): + # Listen for all console logs. + page.on('console', lambda msg: print(f'CONSOLE: {msg.text}')) + + 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(port=random_port) + 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.backend('webagg') +@pytest.mark.parametrize('toolbar', ['toolbar2', 'toolmanager']) +def test_webagg_toolbar(random_port, page, toolbar): + from playwright.sync_api import expect + + # Listen for all console logs. + page.on('console', lambda msg: print(f'CONSOLE: {msg.text}')) + + with warnings.catch_warnings(): + warnings.filterwarnings('ignore', message='Treat the new Tool classes', + category=UserWarning) + 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(port=random_port) + 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=0.500 y=0.500') + + +@pytest.mark.backend('webagg') +def test_webagg_toolbar_save(random_port, 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(port=random_port) + 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(random_port, 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(random_port, 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/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index 6830e7d5c845..1ad3848bde60 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -479,14 +479,15 @@ def test_cross_Qt_imports(host, mpl): @pytest.mark.skipif('TF_BUILD' in os.environ, reason="this test fails an azure for unknown reasons") @pytest.mark.skipif(sys.platform == "win32", reason="Cannot send SIGINT on Windows.") -def test_webagg(): +def test_webagg(random_port): pytest.importorskip("tornado") proc = subprocess.Popen( [sys.executable, "-c", inspect.getsource(_test_interactive_impl) - + "\n_test_interactive_impl()", "{}"], + + "\n_test_interactive_impl()", + json.dumps({'webagg.port': random_port})], env={**os.environ, "MPLBACKEND": "webagg", "SOURCE_DATE_EPOCH": "0"}) - url = f'http://{mpl.rcParams["webagg.address"]}:{mpl.rcParams["webagg.port"]}' + url = f'http://{mpl.rcParams["webagg.address"]}:{random_port}' timeout = time.perf_counter() + _test_timeout try: while True: diff --git a/requirements/testing/all.txt b/requirements/testing/all.txt index e386924a9b67..9ee4a732fe76 100644 --- a/requirements/testing/all.txt +++ b/requirements/testing/all.txt @@ -6,6 +6,7 @@ coverage!=6.3 psutil pytest!=4.6.0,!=5.4.0,!=8.1.0 pytest-cov +pytest-playwright pytest-rerunfailures pytest-timeout pytest-xdist