Skip to content

Support custom statuses #3869

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion atest/resources/TestCheckerLibrary.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ def _check_test_status(self, test, status=None, message=None):
test.exp_status = status
if message is not None:
test.exp_message = message
if test.exp_status != test.status:
if test.exp_status != test.status and test.status in ['PASS', 'FAIL', 'SKIP']:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change looks risky. Doesn't it mean that custom statuses aren't validated at all?

if test.exp_status == 'PASS':
if test.status == 'FAIL':
msg = ("Test '%s' was expected to PASS but it FAILED.\n\n"
Expand Down
37 changes: 37 additions & 0 deletions atest/robot/output/custom_statuses.robot
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
*** Settings ***
Suite Setup Run With Custom Statuses
Resource atest_resource.robot

*** Variables ***
${ADDSTATUS} --AddStatus WIP:PASS:wip:blue --addstatus KNOWN-ISSUE:FAIL:known-*:purple --ADDSTATUS NON-CRITICAL:SKIP:non-criticalNOTwip:pink --log log.html
${ERROR} [ ERROR ] Invalid format for option '--addstatus'. Expected 'NEWSTATUS:OLDSTATUS:TAGPATTERN:COLOR' but got 'invalid'.${USAGE TIP}\n

*** Test Cases ***
Check statuses
Should Be Equal ${TC1.status} WIP
Log should have correct custom status color WIP blue
Should Be Equal ${TC2.status} KNOWN-ISSUE
Log should have correct custom status color KNOWN-ISSUE purple
Should Be Equal ${TC3.status} NON-CRITICAL
Log should have correct custom status color NON-CRITICAL pink

Invalid usage
Run Tests Without Processing Output --AddStatus invalid output/custom_statuses.robot
Stderr Should Be Equal To ${ERROR}

*** Keywords ***
Run With Custom Statuses
Run Tests ${ADDSTATUS} output/custom_statuses.robot
${LOG} = Get File ${OUTDIR}/log.html
Set Suite Variable $LOG
${TC1} = Check Test Case Pass message=Pass
Set Suite Variable $TC1
${TC2} = Check Test Case Fail message=Fail
Set Suite Variable $TC2
${TC3} = Check Test Case Skip message=Skip
Set Suite Variable $TC3

Log should have correct custom status color
[Arguments] ${status} ${color}
Log ${LOG}
Should Contain ${LOG} "${status}":"${color}"
12 changes: 12 additions & 0 deletions atest/testdata/output/custom_statuses.robot
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
*** Test Cases ***
Pass
[Tags] wip
Pass Execution Pass

Fail
[Tags] known-issue
Fail Fail

Skip
[Tags] non-critical
Skip Skip
3 changes: 1 addition & 2 deletions doc/userguide/src/ExecutingTestCases/log_failed.html
Original file line number Diff line number Diff line change
Expand Up @@ -1322,7 +1322,6 @@
var idCounter = 0;
var _statistics = null;
var LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'FAIL', 'SKIP'];
var STATUSES = ['FAIL', 'PASS', 'NOT_RUN', 'SKIP'];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to change examples under doc at all. These changes just add distraction to the PR.

var KEYWORDS = ['KEYWORD', 'SETUP', 'TEARDOWN', 'FOR', 'VAR', 'IF', 'ELSE IF', 'ELSE'];
function addElement(elem) {
if (!elem.id)
Expand Down Expand Up @@ -1350,7 +1349,7 @@
strings.get(element[3])));
}
function parseStatus(stats) {
return STATUSES[stats[0]];
return stats[0];
}
function childCreator(parent, childType) {
return function (elem, strings, index) {
Expand Down
3 changes: 1 addition & 2 deletions doc/userguide/src/ExecutingTestCases/log_passed.html
Original file line number Diff line number Diff line change
Expand Up @@ -1322,7 +1322,6 @@
var idCounter = 0;
var _statistics = null;
var LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'FAIL', 'SKIP'];
var STATUSES = ['FAIL', 'PASS', 'NOT_RUN', 'SKIP'];
var KEYWORDS = ['KEYWORD', 'SETUP', 'TEARDOWN', 'FOR', 'VAR', 'IF', 'ELSE IF', 'ELSE'];
function addElement(elem) {
if (!elem.id)
Expand Down Expand Up @@ -1350,7 +1349,7 @@
strings.get(element[3])));
}
function parseStatus(stats) {
return STATUSES[stats[0]];
return stats[0];
}
function childCreator(parent, childType) {
return function (elem, strings, index) {
Expand Down
3 changes: 1 addition & 2 deletions doc/userguide/src/ExecutingTestCases/log_skipped.html
Original file line number Diff line number Diff line change
Expand Up @@ -1322,7 +1322,6 @@
var idCounter = 0;
var _statistics = null;
var LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'FAIL', 'SKIP'];
var STATUSES = ['FAIL', 'PASS', 'NOT_RUN', 'SKIP'];
var KEYWORDS = ['KEYWORD', 'SETUP', 'TEARDOWN', 'FOR', 'VAR', 'IF', 'ELSE IF', 'ELSE'];
function addElement(elem) {
if (!elem.id)
Expand Down Expand Up @@ -1350,7 +1349,7 @@
strings.get(element[3])));
}
function parseStatus(stats) {
return STATUSES[stats[0]];
return stats[0];
}
function childCreator(parent, childType) {
return function (elem, strings, index) {
Expand Down
3 changes: 1 addition & 2 deletions doc/userguide/src/ExecutingTestCases/report_failed.html
Original file line number Diff line number Diff line change
Expand Up @@ -917,7 +917,6 @@
var idCounter = 0;
var _statistics = null;
var LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'FAIL', 'ERROR'];
var STATUSES = ['FAIL', 'PASS', 'NOT_RUN'];
var KEYWORDS = ['KEYWORD', 'SETUP', 'TEARDOWN', 'FOR', 'VAR'];

function addElement(elem) {
Expand Down Expand Up @@ -954,7 +953,7 @@
function parseStatus(stats, strings, parentSuiteTeardownFailed) {
if (parentSuiteTeardownFailed)
return 'FAIL';
return STATUSES[stats[0]];
return stats[0];
}

function last(items) {
Expand Down
3 changes: 1 addition & 2 deletions doc/userguide/src/ExecutingTestCases/report_passed.html
Original file line number Diff line number Diff line change
Expand Up @@ -1126,7 +1126,6 @@
var idCounter = 0;
var _statistics = null;
var LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'FAIL', 'SKIP'];
var STATUSES = ['FAIL', 'PASS', 'NOT_RUN', 'SKIP'];
var KEYWORDS = ['KEYWORD', 'SETUP', 'TEARDOWN', 'FOR', 'VAR', 'IF', 'ELSE IF', 'ELSE'];
function addElement(elem) {
if (!elem.id)
Expand Down Expand Up @@ -1154,7 +1153,7 @@
strings.get(element[3])));
}
function parseStatus(stats) {
return STATUSES[stats[0]];
return stats[0];
}
function childCreator(parent, childType) {
return function (elem, strings, index) {
Expand Down
3 changes: 1 addition & 2 deletions doc/userguide/src/ExecutingTestCases/visible_log_level.html
Original file line number Diff line number Diff line change
Expand Up @@ -1073,7 +1073,6 @@
var idCounter = 0;
var _statistics = null;
var LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'FAIL', 'ERROR'];
var STATUSES = ['FAIL', 'PASS', 'NOT_RUN'];
var KEYWORDS = ['KEYWORD', 'SETUP', 'TEARDOWN', 'FOR', 'VAR'];

function addElement(elem) {
Expand Down Expand Up @@ -1108,7 +1107,7 @@
}

function parseStatus(stats) {
return STATUSES[stats[0]];
return stats[0];
}

function last(items) {
Expand Down
32 changes: 30 additions & 2 deletions src/robot/conf/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ class _BaseSettings(object):
'ConsoleColors' : ('consolecolors', 'AUTO'),
'StdOut' : ('stdout', None),
'StdErr' : ('stderr', None),
'XUnitSkipNonCritical' : ('xunitskipnoncritical', False)}
'XUnitSkipNonCritical' : ('xunitskipnoncritical', False),
'AddStatus' : ('addstatus', [])}
_output_opts = ['Output', 'Log', 'Report', 'XUnit', 'DebugFile']

def __init__(self, options=None, **extra_options):
Expand Down Expand Up @@ -137,8 +138,16 @@ def _process_value(self, name, value):
self._validate_expandkeywords(value)
if name == 'Extension':
return tuple(ext.lower().lstrip('.') for ext in value.split(':'))
if name == 'AddStatus':
return self._validate_custom_statuses(value)
return value

def _validate_custom_statuses(self, values):
custom_statuses = []
for value in values:
custom_statuses.append(self._process_custom_statuses(value))
return custom_statuses

def _escape_as_data(self, value):
return value

Expand Down Expand Up @@ -273,6 +282,13 @@ def _process_tag_stat_link(self, value):
raise DataError("Invalid format for option '--tagstatlink'. "
"Expected 'tag:link:title' but got '%s'." % value)

def _process_custom_statuses(self, value):
tokens = value.split(':')
if len(tokens) == 4 and tokens[1] in ['PASS', 'FAIL', 'SKIP']:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should support also lower case values.

return tokens
raise DataError("Invalid format for option '--addstatus'. "
"Expected 'NEWSTATUS:OLDSTATUS:TAGPATTERN:COLOR' but got '%s'." % value)

def _convert_to_positive_integer_or_default(self, name, value):
value = self._convert_to_integer(name, value)
return value if value > 0 else self._get_default_value(name)
Expand Down Expand Up @@ -385,6 +401,10 @@ def rpa(self):
def rpa(self, value):
self['RPA'] = value

@property
def add_status(self):
return self['AddStatus']


class RobotSettings(_BaseSettings):
_extra_cli_opts = {'Extension' : ('extension', ('robot',)),
Expand Down Expand Up @@ -583,7 +603,8 @@ def log_config(self):
'title': html_escape(self['LogTitle'] or ''),
'reportURL': self._url_from_path(self.log, self.report),
'splitLogBase': os.path.basename(os.path.splitext(self.log)[0]),
'defaultLevel': self['VisibleLogLevel']
'defaultLevel': self['VisibleLogLevel'],
'customStatusColor': self._resolve_custom_status_colors()
}

@property
Expand All @@ -606,6 +627,13 @@ def _resolve_background_colors(self):
colors = self['ReportBackground']
return {'pass': colors[0], 'fail': colors[1], 'skip': colors[2]}

def _resolve_custom_status_colors(self):
statuses = self['AddStatus']
status_colors = dict()
for item in statuses:
status_colors[item[0]] = item[3]
return status_colors

@property
def merge(self):
return self['Merge']
Expand Down
9 changes: 9 additions & 0 deletions src/robot/htmldata/rebot/log.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ <h1>Opening Robot Framework log failed</h1>
initLayout(topsuite.name, 'Log');
addStatistics();
addErrors();
setCustomStatusColors();
addExecutionLog(topsuite);
addLogLevelSelector(window.settings['minLevel'], window.settings['defaultLevel']);
if (window.location.hash) {
Expand All @@ -60,6 +61,14 @@ <h1>Opening Robot Framework log failed</h1>
setTimeout(function () { loadAndExpandElementIds(window.output['expand_keywords']); }, 100);
});

function setCustomStatusColors() {
let statuses = window.settings['customStatusColor']
let css = $("<style type='text/css'> </style>").appendTo($('body'));
for (let status of Object.keys(statuses)) {
css.append(".label.".concat(status.toLowerCase()).concat("{ background-color:").concat(statuses[status]).concat("}"));
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't it possible to just modify CSS directly and not inject <style> like this? If styles are injected, we could probably do that when writing the log file itself.


function addLogLevelSelector(minLevel, defaultLevel) {
var controller = LogLevelController(minLevel, defaultLevel);
if (controller.showLogLevelSelector()) {
Expand Down
3 changes: 1 addition & 2 deletions src/robot/htmldata/rebot/testdata.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ window.testdata = function () {
var idCounter = 0;
var _statistics = null;
var LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'FAIL', 'SKIP'];
var STATUSES = ['FAIL', 'PASS', 'SKIP', 'NOT RUN'];
var KEYWORD_TYPES = ['KEYWORD', 'SETUP', 'TEARDOWN', 'FOR', 'VAR', 'IF', 'ELSE IF', 'ELSE'];
var MESSAGE_TYPE = 8;

Expand Down Expand Up @@ -38,7 +37,7 @@ window.testdata = function () {
}

function parseStatus(stats) {
return STATUSES[stats[0]];
return stats[0];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Statuses should be written to log.html as integers like earlier. That takes less space and lists containing only integers are faster to process by browsers than lists containing integers and strings. The hard coded STATUSES list mapping integers to actual statuses should just be replaced with a list containing also the custom statuses. It probably could be written to the config section along with custom title, background color, etc.

}

function childCreator(parent, childType) {
Expand Down
11 changes: 8 additions & 3 deletions src/robot/output/console/highlighting.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
windll = None

from robot.errors import DataError
from robot.result.model import StatusMixin
from robot.utils import console_encode, isatty, WINDOWS


Expand Down Expand Up @@ -98,16 +99,20 @@ def error(self, message, level):
@contextmanager
def _highlighting(self, status):
highlighter = self._highlighter
start = {'PASS': highlighter.green,
'FAIL': highlighter.red,
start = {StatusMixin.PASS: highlighter.green,
StatusMixin.FAIL: highlighter.red,
'ERROR': highlighter.red,
'WARN': highlighter.yellow,
'SKIP': highlighter.yellow}[status]
StatusMixin.SKIP: highlighter.yellow}[status]
start()
try:
yield
finally:
highlighter.reset()
# reset custom statuses
StatusMixin.PASS = 'PASS'
StatusMixin.FAIL = 'FAIL'
StatusMixin.SKIP = 'SKIP'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks really strange. Do I get the logic right that code elsewhere sets custom values for StatusMixin.XXX constants and they are reset here? If yes, that's not a very good approach. It only works if code is executed in certain order and doesn't work at all if you programmatically check results.



def Highlighter(stream):
Expand Down
3 changes: 1 addition & 2 deletions src/robot/reporting/jsmodelbuilders.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@


IF_ELSE_ROOT = BodyItem.IF_ELSE_ROOT
STATUSES = {'FAIL': 0, 'PASS': 1, 'SKIP': 2, 'NOT RUN': 3}
KEYWORD_TYPES = {'KEYWORD': 0, 'SETUP': 1, 'TEARDOWN': 2,
'FOR': 3, 'FOR ITERATION': 4,
'IF': 5, 'ELSE IF': 6, 'ELSE': 7}
Expand Down Expand Up @@ -60,7 +59,7 @@ def __init__(self, context):
def _get_status(self, item):
# Branch status with IF/ELSE, "normal" status with others.
status = getattr(item, 'branch_status', item.status)
model = (STATUSES[status],
model = (status,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I already commented the related JS code on log.html side, statuses should still be integers in log.html. Should somehow be able to map also custom statuses to integers here. Probably the mapping could be passed to JsModelBuilder but also the processes result object could have that info.

self._timestamp(item.starttime),
item.elapsedtime)
msg = getattr(item, 'message', '')
Expand Down
7 changes: 7 additions & 0 deletions src/robot/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,13 @@
tag:<pattern>: flatten matched keywords using same
matching rules as with
`--removekeywords tag:<pattern>`
--addstatus for|foritem|<NEWSTATUS:OLDSTATUS:TAGPATTERN:COLOR> *
Allows the possibility to create custom statuses
based on existing statues and tags.
Examples:
--addstatus KNOWN_ISSUE:FAIL:bug-id-*:purple
--addstatus NON-CRITICAL:SKIP:non-critical:pink
New in RF 4.x
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for|foritem looks like a copy-paste error.

Also rebot.py would need this option.

--listener class * A class for monitoring test execution. Gets
notifications e.g. when tests start and end.
Arguments to the listener class can be given after
Expand Down
21 changes: 17 additions & 4 deletions src/robot/running/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from robot.result.model import StatusMixin
from robot.errors import ExecutionFailed, PassExecution
from robot.model import TagPatterns
from robot.utils import html_escape, py3to2, unic, test_or_task
Expand Down Expand Up @@ -76,6 +77,7 @@ def __init__(self, parent=None, *exit_modes):
self.skipped = False
self._teardown_allowed = False
self._rpa = False
self._custom_statuses = None
if parent:
parent.children.append(self)

Expand Down Expand Up @@ -127,11 +129,21 @@ def failed(self):

@property
def status(self):
if self._custom_statuses and isinstance(self, TestStatus):
for custom_status in self._custom_statuses:
if TagPatterns(custom_status[2]).match(self._test.tags):
if custom_status[1] == 'PASS':
StatusMixin.PASS = custom_status[0]
elif custom_status[1] == 'FAIL':
StatusMixin.FAIL = custom_status[0]
else:
StatusMixin.SKIP = custom_status[0]
break
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I commented the code on the highlighter, the logic to here modify constants and then elsewhere resetting them isn't a valid approach. It's too fragile because it requires code to be run in certain exact order and doesn't work at all if analyzing/modifying results programmatically. I'm not exactly sure what's the best way to handle this core logic, but I write some ideas as general comments.

if self.skipped or (self.parent and self.parent.skipped):
return 'SKIP'
return StatusMixin.SKIP
if self.failed:
return 'FAIL'
return 'PASS'
return StatusMixin.FAIL
return StatusMixin.PASS

def _skip_on_failure(self):
return False
Expand Down Expand Up @@ -172,13 +184,14 @@ def _my_message(self):
class TestStatus(_ExecutionStatus):

def __init__(self, parent, test, skip_on_failure=None, critical_tags=None,
rpa=False):
rpa=False, custom_statuses=None):
_ExecutionStatus.__init__(self, parent)
self.exit = parent.exit
self._test = test
self._skip_on_failure_tags = skip_on_failure
self._critical_tags = critical_tags
self._rpa = rpa
self._custom_statuses = custom_statuses

def test_failed(self, failure):
if hasattr(failure, 'skip') and failure.skip:
Expand Down
Loading