From 7ebdeaf50ee78482ee30f0dc52bf0f5c6ee5d40c Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 5 Oct 2023 15:57:26 +0100 Subject: [PATCH 1/3] Add force_capture --- pytest_textual_snapshot.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pytest_textual_snapshot.py b/pytest_textual_snapshot.py index 2616d61..a79fbf3 100644 --- a/pytest_textual_snapshot.py +++ b/pytest_textual_snapshot.py @@ -39,6 +39,7 @@ def app_stash_key() -> pytest.StashKey: app_stash_key._key = pytest.StashKey[App]() return app_stash_key() + @pytest.fixture def snap_compare( snapshot: SnapshotAssertion, request: FixtureRequest @@ -54,6 +55,7 @@ def compare( press: Iterable[str] = (), terminal_size: tuple[int, int] = (80, 24), run_before: Callable[[Pilot], Awaitable[None] | None] | None = None, + force_capture: bool = False, ) -> bool: """ Compare a current screenshot of the app running at app_path, with @@ -69,6 +71,8 @@ def compare( run_before: An arbitrary callable that runs arbitrary code before taking the screenshot. Use this to simulate complex user interactions with the app that cannot be simulated by key presses. + force_capture: True to force enable output capturing. When `headless=True`, output + capturing is disabled. Setting `force_capture=True` overrides this behaviour. Returns: Whether the screenshot matches the snapshot. @@ -92,6 +96,7 @@ def compare( press=press, terminal_size=terminal_size, run_before=run_before, + force_capture=force_capture ) result = snapshot == actual_screenshot From c402f9b0ad84cbf8815fb04ca759dd6326e7ebdf Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 22 Jul 2024 15:44:43 +0100 Subject: [PATCH 2/3] Improve documentation. Add docstring to snapshot report. Improve snapshot report design. Add clickable link to file in snapshot report and associated environment variable. --- README.md | 50 ++- poetry.lock | 354 ++++++++++++++++++++++ pyproject.toml | 6 +- pytest_textual_snapshot.py | 281 +++++++++++++---- resources/snapshot_report_template.jinja2 | 114 ++++--- 5 files changed, 702 insertions(+), 103 deletions(-) create mode 100644 poetry.lock diff --git a/README.md b/README.md index a2096b6..5ac5d0a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ -# pytest-textual-snapshot +# `pytest-textual-snapshot` -Snapshot testing for Textual apps. +A pytest plugin for snapshot testing Textual applications. + +image ## Installation @@ -23,7 +25,7 @@ This is a convenient way to quickly and automatically detect visual regressions ### Running tests -You can run your tests using `pytest` as normal. +You can run your tests using `pytest` as normal. You can use `pytest-xdist` to run your tests in parallel. #### My snapshot test failed, what do I do? @@ -39,7 +41,13 @@ by running `pytest` with the `--snapshot-update` flag. #### Basic usage Inject the `snap_compare` fixture into your test and call -it with the path to the Textual app (the file containing the `App` subclass). +it with an app instance or the path to the Textual app (the file containing the `App` subclass). + +```python +def test_my_app(snap_compare): + app = MyTextualApp() # a *non-running* Textual `App` instance + assert snap_compare(app) +``` ```python def test_something(snap_compare): @@ -54,3 +62,37 @@ Key presses can be simulated before the screenshot is taken. def test_something(snap_compare): assert snap_compare("path/to/app.py", press=["tab", "left", "a"]) ``` + +#### Run code before screenshot + +You can run some code before capturing a screenshot using the `run_before` parameter. + +```python +def test_something(snap_compare): + async def run_before(pilot: Pilot): + await pilot.press("ctrl+p") + # You can run arbitrary code before the screenshot occurs: + await disable_blink_for_active_cursors(pilot) + await pilot.press(*"view") + + assert snap_compare(MyApp(), run_before=run_before) +``` + +#### Customizing the size of the terminal + +If you need to change the size of the terminal (for example to fit in more content or test layout-related code), you can adjust the `terminal_size` parameter. + +```python +def test_another_thing(snap_compare): + assert snap_compare(MyApp(), terminal_size=(80, 34)) +``` + +#### Quickly opening paths in your editor + +If you passed a path to `snap_compare`, you can quickly open the path in your editor by setting the `TEXTUAL_SNAPSHOT_FILE_OPEN_PREFIX` environment variable based on the editor you want to use. Clicking on the path in the snapshot report will open the path in your editor. + +- `file://` - default, most likely opening in your browser +- `code://file/` - opens the path in VS Code +- `cursor://file/` - opens the path in Cursor +- `pycharm://` - opens the path in PyCharm + diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..16f01f6 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,354 @@ +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "jinja2" +version = "3.1.4" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +files = [ + {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, + {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "linkify-it-py" +version = "2.0.3" +description = "Links recognition library with FULL unicode support." +optional = false +python-versions = ">=3.7" +files = [ + {file = "linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048"}, + {file = "linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79"}, +] + +[package.dependencies] +uc-micro-py = "*" + +[package.extras] +benchmark = ["pytest", "pytest-benchmark"] +dev = ["black", "flake8", "isort", "pre-commit", "pyproject-flake8"] +doc = ["myst-parser", "sphinx", "sphinx-book-theme"] +test = ["coverage", "pytest", "pytest-cov"] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.8" +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[package.dependencies] +linkify-it-py = {version = ">=1,<3", optional = true, markers = "extra == \"linkify\""} +mdit-py-plugins = {version = "*", optional = true, markers = "extra == \"plugins\""} +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "markupsafe" +version = "2.1.5" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.7" +files = [ + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, + {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.4.1" +description = "Collection of plugins for markdown-it-py" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mdit_py_plugins-0.4.1-py3-none-any.whl", hash = "sha256:1020dfe4e6bfc2c79fb49ae4e3f5b297f5ccd20f010187acc52af2921e27dc6a"}, + {file = "mdit_py_plugins-0.4.1.tar.gz", hash = "sha256:834b8ac23d1cd60cec703646ffd22ae97b7955a6d596eb1d304be1e251ae499c"}, +] + +[package.dependencies] +markdown-it-py = ">=1.0.0,<4.0.0" + +[package.extras] +code-style = ["pre-commit"] +rtd = ["myst-parser", "sphinx-book-theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "packaging" +version = "24.1" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pygments" +version = "2.18.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, + {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pytest" +version = "8.3.1" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.3.1-py3-none-any.whl", hash = "sha256:e9600ccf4f563976e2c99fa02c7624ab938296551f280835ee6516df8bc4ae8c"}, + {file = "pytest-8.3.1.tar.gz", hash = "sha256:7e8e5c5abd6e93cb1cc151f23e57adc31fcf8cfd2a3ff2da63e23f732de35db6"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "rich" +version = "13.7.1" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"}, + {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" +typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""} + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "syrupy" +version = "4.6.1" +description = "Pytest Snapshot Test Utility" +optional = false +python-versions = ">=3.8.1,<4" +files = [ + {file = "syrupy-4.6.1-py3-none-any.whl", hash = "sha256:203e52f9cb9fa749cf683f29bd68f02c16c3bc7e7e5fe8f2fc59bdfe488ce133"}, + {file = "syrupy-4.6.1.tar.gz", hash = "sha256:37a835c9ce7857eeef86d62145885e10b3cb9615bc6abeb4ce404b3f18e1bb36"}, +] + +[package.dependencies] +pytest = ">=7.0.0,<9.0.0" + +[[package]] +name = "textual" +version = "0.73.0" +description = "Modern Text User Interface framework" +optional = false +python-versions = "<4.0,>=3.8" +files = [ + {file = "textual-0.73.0-py3-none-any.whl", hash = "sha256:4d93d80d203f7fb7ba51828a546e8777019700d529a1b405ceee313dea2edfc2"}, + {file = "textual-0.73.0.tar.gz", hash = "sha256:ccd1e873370577f557dfdf2b3411f2a4f68b57d4365f9d83a00d084afb15f5a6"}, +] + +[package.dependencies] +markdown-it-py = {version = ">=2.1.0", extras = ["linkify", "plugins"]} +rich = ">=13.3.3" +typing-extensions = ">=4.4.0,<5.0.0" + +[package.extras] +syntax = ["tree-sitter (>=0.20.1,<0.21.0)", "tree-sitter-languages (==1.10.2)"] + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[[package]] +name = "uc-micro-py" +version = "1.0.3" +description = "Micro subset of unicode data files for linkify-it-py projects." +optional = false +python-versions = ">=3.7" +files = [ + {file = "uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a"}, + {file = "uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5"}, +] + +[package.extras] +test = ["coverage", "pytest", "pytest-cov"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.8.1" +content-hash = "055bd422780ae403bba47c6d82b6bcc84a729bdf92d54ef0155efcb321f44df8" diff --git a/pyproject.toml b/pyproject.toml index d5a8c7c..0d55fbc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,11 +30,11 @@ classifiers = [ include = ["resources/**/*"] [tool.poetry.dependencies] -python = "^3.6" -pytest = ">=7.0.0" +python = "^3.8.1" +pytest = ">=8.0.0" rich = ">=12.0.0" textual = ">=0.28.0" -syrupy = ">=3.0.0" +syrupy = ">=4.0.0" jinja2 = ">=3.0.0" [tool.poetry.plugins."pytest11"] diff --git a/pytest_textual_snapshot.py b/pytest_textual_snapshot.py index a79fbf3..f973ae1 100644 --- a/pytest_textual_snapshot.py +++ b/pytest_textual_snapshot.py @@ -1,12 +1,17 @@ from __future__ import annotations +import inspect import os +import pickle +import re +import shutil from dataclasses import dataclass from datetime import datetime from operator import attrgetter from os import PathLike from pathlib import Path, PurePath -from typing import Awaitable, Union, List, Optional, Callable, Iterable, TYPE_CHECKING +from tempfile import mkdtemp +from typing import Any, Awaitable, Union, Optional, Callable, Iterable, TYPE_CHECKING import pytest from _pytest.config import ExitCode @@ -16,30 +21,97 @@ from jinja2 import Template from rich.console import Console from syrupy import SnapshotAssertion +from syrupy.extensions.single_file import SingleFileSnapshotExtension, WriteMode +from textual.app import App if TYPE_CHECKING: - from textual.app import App + from _pytest.nodes import Item from textual.pilot import Pilot -TEXTUAL_SNAPSHOT_SVG_KEY = pytest.StashKey[str]() -TEXTUAL_ACTUAL_SVG_KEY = pytest.StashKey[str]() -TEXTUAL_SNAPSHOT_PASS = pytest.StashKey[bool]() + +class SVGImageExtension(SingleFileSnapshotExtension): + _file_extension = "svg" + _write_mode = WriteMode.TEXT + + +class TemporaryDirectory: + """A temporary that survives forking. + + This provides something akin to tempfile.TemporaryDirectory, but this + version is not removed automatically when a process exits. + """ + + def __init__(self, name: str = ""): + if name: + self.name = name + else: + self.name = mkdtemp(None, None, None) + + def cleanup(self): + """Clean up the temporary directory.""" + shutil.rmtree(self.name, ignore_errors=True) + + +@dataclass +class PseudoConsole: + """Something that looks enough like a Console to fill a Jinja2 template.""" + + legacy_windows: bool + size: ConsoleDimensions + + +@dataclass +class PseudoApp: + """Something that looks enough like an App to fill a Jinja2 template. + + This can be pickled OK, whereas the 'real' application involved in a test + may contain unpickleable data. + """ + + console: PseudoConsole + + +def rename_styles(svg: str, suffix: str) -> str: + """Rename style names to prevent clashes when combined in HTML report.""" + return re.sub(r"terminal-(\d+)-r(\d+)", rf"terminal-\1-r\2-{suffix}", svg) def pytest_addoption(parser): parser.addoption( - '--snapshot-report', action='store', default="snapshot_report.html", help='Snapshot test output HTML path.' + "--snapshot-report", + action="store", + default="snapshot_report.html", + help="Snapshot test output HTML path.", ) + def app_stash_key() -> pytest.StashKey: try: return app_stash_key._key except AttributeError: from textual.app import App + app_stash_key._key = pytest.StashKey[App]() return app_stash_key() +def node_to_report_path(node: Item) -> Path: + """Generate a report file name for a test node.""" + tempdir = get_tempdir() + path, _, name = node.reportinfo() + temp = Path(path.parent) + base = [] + while temp != temp.parent and temp.name != "tests": + base.append(temp.name) + temp = temp.parent + parts = [] + if base: + parts.append("_".join(reversed(base))) + parts.append(path.name.replace(".", "_")) + parts.append(name.replace("[", "_").replace("]", "_")) + return Path(tempdir.name) / "_".join(parts) + + @pytest.fixture def snap_compare( snapshot: SnapshotAssertion, request: FixtureRequest @@ -49,13 +121,14 @@ def snap_compare( app with the output of the same app in the past. This is snapshot testing, and it used to catch regressions in output. """ + # Switch so one file per snapshot, stored as plain simple SVG file. + snapshot = snapshot.use_extension(SVGImageExtension) def compare( - app_path: str | PurePath, + app: str | PurePath | App[Any], press: Iterable[str] = (), terminal_size: tuple[int, int] = (80, 24), run_before: Callable[[Pilot], Awaitable[None] | None] | None = None, - force_capture: bool = False, ) -> bool: """ Compare a current screenshot of the app running at app_path, with @@ -64,51 +137,82 @@ def compare( the snapshot on disk will be updated to match the current screenshot. Args: - app_path (str): The path of the app. Relative paths are relative to the location of the - test this function is called from. + app (str): An `App` instance or the path to an App. Relative paths are relative to the location of the + test this function is called from. If the path contains an App, that file should *not* call `App.run` + itself, as this is done automatically by this function. press (Iterable[str]): Key presses to run before taking screenshot. "_" is a short pause. terminal_size (tuple[int, int]): A pair of integers (WIDTH, HEIGHT), representing terminal size. run_before: An arbitrary callable that runs arbitrary code before taking the screenshot. Use this to simulate complex user interactions with the app that cannot be simulated by key presses. - force_capture: True to force enable output capturing. When `headless=True`, output - capturing is disabled. Setting `force_capture=True` overrides this behaviour. Returns: Whether the screenshot matches the snapshot. """ from textual._import_app import import_app + node = request.node - path = Path(app_path) - if path.is_absolute(): - # If the user supplies an absolute path, just use it directly. - app = import_app(str(path.resolve())) + + if isinstance(app, App): + app_instance = app + app_path = "" else: - # If a relative path is supplied by the user, it's relative to the location of the pytest node, - # NOT the location that `pytest` was invoked from. - node_path = node.path.parent - resolved = (node_path / app_path).resolve() - app = import_app(str(resolved)) + path = Path(app) + if path.is_absolute(): + # If the user supplies an absolute path, just use it directly. + app_path = str(path.resolve()) + app_instance = import_app(app_path) + else: + # If a relative path is supplied by the user, it's relative to the location of the pytest node, + # NOT the location that `pytest` was invoked from. + node_path = node.path.parent + resolved = (node_path / app).resolve() + app_path = str(resolved) + app_instance = import_app(app_path) from textual._doc import take_svg_screenshot + actual_screenshot = take_svg_screenshot( - app=app, + app=app_instance, press=press, terminal_size=terminal_size, run_before=run_before, - force_capture=force_capture ) + console = Console(legacy_windows=False, force_terminal=True) + p_app = PseudoApp(PseudoConsole(console.legacy_windows, console.size)) + result = snapshot == actual_screenshot - if result is False: - # The split and join below is a mad hack, sorry... - node.stash[TEXTUAL_SNAPSHOT_SVG_KEY] = "\n".join( - str(snapshot).splitlines()[1:-1] - ) - node.stash[TEXTUAL_ACTUAL_SVG_KEY] = actual_screenshot - node.stash[app_stash_key()] = app - else: - node.stash[TEXTUAL_SNAPSHOT_PASS] = True + # This code must come below the comparison above, as it uses data generated by the comparison. + execution_index = ( + snapshot._custom_index + and snapshot._execution_name_index.get(snapshot._custom_index) + ) or snapshot.num_executions - 1 + assertion_result = snapshot.executions.get(execution_index) + + snapshot_exists = ( + execution_index in snapshot.executions + and assertion_result + and assertion_result.final_data is not None + ) + + expected_svg_text = str(snapshot) + full_path, line_number, name = node.reportinfo() + + data = ( + result, + expected_svg_text, + actual_screenshot, + p_app, + full_path, + line_number, + name, + inspect.getdoc(node.function) or "", + app_path, + snapshot_exists, + ) + data_path = node_to_report_path(request.node) + data_path.write_bytes(pickle.dumps(data)) return result @@ -126,8 +230,35 @@ class SvgSnapshotDiff: test_name: str path: PathLike line_number: int - app: App - environment: dict + app: App[Any] + """The app instance which was tested.""" + environment: dict[str, str] + """The environment variables from the host which ran the test.""" + docstring: str + """If the underlying test functions contains a docstring, we'll include it in the test report.""" + app_path: Path | None + """If the app was loaded from a path, we'll include that path in the test report for easier access. + This will be None if an App instance was directly passed to `snap_compare`.""" + snapshot_exists: bool + """True if the there was a snapshot available to compare the test output to, otherwise False.""" + + +def pytest_sessionstart( + session: Session, +) -> None: + """Set up a temporary directory to store snapshots. + + The temporary directory name is stored in an environment vairable so that + pytest-xdist worker child processes can retrieve it. + """ + if os.environ.get("PYTEST_XDIST_WORKER") is None: + tempdir = TemporaryDirectory() + os.environ["TEXTUAL_SNAPSHOT_TEMPDIR"] = tempdir.name + + +def get_tempdir(): + """Get the TemporaryDirectory.""" + return TemporaryDirectory(os.environ["TEXTUAL_SNAPSHOT_TEMPDIR"]) def pytest_sessionfinish( @@ -137,30 +268,60 @@ def pytest_sessionfinish( """Called after whole test run finished, right before returning the exit status to the system. Generates the snapshot report and writes it to disk. """ - diffs: List[SvgSnapshotDiff] = [] - num_snapshots_passing = 0 - - for item in session.items: - # Grab the data our fixture attached to the pytest node - num_snapshots_passing += int(item.stash.get(TEXTUAL_SNAPSHOT_PASS, False)) - snapshot_svg = item.stash.get(TEXTUAL_SNAPSHOT_SVG_KEY, None) - actual_svg = item.stash.get(TEXTUAL_ACTUAL_SVG_KEY, None) - app = item.stash.get(app_stash_key(), None) - - if app: - path, line_index, name = item.reportinfo() + if os.environ.get("PYTEST_XDIST_WORKER") is None: + tempdir = get_tempdir() + diffs, num_snapshots_passing = retrieve_svg_diffs(tempdir) + save_svg_diffs(diffs, session, num_snapshots_passing) + tempdir.cleanup() + + +def retrieve_svg_diffs( + tempdir: TemporaryDirectory, +) -> tuple[list[SvgSnapshotDiff], int]: + """Retrieve snapshot diffs from the temporary directory.""" + diffs: list[SvgSnapshotDiff] = [] + pass_count = 0 + + n = 0 + for data_path in Path(tempdir.name).iterdir(): + ( + passed, + expect_svg_text, + svg_text, + app, + full_path, + line_index, + name, + docstring, + app_path, + snapshot_exists, + ) = pickle.loads(data_path.read_bytes()) + pass_count += 1 if passed else 0 + if not passed: + n += 1 diffs.append( SvgSnapshotDiff( - snapshot=str(snapshot_svg), - actual=str(actual_svg), + snapshot=rename_styles(str(expect_svg_text), f"exp{n}"), + actual=rename_styles(svg_text, f"act{n}"), test_name=name, - path=path, + path=full_path, line_number=line_index + 1, app=app, environment=dict(os.environ), + docstring=docstring, + app_path=Path(app_path) if app_path else None, + snapshot_exists=snapshot_exists, ) ) + return diffs, pass_count + +def save_svg_diffs( + diffs: list[SvgSnapshotDiff], + session: Session, + num_snapshots_passing: int, +) -> None: + """Save any detected differences to an HTML formatted report.""" if diffs: diff_sort_key = attrgetter("test_name") diffs = sorted(diffs, key=diff_sort_key) @@ -187,6 +348,7 @@ def pytest_sessionfinish( fail_percentage=100 * (num_fails / max(num_snapshot_tests, 1)), num_snapshot_tests=num_snapshot_tests, now=datetime.utcnow(), + file_open_prefix=os.getenv("TEXTUAL_SNAPSHOT_FILE_OPEN_PREFIX", "file://"), ) with open(snapshot_report_path, "w+", encoding="utf-8") as snapshot_file: snapshot_file.write(rendered_report) @@ -203,13 +365,14 @@ def pytest_terminal_summary( """Add a section to terminal summary reporting. Displays the link to the snapshot report that was generated in a prior hook. """ - diffs = getattr(config, "_textual_snapshots", None) - console = Console(legacy_windows=False, force_terminal=True) - if diffs: - snapshot_report_location = config._textual_snapshot_html_report - console.print("[b red]Textual Snapshot Report", style="red") - console.print( - f"\n[black on red]{len(diffs)} mismatched snapshots[/]\n" - f"\n[b]View the [link=file://{snapshot_report_location}]failure report[/].\n" - ) - console.print(f"[dim]{snapshot_report_location}\n") + if os.environ.get("PYTEST_XDIST_WORKER") is None: + diffs = getattr(config, "_textual_snapshots", None) + console = Console(legacy_windows=False, force_terminal=True) + if diffs: + snapshot_report_location = config._textual_snapshot_html_report + console.print("[b red]Textual Snapshot Report", style="red") + console.print( + f"\n[black on red]{len(diffs)} mismatched snapshots[/]\n" + f"\n[b]View the [link=file://{snapshot_report_location}]failure report[/].\n" + ) + console.print(f"[dim]{snapshot_report_location}\n") diff --git a/resources/snapshot_report_template.jinja2 b/resources/snapshot_report_template.jinja2 index 4dcba87..003901d 100644 --- a/resources/snapshot_report_template.jinja2 +++ b/resources/snapshot_report_template.jinja2 @@ -7,6 +7,12 @@ - +
-
-
-

+
+
+ - Showing diffs for {{ fails }} mismatched snapshot(s) +

-
- +
+ {{ diffs | length }} snapshots changed - ยท - + {{ passes }} snapshots matched
-
-
-
-
{% for diff in diffs %}
-
+
{{ diff.test_name }} @@ -61,8 +87,8 @@ {{ diff.path }}:{{ diff.line_number }} - {% if diff.snapshot != "" %} -
+ {% if diff.snapshot_exists %} +