diff --git a/plugin_test.py b/plugin_test.py index caff8b5..e3e2549 100644 --- a/plugin_test.py +++ b/plugin_test.py @@ -41,7 +41,7 @@ def test_fail(): testdir.monkeypatch.setenv('GITHUB_ACTIONS', 'true') result = testdir.runpytest_subprocess() result.stdout.fnmatch_lines([ - '::error file=test_annotation_fail.py,line=4::def test_fail():*', + '::error file=test_annotation_fail.py,line=5::test_fail*assert 0*', ]) def test_annotation_exception(testdir): @@ -58,7 +58,7 @@ def test_fail(): testdir.monkeypatch.setenv('GITHUB_ACTIONS', 'true') result = testdir.runpytest_subprocess() result.stdout.fnmatch_lines([ - '::error file=test_annotation_exception.py,line=4::def test_fail():*', + '::error file=test_annotation_exception.py,line=5::test_fail*oops*', ]) def test_annotation_fail_disabled_outside_workflow(testdir): @@ -73,7 +73,7 @@ def test_fail(): ) testdir.monkeypatch.setenv('GITHUB_ACTIONS', '') result = testdir.runpytest_subprocess() - no_fnmatch_line(result, '::error file=test_annotation_fail_disabled_outside_workflow.py') + no_fnmatch_line(result, '::error file=test_annotation_fail_disabled_outside_workflow.py*') def test_annotation_fail_cwd(testdir): testdir.makepyfile( @@ -91,5 +91,87 @@ def test_fail(): testdir.makefile('.ini', pytest='[pytest]\ntestpaths=..') result = testdir.runpytest_subprocess('--rootdir=foo') result.stdout.fnmatch_lines([ - '::error file=test_annotation_fail_cwd.py,line=4::def test_fail():*', + '::error file=test_annotation_fail_cwd.py,line=5::test_fail*assert 0*', ]) + +def test_annotation_long(testdir): + testdir.makepyfile( + ''' + import pytest + pytest_plugins = 'pytest_github_actions_annotate_failures' + + def f(x): + return x + + def test_fail(): + x = 1 + x += 1 + x += 1 + x += 1 + x += 1 + x += 1 + x += 1 + x += 1 + + assert f(x) == 3 + ''' + ) + testdir.monkeypatch.setenv('GITHUB_ACTIONS', 'true') + result = testdir.runpytest_subprocess() + result.stdout.fnmatch_lines([ + '::error file=test_annotation_long.py,line=17::test_fail*assert 8 == 3*where 8 = f(8)*', + ]) + no_fnmatch_line(result, '::*assert x += 1*') + +def test_class_method(testdir): + testdir.makepyfile( + ''' + import pytest + pytest_plugins = 'pytest_github_actions_annotate_failures' + + class TestClass(object): + def test_method(self): + x = 1 + assert x == 2 + ''' + ) + testdir.monkeypatch.setenv('GITHUB_ACTIONS', 'true') + result = testdir.runpytest_subprocess() + result.stdout.fnmatch_lines([ + '::error file=test_class_method.py,line=7::TestClass.test_method*assert 1 == 2*', + ]) + no_fnmatch_line(result, '::*x = 1*') + + + + +def test_annotation_param(testdir): + testdir.makepyfile( + ''' + import pytest + pytest_plugins = 'pytest_github_actions_annotate_failures' + + @pytest.mark.parametrize("a", [1]) + @pytest.mark.parametrize("b", [2], ids=["other"]) + def test_param(a, b): + + a += 1 + b += 1 + + assert a == b + ''' + ) + testdir.monkeypatch.setenv('GITHUB_ACTIONS', 'true') + result = testdir.runpytest_subprocess() + result.stdout.fnmatch_lines([ + '::error file=test_annotation_param.py,line=11::test_param?other?1*assert 2 == 3*', + ]) + +# Debugging / development tip: +# Add a breakpoint() to the place you are going to check, +# uncomment this example, and run it with: +# GITHUB_ACTIONS=true pytest -k test_example +# def test_example(): +# x = 3 +# y = 4 +# assert x == y diff --git a/pytest_github_actions_annotate_failures/plugin.py b/pytest_github_actions_annotate_failures/plugin.py index 161bf79..77fba22 100644 --- a/pytest_github_actions_annotate_failures/plugin.py +++ b/pytest_github_actions_annotate_failures/plugin.py @@ -1,46 +1,71 @@ from __future__ import print_function import os +import pytest +from collections import OrderedDict + +# Reference: +# https://docs.pytest.org/en/latest/writing_plugins.html#hookwrapper-executing-around-other-hooks +# https://docs.pytest.org/en/latest/writing_plugins.html#hook-function-ordering-call-example +# https://docs.pytest.org/en/stable/reference.html#pytest.hookspec.pytest_runtest_makereport +# +# Inspired by: +# https://github.com/pytest-dev/pytest/blob/master/src/_pytest/terminal.py + +@pytest.hookimpl(tryfirst=True, hookwrapper=True) +def pytest_runtest_makereport(item, call): + # execute all other hooks to obtain the report object + outcome = yield + report = outcome.get_result() -def pytest_runtest_logreport(report): # enable only in a workflow of GitHub Actions # ref: https://help.github.com/en/actions/configuring-and-managing-workflows/using-environment-variables#default-environment-variables if os.environ.get('GITHUB_ACTIONS') != 'true': return - if report.outcome != 'failed': - return + if report.when == "call" and report.failed: + # collect information to be annotated + filesystempath, lineno, _ = report.location + + # try to convert to absolute path in GitHub Actions + workspace = os.environ.get('GITHUB_WORKSPACE') + if workspace: + full_path = os.path.abspath(filesystempath) + rel_path = os.path.relpath(full_path, workspace) + if not rel_path.startswith('..'): + filesystempath = rel_path - # collect information to be annotated - filesystempath, lineno, _ = report.location + # 0-index to 1-index + lineno += 1 - # try to convert to absolute path in GitHub Actions - workspace = os.environ.get('GITHUB_WORKSPACE') - if workspace: - full_path = os.path.abspath(filesystempath) - rel_path = os.path.relpath(full_path, workspace) - if not rel_path.startswith('..'): - filesystempath = rel_path - # 0-index to 1-index - lineno += 1 + # get the name of the current failed test, with parametrize info + longrepr = report.head_line or item.name - longrepr = str(report.longrepr) + # get the error message and line number from the actual error + try: + longrepr += "\n\n" + report.longrepr.reprcrash.message + lineno = report.longrepr.reprcrash.lineno + + except AttributeError: + pass + + print(_error_workflow_command(filesystempath, lineno, longrepr)) - print(_error_workflow_command(filesystempath, lineno, longrepr)) def _error_workflow_command(filesystempath, lineno, longrepr): - if lineno is None: - if longrepr is None: - return '\n::error file={}'.format(filesystempath) - else: - longrepr = _escape(longrepr) - return '\n::error file={}::{}'.format(filesystempath, longrepr) + # Build collection of arguments. Ordering is strict for easy testing + details_dict = OrderedDict() + details_dict["file"] = filesystempath + if lineno is not None: + details_dict["line"] = lineno + + details = ",".join("{}={}".format(k,v) for k,v in details_dict.items()) + + if longrepr is None: + return '\n::error {}'.format(details) else: - if longrepr is None: - return '\n::error file={},line={}'.format(filesystempath, lineno) - else: - longrepr = _escape(longrepr) - return '\n::error file={},line={}::{}'.format(filesystempath, lineno, longrepr) + longrepr = _escape(longrepr) + return '\n::error {}::{}'.format(details, longrepr) def _escape(s): return s.replace('%', '%25').replace('\r', '%0D').replace('\n', '%0A')