From 1af4df94741b817499a312f5707ab743f49e0230 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Br=C3=A4nnlund?= Date: Sat, 1 Apr 2023 11:55:50 +0200 Subject: [PATCH] fix: Collapsed should support All and none (#605) --- docs/user_guide.rst | 14 ++- src/pytest_html/nextgen.py | 4 + src/pytest_html/plugin.py | 4 +- src/pytest_html/scripts/datamanager.js | 2 +- src/pytest_html/scripts/main.js | 12 +-- src/pytest_html/scripts/storage.js | 36 +++++-- testing/test_integration.py | 124 +++++++++++++++++++++++-- testing/unittest.js | 66 +++++++++++++ 8 files changed, 227 insertions(+), 35 deletions(-) diff --git a/docs/user_guide.rst b/docs/user_guide.rst index ca0fb7e9..d07e34bb 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -248,15 +248,19 @@ Auto Collapsing Table Rows By default, all rows in the **Results** table will be expanded except those that have :code:`Passed`. -This behavior can be customized either with a query parameter: :code:`?collapsed=Passed,XFailed,Skipped` -or by setting the :code:`render_collapsed` in a configuration file (pytest.ini, setup.cfg, etc). +This behavior can be customized with a query parameter: :code:`?collapsed=Passed,XFailed,Skipped`. +If you want all rows to be collapsed you can pass :code:`?collapsed=All`. +By setting the query parameter to empty string :code:`?collapsed=""` **none** of the rows will be collapsed. + +Note that the query parameter is case insensitive, so passing :code:`PASSED` and :code:`passed` has the same effect. + +You can also set the collapsed behaviour by setting the :code:`render_collapsed` in a configuration file (pytest.ini, setup.cfg, etc). +Note that the query parameter takes precedence. .. code-block:: ini [pytest] - render_collapsed = True - -**NOTE:** Setting :code:`render_collapsed` will, unlike the query parameter, affect all statuses. + render_collapsed = failed,error Controlling Test Result Visibility Via Query Params ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/pytest_html/nextgen.py b/src/pytest_html/nextgen.py index 6c04ffa5..8a230689 100644 --- a/src/pytest_html/nextgen.py +++ b/src/pytest_html/nextgen.py @@ -71,6 +71,10 @@ def __init__(self, title, config): "additionalSummary": defaultdict(list), } + collapsed = config.getini("render_collapsed") + if collapsed: + self.set_data("collapsed", collapsed.split(",")) + @property def title(self): return self._data["title"] diff --git a/src/pytest_html/plugin.py b/src/pytest_html/plugin.py index 4aa3f0d3..0d025c67 100644 --- a/src/pytest_html/plugin.py +++ b/src/pytest_html/plugin.py @@ -44,8 +44,8 @@ def pytest_addoption(parser): ) parser.addini( "render_collapsed", - type="bool", - default=False, + type="string", + default="", help="Open the report with all rows collapsed. Useful for very large reports", ) parser.addini( diff --git a/src/pytest_html/scripts/datamanager.js b/src/pytest_html/scripts/datamanager.js index aa9f65c5..25c2c3f8 100644 --- a/src/pytest_html/scripts/datamanager.js +++ b/src/pytest_html/scripts/datamanager.js @@ -2,7 +2,7 @@ const { getCollapsedCategory } = require('./storage.js') class DataManager { setManager(data) { - const collapsedCategories = [...getCollapsedCategory(), 'passed'] + const collapsedCategories = [...getCollapsedCategory(data.collapsed)] const dataBlob = { ...data, tests: Object.values(data.tests).flat().map((test, index) => ({ ...test, id: `test_${index}`, diff --git a/src/pytest_html/scripts/main.js b/src/pytest_html/scripts/main.js index b6d4ef30..b737285e 100644 --- a/src/pytest_html/scripts/main.js +++ b/src/pytest_html/scripts/main.js @@ -3,7 +3,7 @@ const { dom, findAll } = require('./dom.js') const { manager } = require('./datamanager.js') const { doSort } = require('./sort.js') const { doFilter } = require('./filter.js') -const { getVisible } = require('./storage.js') +const { getVisible, possibleResults } = require('./storage.js') const removeChildren = (node) => { while (node.firstChild) { @@ -61,16 +61,6 @@ const renderContent = (tests) => { } const renderDerived = (tests, collectedItems, isFinished) => { - const possibleResults = [ - { result: 'passed', label: 'Passed' }, - { result: 'skipped', label: 'Skipped' }, - { result: 'failed', label: 'Failed' }, - { result: 'error', label: 'Errors' }, - { result: 'xfailed', label: 'Unexpected failures' }, - { result: 'xpassed', label: 'Unexpected passes' }, - { result: 'rerun', label: 'Reruns' }, - ] - const currentFilter = getVisible() possibleResults.forEach(({ result, label }) => { const count = tests.filter((test) => test.result.toLowerCase() === result).length diff --git a/src/pytest_html/scripts/storage.js b/src/pytest_html/scripts/storage.js index 5703b013..7033ee48 100644 --- a/src/pytest_html/scripts/storage.js +++ b/src/pytest_html/scripts/storage.js @@ -1,4 +1,13 @@ -const possibleFilters = ['passed', 'skipped', 'failed', 'error', 'xfailed', 'xpassed', 'rerun'] +const possibleResults = [ + { result: 'passed', label: 'Passed' }, + { result: 'skipped', label: 'Skipped' }, + { result: 'failed', label: 'Failed' }, + { result: 'error', label: 'Errors' }, + { result: 'xfailed', label: 'Unexpected failures' }, + { result: 'xpassed', label: 'Unexpected passes' }, + { result: 'rerun', label: 'Reruns' }, +] +const possibleFilters = possibleResults.map((item) => item.result) const getVisible = () => { const url = new URL(window.location.href) @@ -49,16 +58,29 @@ const setSort = (type) => { history.pushState({}, null, unescape(url.href)) } -const getCollapsedCategory = () => { - let categotries +const getCollapsedCategory = (config) => { + let categories if (typeof window !== 'undefined') { const url = new URL(window.location.href) const collapsedItems = new URLSearchParams(url.search).get('collapsed') - categotries = collapsedItems?.split(',') || [] + switch (true) { + case collapsedItems === null: + categories = config || ['passed']; + break; + case collapsedItems?.length === 0 || /^["']{2}$/.test(collapsedItems): + categories = []; + break; + case /^all$/.test(collapsedItems): + categories = [...possibleFilters]; + break; + default: + categories = collapsedItems?.split(',').map(item => item.toLowerCase()) || []; + break; + } } else { - categotries = [] + categories = [] } - return categotries + return categories } const getSortDirection = () => JSON.parse(sessionStorage.getItem('sortAsc')) @@ -75,4 +97,6 @@ module.exports = { setSort, setSortDirection, getCollapsedCategory, + possibleFilters, + possibleResults, } diff --git a/testing/test_integration.py b/testing/test_integration.py index 1321d7ca..b94b70ee 100644 --- a/testing/test_integration.py +++ b/testing/test_integration.py @@ -4,6 +4,7 @@ import os import random import re +import urllib.parse from base64 import b64encode from pathlib import Path @@ -26,9 +27,16 @@ } -def run(pytester, path="report.html", *args): +def run(pytester, path="report.html", cmd_flags=None, query_params=None): + if cmd_flags is None: + cmd_flags = [] + + if query_params is None: + query_params = {} + query_params = urllib.parse.urlencode(query_params) + path = pytester.path.joinpath(path) - pytester.runpytest("--html", path, *args) + pytester.runpytest("--html", path, *cmd_flags) chrome_options = webdriver.ChromeOptions() if os.environ.get("CI", False): @@ -48,7 +56,7 @@ def run(pytester, path="report.html", *args): continue # End workaround - driver.get(f"file:///reports{path}") + driver.get(f"file:///reports{path}?{query_params}") return BeautifulSoup(driver.page_source, "html.parser") finally: driver.quit() @@ -91,6 +99,10 @@ def get_text(page, selector): return get_element(page, selector).string +def is_collapsed(page, test_name): + return get_element(page, f".summary tbody[id$='{test_name}'] .expander") + + def get_log(page, test_id=None): # TODO(jim) move to get_text (use .contents) if test_id: @@ -267,7 +279,7 @@ def pytest_html_report_title(report): def test_resources_inline_css(self, pytester): pytester.makepyfile("def test_pass(): pass") - page = run(pytester, "report.html", "--self-contained-html") + page = run(pytester, cmd_flags=["--self-contained-html"]) content = file_content() @@ -349,7 +361,7 @@ def pytest_runtest_makereport(item, call): ) pytester.makepyfile("def test_pass(): pass") - page = run(pytester, "report.html", "--self-contained-html") + page = run(pytester, cmd_flags=["--self-contained-html"]) element = page.select_one(".summary a[class='col-links__extra text']") assert_that(element.string).is_equal_to("Text") @@ -374,7 +386,7 @@ def pytest_runtest_makereport(item, call): ) pytester.makepyfile("def test_pass(): pass") - page = run(pytester, "report.html", "--self-contained-html") + page = run(pytester, cmd_flags=["--self-contained-html"]) content_str = json.dumps(content) data = b64encode(content_str.encode("utf-8")).decode("ascii") @@ -435,7 +447,7 @@ def pytest_runtest_makereport(item, call): """ ) pytester.makepyfile("def test_pass(): pass") - page = run(pytester, "report.html", "--self-contained-html") + page = run(pytester, cmd_flags=["--self-contained-html"]) # element = page.select_one(".summary a[class='col-links__extra image']") src = f"data:{mime_type};base64,{data}" @@ -463,7 +475,7 @@ def pytest_runtest_makereport(item, call): """ ) pytester.makepyfile("def test_pass(): pass") - page = run(pytester, "report.html", "--self-contained-html") + page = run(pytester, cmd_flags=["--self-contained-html"]) # element = page.select_one(".summary a[class='col-links__extra video']") src = f"data:{mime_type};base64,{data}" @@ -477,7 +489,7 @@ def pytest_runtest_makereport(item, call): def test_xdist(self, pytester): pytester.makepyfile("def test_xdist(): pass") - page = run(pytester, "report.html", "-n1") + page = run(pytester, cmd_flags=["-n1"]) assert_results(page, passed=1) def test_results_table_hook_insert(self, pytester): @@ -552,7 +564,7 @@ def test_streams(setup): assert True """ ) - page = run(pytester, "report.html", no_capture) + page = run(pytester, "report.html", cmd_flags=[no_capture]) assert_results(page, passed=1) log = get_log(page) @@ -657,3 +669,95 @@ def test_no_log(self, test_file, pytester): assert_that(log).contains("No log output captured.") for when in ["setup", "test", "teardown"]: assert_that(log).does_not_match(self.LOG_LINE_REGEX.format(when)) + + +class TestCollapsedQueryParam: + @pytest.fixture + def test_file(self): + return """ + import pytest + @pytest.fixture + def setup(): + error + + def test_error(setup): + assert True + + def test_pass(): + assert True + + def test_fail(): + assert False + """ + + def test_default(self, pytester, test_file): + pytester.makepyfile(test_file) + page = run(pytester) + assert_results(page, passed=1, failed=1, error=1) + + assert_that(is_collapsed(page, "test_pass")).is_true() + assert_that(is_collapsed(page, "test_fail")).is_false() + assert_that(is_collapsed(page, "test_error::setup")).is_false() + + @pytest.mark.parametrize("param", ["failed,error", "FAILED,eRRoR"]) + def test_specified(self, pytester, test_file, param): + pytester.makepyfile(test_file) + page = run(pytester, query_params={"collapsed": param}) + assert_results(page, passed=1, failed=1, error=1) + + assert_that(is_collapsed(page, "test_pass")).is_false() + assert_that(is_collapsed(page, "test_fail")).is_true() + assert_that(is_collapsed(page, "test_error::setup")).is_true() + + def test_all(self, pytester, test_file): + pytester.makepyfile(test_file) + page = run(pytester, query_params={"collapsed": "all"}) + assert_results(page, passed=1, failed=1, error=1) + + for test_name in ["test_pass", "test_fail", "test_error::setup"]: + assert_that(is_collapsed(page, test_name)).is_true() + + @pytest.mark.parametrize("param", ["", 'collapsed=""', "collapsed=''"]) + def test_falsy(self, pytester, test_file, param): + pytester.makepyfile(test_file) + page = run(pytester, query_params={"collapsed": param}) + assert_results(page, passed=1, failed=1, error=1) + + assert_that(is_collapsed(page, "test_pass")).is_false() + assert_that(is_collapsed(page, "test_fail")).is_false() + assert_that(is_collapsed(page, "test_error::setup")).is_false() + + def test_render_collapsed(self, pytester, test_file): + pytester.makeini( + """ + [pytest] + render_collapsed = failed,error + """ + ) + pytester.makepyfile(test_file) + page = run(pytester) + assert_results(page, passed=1, failed=1, error=1) + + assert_that(is_collapsed(page, "test_pass")).is_false() + assert_that(is_collapsed(page, "test_fail")).is_true() + assert_that(is_collapsed(page, "test_error::setup")).is_true() + + def test_render_collapsed_precedence(self, pytester, test_file): + pytester.makeini( + """ + [pytest] + render_collapsed = failed,error + """ + ) + test_file += """ + def test_skip(): + pytest.skip('meh') + """ + pytester.makepyfile(test_file) + page = run(pytester, query_params={"collapsed": "skipped"}) + assert_results(page, passed=1, failed=1, error=1, skipped=1) + + assert_that(is_collapsed(page, "test_pass")).is_false() + assert_that(is_collapsed(page, "test_fail")).is_false() + assert_that(is_collapsed(page, "test_error::setup")).is_false() + assert_that(is_collapsed(page, "test_skip")).is_true() diff --git a/testing/unittest.js b/testing/unittest.js index 24d92fd6..0b23171f 100644 --- a/testing/unittest.js +++ b/testing/unittest.js @@ -156,3 +156,69 @@ describe('utils tests', () => { }) }) }) + +describe('Storage tests', () => { + describe('getCollapsedCategory', () => { + let originalWindow + const mockWindow = (queryParam) => { + const mock = { + location: { + href: `https://example.com/page?${queryParam}` + } + } + originalWindow = global.window + global.window = mock + } + after(() => global.window = originalWindow) + + it('collapses passed by default', () => { + mockWindow() + const collapsedItems = storageModule.getCollapsedCategory() + expect(collapsedItems).to.eql(['passed']) + }) + + it('collapses specified outcomes', () => { + mockWindow('collapsed=failed,error') + const collapsedItems = storageModule.getCollapsedCategory() + expect(collapsedItems).to.eql(['failed', 'error']) + }) + + it('collapses all', () => { + mockWindow('collapsed=all') + const collapsedItems = storageModule.getCollapsedCategory() + expect(collapsedItems).to.eql(storageModule.possibleFilters) + }) + + it('handles case insensitive params', () => { + mockWindow('collapsed=fAiLeD,ERROR,passed') + const collapsedItems = storageModule.getCollapsedCategory() + expect(collapsedItems).to.eql(['failed', 'error', 'passed']) + }) + + it('handles python config', () => { + mockWindow() + const collapsedItems = storageModule.getCollapsedCategory(['failed', 'error']) + expect(collapsedItems).to.eql(['failed', 'error']) + }) + + it('handles python config precedence', () => { + mockWindow('collapsed=xpassed,xfailed') + const collapsedItems = storageModule.getCollapsedCategory(['failed', 'error']) + expect(collapsedItems).to.eql(['xpassed', 'xfailed']) + }) + + const falsy = [ + { param: 'collapsed' }, + { param: 'collapsed=' }, + { param: 'collapsed=""' }, + { param: 'collapsed=\'\'' } + ] + falsy.forEach(({param}) => { + it(`collapses none with ${param}`, () => { + mockWindow(param) + const collapsedItems = storageModule.getCollapsedCategory() + expect(collapsedItems).to.be.empty + }) + }) + }) +})