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))