diff --git a/atest/robot/parsing/test_metadata.robot b/atest/robot/parsing/test_metadata.robot new file mode 100644 index 00000000000..21401bd59da --- /dev/null +++ b/atest/robot/parsing/test_metadata.robot @@ -0,0 +1,43 @@ +*** Settings *** +Documentation Tests for --metadata are located in robot/cli/runner and +... for other suite settings in suite_settings.robot. +Suite Setup Run Tests --variable meta_value_from_cli:my_metadata parsing/test_metadata.robot +Test Template Validate metadata +Resource atest_resource.robot + +*** Test Cases *** +Metadata + Name Value + name Value + NAME Value + +Metadata In Multiple Columns + Multiple columns Value in${SPACE*4}multiple${SPACE*4}columns + +Metadata In Multiple Lines + Multiple lines Metadata in multiple lines + ... is parsed using + ... same semantics${SPACE*4}as${SPACE*4}documentation. + ... | table | + ... |${SPACE*3}!${SPACE*3}| + +Metadata With Variables + Variables Version: 1.2 + +Metadata With Variable From Resource + Variable from resource Variable from a resource file + +Metadata With Variable From Commandline + Value from CLI my_metadata + +Using Same Name Twice + Overridden This overrides first value + +Unescaping Metadata In Setting Table + Escaping Three backslashes \\\\\\ & \${version} + +*** Keywords *** +Validate metadata + [Arguments] ${name} @{lines} + ${value} = Catenate SEPARATOR=\n @{lines} + Should be Equal ${TEST.metadata['${name}']} ${value} diff --git a/atest/testdata/parsing/test_metadata.robot b/atest/testdata/parsing/test_metadata.robot new file mode 100644 index 00000000000..8266513578a --- /dev/null +++ b/atest/testdata/parsing/test_metadata.robot @@ -0,0 +1,24 @@ +*** Settings *** +Resource ../core/resources.robot + + + +*** Variables *** +${version} 1.2 + +*** Test Cases *** +Test Case + [Metadata] Name Value + [Metadata] Multiple columns Value in multiple columns + [Metadata] multiple lines Metadata in multiple lines + ... is parsed using + ... same semantics as documentation. + ... | table | + ... | ! | + [Metadata] variables Version: ${version} + [Metadata] Variable from resource ${resource_file_var} + [Metadata] Value from CLI ${META_VALUE_FROM_CLI} + [Metadata] Escaping Three backslashes \\\\\\ & \${version} + [Metadata] Overridden first value + [Metadata] over ridden This overrides first value + No Operation diff --git a/src/robot/conf/settings.py b/src/robot/conf/settings.py index 54ea34fb63b..46743ae3c5d 100644 --- a/src/robot/conf/settings.py +++ b/src/robot/conf/settings.py @@ -22,6 +22,8 @@ from datetime import datetime from pathlib import Path +import toml + from robot.errors import DataError, FrameworkError from robot.output import LOGGER, LogLevel from robot.result.keywordremover import KeywordRemover @@ -141,6 +143,8 @@ 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 == 'Customsetting': + return self._process_custom_settings(value) return value def _process_doc(self, value): @@ -359,6 +363,9 @@ def _validate_expandkeywords(self, values): def _raise_invalid(self, option, error): raise DataError(f"Invalid value for option '--{option.lower()}': {error}") + + def _process_custom_settings(self, name): + return name def __contains__(self, setting): return setting in self._opts @@ -496,7 +503,8 @@ class RobotSettings(_BaseSettings): 'ConsoleWidth' : ('consolewidth', 78), 'ConsoleMarkers' : ('consolemarkers', 'AUTO'), 'DebugFile' : ('debugfile', None), - 'Language' : ('language', [])} + 'Language' : ('language', []), + 'CustomSettings' : ('customsettings', None)} _languages = None def get_rebot_settings(self): @@ -549,6 +557,7 @@ def suite_config(self): 'randomize_suites': self.randomize_suites, 'randomize_tests': self.randomize_tests, 'randomize_seed': self.randomize_seed, + 'custom_settings': self.custom_settings } @property @@ -672,6 +681,10 @@ def variable_files(self): @property def extension(self): return self['Extension'] + + @property + def custom_settings(self): + return self['CustomSettings'] class RebotSettings(_BaseSettings): diff --git a/src/robot/htmldata/rebot/log.html b/src/robot/htmldata/rebot/log.html index a489866589f..84084f334f2 100644 --- a/src/robot/htmldata/rebot/log.html +++ b/src/robot/htmldata/rebot/log.html @@ -212,6 +212,12 @@

{{= testOrTask('{Test}')}} Execution Errors

{{html $value[1]}} {{/each}} + {{each custom_setting}} + + {{html $value[0]}}: + {{html $value[1]}} + + {{/each}} {{if source}} Source: @@ -280,6 +286,18 @@

{{= testOrTask('{Test}')}} Execution Errors

{{html timeout}} {{/if}} + {{each metadata}} + + {{html $value[0]}}: + {{html $value[1]}} + + {{/each}} + {{each custom_setting}} + + {{html $value[0]}}: + {{html $value[1]}} + + {{/each}} Start / End / Elapsed: ${times.startTime} / ${times.endTime} / ${times.elapsedTime} diff --git a/src/robot/htmldata/rebot/model.js b/src/robot/htmldata/rebot/model.js index 05a65113b7c..289da4af1bb 100644 --- a/src/robot/htmldata/rebot/model.js +++ b/src/robot/htmldata/rebot/model.js @@ -131,6 +131,7 @@ window.model = (function () { return test.keywords(); }; test.tags = data.tags; + test.metadata = data.metadata; test.matchesTagPattern = function (pattern) { return containsTagPattern(test.tags, pattern); }; diff --git a/src/robot/htmldata/rebot/testdata.js b/src/robot/htmldata/rebot/testdata.js index ef7d7275894..a54d5456d7e 100644 --- a/src/robot/htmldata/rebot/testdata.js +++ b/src/robot/htmldata/rebot/testdata.js @@ -125,7 +125,8 @@ window.testdata = function () { }, times: model.Times(times(status)), tags: tags(element[3], strings), - isChildrenLoaded: typeof(element[5]) !== 'number' + isChildrenLoaded: typeof(element[5]) !== 'number', + metadata: parseMetadata(element[6], strings) }); lazyPopulateKeywordsFromFile(test, element[5], strings); return test; diff --git a/src/robot/model/configurer.py b/src/robot/model/configurer.py index 5263bbec886..0e0c0203294 100644 --- a/src/robot/model/configurer.py +++ b/src/robot/model/configurer.py @@ -1,93 +1,94 @@ -# Copyright 2008-2015 Nokia Networks -# Copyright 2016- Robot Framework Foundation -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from robot.utils import seq2str -from robot.errors import DataError - -from .visitor import SuiteVisitor - - -class SuiteConfigurer(SuiteVisitor): - - def __init__(self, name=None, doc=None, metadata=None, set_tags=None, - include_tags=None, exclude_tags=None, include_suites=None, - include_tests=None, empty_suite_ok=False): - self.name = name - self.doc = doc - self.metadata = metadata - self.set_tags = set_tags or [] - self.include_tags = include_tags - self.exclude_tags = exclude_tags - self.include_suites = include_suites - self.include_tests = include_tests - self.empty_suite_ok = empty_suite_ok - - @property - def add_tags(self): - return [t for t in self.set_tags if not t.startswith('-')] - - @property - def remove_tags(self): - return [t[1:] for t in self.set_tags if t.startswith('-')] - - def visit_suite(self, suite): - self._set_suite_attributes(suite) - self._filter(suite) - suite.set_tags(self.add_tags, self.remove_tags) - - def _set_suite_attributes(self, suite): - if self.name: - suite.name = self.name - if self.doc: - suite.doc = self.doc - if self.metadata: - suite.metadata.update(self.metadata) - - def _filter(self, suite): - name = suite.name - suite.filter(self.include_suites, self.include_tests, - self.include_tags, self.exclude_tags) - if not (suite.has_tests or self.empty_suite_ok): - self._raise_no_tests_or_tasks_error(name, suite.rpa) - - def _raise_no_tests_or_tasks_error(self, name, rpa): - parts = [{False: 'tests', True: 'tasks', None: 'tests or tasks'}[rpa], - self._get_test_selector_msgs(), - self._get_suite_selector_msg()] - raise DataError(f"Suite '{name}' contains no " - f"{' '.join(p for p in parts if p)}.") - - def _get_test_selector_msgs(self): - parts = [] - for separator, explanation, selectors in [ - (None, 'matching name', self.include_tests), - ('and', 'matching tags', self.include_tags), - ('and', 'not matching tags', self.exclude_tags) - ]: - if selectors: - if parts: - parts.append(separator) - parts.append(self._format_selector_msg(explanation, selectors)) - return ' '.join(parts) - - def _format_selector_msg(self, explanation, selectors): - if len(selectors) == 1 and explanation[-1] == 's': - explanation = explanation[:-1] - return f"{explanation} {seq2str(selectors, lastsep=' or ')}" - - def _get_suite_selector_msg(self): - if not self.include_suites: - return '' - return self._format_selector_msg('in suites', self.include_suites) +# Copyright 2008-2015 Nokia Networks +# Copyright 2016- Robot Framework Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from robot.utils import seq2str +from robot.errors import DataError + +from .visitor import SuiteVisitor + + +class SuiteConfigurer(SuiteVisitor): + + def __init__(self, name=None, doc=None, metadata=None, set_tags=None, + include_tags=None, exclude_tags=None, include_suites=None, + include_tests=None, empty_suite_ok=False, custom_settings=None): + self.name = name + self.doc = doc + self.metadata = metadata + self.set_tags = set_tags or [] + self.include_tags = include_tags + self.exclude_tags = exclude_tags + self.include_suites = include_suites + self.include_tests = include_tests + self.empty_suite_ok = empty_suite_ok + self.custom_settings = custom_settings + + @property + def add_tags(self): + return [t for t in self.set_tags if not t.startswith('-')] + + @property + def remove_tags(self): + return [t[1:] for t in self.set_tags if t.startswith('-')] + + def visit_suite(self, suite): + self._set_suite_attributes(suite) + self._filter(suite) + suite.set_tags(self.add_tags, self.remove_tags) + + def _set_suite_attributes(self, suite): + if self.name: + suite.name = self.name + if self.doc: + suite.doc = self.doc + if self.metadata: + suite.metadata.update(self.metadata) + + def _filter(self, suite): + name = suite.name + suite.filter(self.include_suites, self.include_tests, + self.include_tags, self.exclude_tags) + if not (suite.has_tests or self.empty_suite_ok): + self._raise_no_tests_or_tasks_error(name, suite.rpa) + + def _raise_no_tests_or_tasks_error(self, name, rpa): + parts = [{False: 'tests', True: 'tasks', None: 'tests or tasks'}[rpa], + self._get_test_selector_msgs(), + self._get_suite_selector_msg()] + raise DataError(f"Suite '{name}' contains no " + f"{' '.join(p for p in parts if p)}.") + + def _get_test_selector_msgs(self): + parts = [] + for separator, explanation, selectors in [ + (None, 'matching name', self.include_tests), + ('and', 'matching tags', self.include_tags), + ('and', 'not matching tags', self.exclude_tags) + ]: + if selectors: + if parts: + parts.append(separator) + parts.append(self._format_selector_msg(explanation, selectors)) + return ' '.join(parts) + + def _format_selector_msg(self, explanation, selectors): + if len(selectors) == 1 and explanation[-1] == 's': + explanation = explanation[:-1] + return f"{explanation} {seq2str(selectors, lastsep=' or ')}" + + def _get_suite_selector_msg(self): + if not self.include_suites: + return '' + return self._format_selector_msg('in suites', self.include_suites) diff --git a/src/robot/model/testcase.py b/src/robot/model/testcase.py index 38f0d876bde..13395633706 100644 --- a/src/robot/model/testcase.py +++ b/src/robot/model/testcase.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from collections.abc import Mapping from pathlib import Path from typing import Any, Generic, Sequence, Type, TYPE_CHECKING, TypeVar @@ -22,6 +23,7 @@ from .fixture import create_fixture from .itemlist import ItemList from .keyword import Keyword +from .metadata import Metadata from .modelobject import DataDict, ModelObject from .tags import Tags @@ -52,9 +54,11 @@ def __init__(self, name: str = '', tags: 'Tags|Sequence[str]' = (), timeout: 'str|None' = None, lineno: 'int|None' = None, - parent: 'TestSuite[KW, TestCase[KW]]|None' = None): + parent: 'TestSuite[KW, TestCase[KW]]|None' = None, + metadata: 'Mapping[str, str]|None' = None): self.name = name self.doc = doc + self.metadata = metadata self.tags = tags self.timeout = timeout self.lineno = lineno @@ -168,6 +172,12 @@ def full_name(self) -> str: def longname(self) -> str: """Deprecated since Robot Framework 7.0. Use :attr:`full_name` instead.""" return self.full_name + + + @setter + def metadata(self, metadata: 'Mapping[str, str]|None') -> Metadata: + """Free case metadata as a :class:`~.metadata.Metadata` object.""" + return Metadata(metadata) @property def source(self) -> 'Path|None': @@ -181,6 +191,8 @@ def to_dict(self) -> 'dict[str, Any]': data: 'dict[str, Any]' = {'name': self.name} if self.doc: data['doc'] = self.doc + if self.metadata: + data['metadata'] = dict(self.metadata) if self.tags: data['tags'] = tuple(self.tags) if self.timeout: diff --git a/src/robot/output/xmllogger.py b/src/robot/output/xmllogger.py index 061bd9be503..ecef9eda5cd 100644 --- a/src/robot/output/xmllogger.py +++ b/src/robot/output/xmllogger.py @@ -214,6 +214,10 @@ def start_test(self, test): def end_test(self, test): self._writer.element('doc', test.doc) self._write_list('tag', test.tags) + for name, value in test.metadata.items(): + self._writer.element('meta', value, {'name': name}) + for name, value in test.custom_settings.items(): + self._writer.element('custom_setting', value, {'name': name}) if test.timeout: self._writer.element('timeout', attrs={'value': str(test.timeout)}) self._write_status(test) @@ -229,6 +233,8 @@ def end_suite(self, suite): self._writer.element('doc', suite.doc) for name, value in suite.metadata.items(): self._writer.element('meta', value, {'name': name}) + for name, value in suite.custom_settings.items(): + self._writer.element('custom_setting', value, {'name': name}) self._write_status(suite) self._writer.end('suite') diff --git a/src/robot/parsing/lexer/settings.py b/src/robot/parsing/lexer/settings.py index e5d7955927e..9b27a0b6bb0 100644 --- a/src/robot/parsing/lexer/settings.py +++ b/src/robot/parsing/lexer/settings.py @@ -36,7 +36,8 @@ class Settings(ABC): 'Test Template', 'Timeout', 'Template', - 'Name' + 'Name', + 'Customsetting' ) name_and_arguments = ( 'Metadata', @@ -55,7 +56,9 @@ class Settings(ABC): 'Library', ) - def __init__(self, languages: Languages): + def __init__(self, languages: Languages, custom_settings = {}): + for n in custom_settings: + self.names = self.names + n self.settings: 'dict[str, list[Token]|None]' = {n: None for n in self.names} self.languages = languages @@ -76,7 +79,7 @@ def _format_name(self, name: str) -> str: return name def _validate(self, orig: str, name: str, statement: StatementTokens): - if name not in self.settings: + if name not in self.settings and name not in self.settings['Customsetting'].keys(): message = self._get_non_existing_setting_message(orig, name) raise ValueError(message) if self.settings[name] is not None and name not in self.multi_use: @@ -162,7 +165,8 @@ class SuiteFileSettings(FileSettings): 'Keyword Tags', 'Library', 'Resource', - 'Variables' + 'Variables', + 'Customsetting' ) aliases = { 'Force Tags': 'Test Tags', @@ -225,7 +229,9 @@ class TestCaseSettings(Settings): 'Setup', 'Teardown', 'Template', - 'Timeout' + 'Timeout', + 'Metadata', + 'Customsetting' ) def __init__(self, parent: SuiteFileSettings): diff --git a/src/robot/parsing/lexer/tokens.py b/src/robot/parsing/lexer/tokens.py index 3e6cfe0a65f..74cc24fe9fb 100644 --- a/src/robot/parsing/lexer/tokens.py +++ b/src/robot/parsing/lexer/tokens.py @@ -77,6 +77,7 @@ class Token: ARGUMENTS = 'ARGUMENTS' RETURN = 'RETURN' # TODO: Change to mean RETURN statement in RF 8. RETURN_SETTING = RETURN # TODO: Remove in RF 8. + CUSTOMSETTING = 'CUSTOMSETTING' AS = 'AS' WITH_NAME = AS # TODO: Remove in RF 8. @@ -142,7 +143,8 @@ class Token: TIMEOUT, TAGS, ARGUMENTS, - RETURN + RETURN, + CUSTOMSETTING )) HEADER_TOKENS = frozenset(( SETTING_HEADER, diff --git a/src/robot/parsing/model/statements.py b/src/robot/parsing/model/statements.py index cff71bf0da3..db257a5aec4 100644 --- a/src/robot/parsing/model/statements.py +++ b/src/robot/parsing/model/statements.py @@ -1439,3 +1439,20 @@ def _validate_dict_items(self, statement: Statement): def _is_valid_dict_item(self, item: str) -> bool: name, value = split_from_equals(item) return value is not None or is_dict_variable(item) + + +@Statement.register +class Customsetting(SingleValue): + type = Token.CUSTOMSETTING + + @classmethod + def from_params(cls, value: str, indent: str = FOUR_SPACES, + separator: str = FOUR_SPACES, eol: str = EOL, name: str = None) -> 'Customsetting': + return cls([ + Token(Token.SEPARATOR, indent), + Token(Token.CUSTOMSETTING, name), + Token(Token.SEPARATOR, separator), + Token(Token.ARGUMENT, value), + Token(Token.EOL, eol) + ]) + \ No newline at end of file diff --git a/src/robot/reporting/jsmodelbuilders.py b/src/robot/reporting/jsmodelbuilders.py index 2297e3071b9..8ee3b51333b 100644 --- a/src/robot/reporting/jsmodelbuilders.py +++ b/src/robot/reporting/jsmodelbuilders.py @@ -139,7 +139,13 @@ def build(self, test): self._html(test.doc), tuple(self._string(t) for t in test.tags), self._get_status(test), - self._build_body(body, split=True)) + self._build_body(body, split=True), + tuple(self._yield_metadata(test))) + + def _yield_metadata(self, test): + for name, value in test.metadata.items(): + yield self._string(name) + yield self._html(value) def _get_body_items(self, test): body = test.body.flatten() diff --git a/src/robot/result/model.py b/src/robot/result/model.py index 68961a8af51..c80caa4f208 100644 --- a/src/robot/result/model.py +++ b/src/robot/result/model.py @@ -940,8 +940,9 @@ def __init__(self, name: str = '', start_time: 'datetime|str|None' = None, end_time: 'datetime|str|None' = None, elapsed_time: 'timedelta|int|float|None' = None, - parent: 'TestSuite|None' = None): - super().__init__(name, doc, tags, timeout, lineno, parent) + parent: 'TestSuite|None' = None, + metadata: 'Mapping[str, str]|None' = None,): + super().__init__(name, doc, tags, timeout, lineno, parent, metadata) self.status = status self.message = message self.start_time = start_time diff --git a/src/robot/result/xmlelementhandlers.py b/src/robot/result/xmlelementhandlers.py index 1e744bc4ea2..9f891007b1b 100644 --- a/src/robot/result/xmlelementhandlers.py +++ b/src/robot/result/xmlelementhandlers.py @@ -127,8 +127,8 @@ class TestHandler(ElementHandler): tag = 'test' # 'tags' is for RF < 4 compatibility. children = frozenset(('doc', 'tags', 'tag', 'timeout', 'status', 'kw', 'if', 'for', - 'try', 'while', 'group', 'variable', 'return', 'break', 'continue', - 'error', 'msg')) + 'try', 'while', 'variable', 'return', 'break', 'continue', + 'error', 'msg', 'meta')) def start(self, elem, result): lineno = elem.get('line') diff --git a/src/robot/run.py b/src/robot/run.py index 067fc441749..acd33750105 100755 --- a/src/robot/run.py +++ b/src/robot/run.py @@ -370,6 +370,8 @@ | path/to/test/directory/ Examples: --argumentfile argfile.txt --argumentfile STDIN + --customsettings tag * Name of custom setting to process inside the + suites. Can be used multiple times. -h -? --help Print usage instructions. --version Print version information. diff --git a/src/robot/running/builder/settings.py b/src/robot/running/builder/settings.py index d48a6655f4e..0483c479b5b 100644 --- a/src/robot/running/builder/settings.py +++ b/src/robot/running/builder/settings.py @@ -14,7 +14,7 @@ # limitations under the License. from collections.abc import Sequence -from typing import TypedDict +from typing import Mapping, TypedDict from ..model import TestCase @@ -51,12 +51,14 @@ def __init__(self, parent: 'TestDefaults|None' = None, setup: 'FixtureDict|None' = None, teardown: 'FixtureDict|None' = None, tags: 'Sequence[str]' = (), - timeout: 'str|None' = None): + timeout: 'str|None' = None, + metadata: 'Mapping[str, str]|None' = None): self.parent = parent self.setup = setup self.teardown = teardown self.tags = tags self.timeout = timeout + self.metadata = metadata @property def setup(self) -> 'FixtureDict|None': @@ -112,6 +114,18 @@ def timeout(self) -> 'str|None': def timeout(self, timeout: 'str|None'): self._timeout = timeout + @property + def metadata(self) -> 'Mapping[str, str]|None': + if self._metadata: + return self._metadata + if self.parent: + return self.parent.metadata + return None + + @metadata.setter + def metadata(self, metadata:'Mapping[str, str]|None'): + self._metadata = metadata + def set_to(self, test: TestCase): """Sets defaults to the given test. @@ -126,6 +140,8 @@ def set_to(self, test: TestCase): test.teardown.config(**self.teardown) if self.timeout and not test.timeout: test.timeout = self.timeout + if self.metadata and not test.metadata: + test.metadata = self.metadata class FileSettings: diff --git a/src/robot/running/builder/transformers.py b/src/robot/running/builder/transformers.py index 54ebff45750..94eb00d31be 100644 --- a/src/robot/running/builder/transformers.py +++ b/src/robot/running/builder/transformers.py @@ -295,6 +295,8 @@ def visit_Tags(self, node): def visit_Template(self, node): self.model.template = node.value + def visit_Metadata(self, node): + self.model.metadata[node.name] = node.value class KeywordBuilder(BodyBuilder): model: UserKeyword diff --git a/src/robot/running/context.py b/src/robot/running/context.py index a75c4179a9e..a638d15db28 100644 --- a/src/robot/running/context.py +++ b/src/robot/running/context.py @@ -231,6 +231,7 @@ def start_test(self, data, result): self.variables.set_test('${TEST_NAME}', result.name) self.variables.set_test('${TEST_DOCUMENTATION}', result.doc) self.variables.set_test('@{TEST_TAGS}', list(result.tags)) + self.variables.set_test('${TEST_METADATA}', result.metadata.copy()) self.output.start_test(data, result) def _add_timeout(self, timeout): diff --git a/src/robot/running/model.py b/src/robot/running/model.py index 1bf72258cef..7074e836e8e 100644 --- a/src/robot/running/model.py +++ b/src/robot/running/model.py @@ -574,8 +574,9 @@ def __init__(self, name: str = '', lineno: 'int|None' = None, parent: 'TestSuite|None' = None, template: 'str|None' = None, - error: 'str|None' = None): - super().__init__(name, doc, tags, timeout, lineno, parent) + error: 'str|None' = None, + metadata: 'Mapping[str, str]|None' = None,): + super().__init__(name, doc, tags, timeout, lineno, parent, metadata) #: Name of the keyword that has been used as a template when building the test. # ``None`` if template is not used. self.template = template diff --git a/src/robot/running/suiterunner.py b/src/robot/running/suiterunner.py index d87e9b0cbf3..ab836924b08 100644 --- a/src/robot/running/suiterunner.py +++ b/src/robot/running/suiterunner.py @@ -131,7 +131,8 @@ def visit_test(self, data: TestData): self._resolve_setting(data.tags), self._get_timeout(data), data.lineno, - start_time=datetime.now()) + start_time=datetime.now(), + metadata=data.metadata) if result.tags.robot('exclude'): self.suite_result.tests.pop() return diff --git a/src/robot/testdoc.py b/src/robot/testdoc.py index 4b41e36aea2..45de42b8ae2 100755 --- a/src/robot/testdoc.py +++ b/src/robot/testdoc.py @@ -208,6 +208,8 @@ def _convert_test(self, test): 'fullName': self._escape(test.full_name), 'id': test.id, 'doc': self._html(test.doc), + 'metadata': [(self._escape(name), self._html(value)) + for name, value in test.metadata.items()], 'tags': [self._escape(t) for t in test.tags], 'timeout': self._get_timeout(test.timeout), 'keywords': list(self._convert_keywords(test.body))