runner.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. #!/usr/bin/env -S python3 -B
  2. # Copyright (c) 2023 Project CHIP Authors
  3. #
  4. # Licensed under the Apache License, Version 2.0 (the "License");
  5. # you may not use this file except in compliance with the License.
  6. # You may obtain a copy of the License at
  7. #
  8. # http://www.apache.org/licenses/LICENSE-2.0
  9. #
  10. # Unless required by applicable law or agreed to in writing, software
  11. # distributed under the License is distributed on an "AS IS" BASIS,
  12. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. # See the License for the specific language governing permissions and
  14. # limitations under the License.
  15. import relative_importer # isort: split # noqa: F401
  16. import asyncio
  17. import importlib
  18. import os
  19. import sys
  20. import traceback
  21. from dataclasses import dataclass
  22. import click
  23. from matter_yamltests.definitions import SpecDefinitionsFromPaths
  24. from matter_yamltests.parser import TestParserConfig
  25. from matter_yamltests.parser_builder import TestParserBuilderConfig
  26. from matter_yamltests.parser_config import TestConfigParser
  27. from matter_yamltests.pseudo_clusters.pseudo_clusters import PseudoClusters, get_default_pseudo_clusters
  28. from matter_yamltests.runner import TestRunner, TestRunnerConfig, TestRunnerOptions
  29. from matter_yamltests.websocket_runner import WebSocketRunner, WebSocketRunnerConfig
  30. from paths_finder import PathsFinder
  31. from tests_finder import TestsFinder
  32. from tests_logger import TestParserLogger, TestRunnerLogger, WebSocketRunnerLogger
  33. #
  34. # Options
  35. #
  36. _DEFAULT_CONFIG_NAME = TestsFinder.get_default_configuration_name()
  37. _DEFAULT_CONFIG_DIR = TestsFinder.get_default_configuration_directory()
  38. _DEFAULT_SPECIFICATIONS_DIR = 'src/app/zap-templates/zcl/data-model/chip/*.xml'
  39. _DEFAULT_PICS_FILE = 'src/app/tests/suites/certification/ci-pics-values'
  40. def get_custom_pseudo_clusters(additional_pseudo_clusters_directory: str):
  41. clusters = get_default_pseudo_clusters()
  42. if additional_pseudo_clusters_directory:
  43. sys.path.insert(0, additional_pseudo_clusters_directory)
  44. for filepath in os.listdir(additional_pseudo_clusters_directory):
  45. if filepath != '__init__.py' and filepath[-3:] == '.py':
  46. module = importlib.import_module(f'{filepath[:-3]}')
  47. constructor = getattr(module, module.__name__)
  48. if constructor:
  49. clusters.add(constructor())
  50. return clusters
  51. def test_parser_options(f):
  52. f = click.option('--configuration_name', type=str, show_default=True, default=_DEFAULT_CONFIG_NAME,
  53. help='Name of the collection configuration json file to use.')(f)
  54. f = click.option('--configuration_directory', type=click.Path(exists=True), show_default=True, default=_DEFAULT_CONFIG_DIR,
  55. help='Path to the directory containing the tests configuration.')(f)
  56. f = click.option('--specifications_paths', type=click.Path(), show_default=True, default=_DEFAULT_SPECIFICATIONS_DIR,
  57. help='Path to a set of files containing clusters definitions.')(f)
  58. f = click.option('--PICS', type=click.Path(exists=True), show_default=True, default=_DEFAULT_PICS_FILE,
  59. help='Path to the PICS file to use.')(f)
  60. f = click.option('--stop_on_error', type=bool, show_default=True, default=True,
  61. help='Stop parsing on first error.')(f)
  62. f = click.option('--use_default_pseudo_clusters', type=bool, show_default=True, default=True,
  63. help='If enable this option use the set of default clusters provided by the matter_yamltests package.')(f)
  64. f = click.option('--additional_pseudo_clusters_directory', type=click.Path(), show_default=True, default=None,
  65. help='Path to a directory containing additional pseudo clusters.')(f)
  66. return f
  67. def test_runner_options(f):
  68. f = click.option('--adapter', type=str, default=None, required=True, show_default=True,
  69. help='The adapter to run the test with.')(f)
  70. f = click.option('--stop_on_error', type=bool, default=True, show_default=True,
  71. help='Stop the test suite on first error.')(f)
  72. f = click.option('--stop_on_warning', type=bool, default=False, show_default=True,
  73. help='Stop the test suite on first warning.')(f)
  74. f = click.option('--stop_at_number', type=int, default=-1, show_default=True,
  75. help='Stop the the test suite at the specified test number.')(f)
  76. f = click.option('--show_adapter_logs', type=bool, default=False, show_default=True,
  77. help='Show additional logs provided by the adapter.')(f)
  78. f = click.option('--show_adapter_logs_on_error', type=bool, default=True, show_default=True,
  79. help='Show additional logs provided by the adapter on error.')(f)
  80. f = click.option('--use_test_harness_log_format', type=bool, default=False, show_default=True,
  81. help='Use the test harness log format.')(f)
  82. f = click.option('--delay-in-ms', type=int, default=0, show_default=True,
  83. help='Add a delay between test suite steps.')(f)
  84. return f
  85. def websocket_runner_options(f):
  86. f = click.option('--server_address', type=str, default='localhost', show_default=True,
  87. help='The websocket server address to connect to.')(f)
  88. f = click.option('--server_port', type=int, default=9002, show_default=True,
  89. help='The websocket server port to connect to.')(f)
  90. f = click.option('--server_name', type=str, default=None,
  91. help='Name of a websocket server to run at launch.')(f)
  92. f = click.option('--server_path', type=click.Path(exists=True), default=None,
  93. help='Path to a websocket server to run at launch.')(f)
  94. f = click.option('--server_arguments', type=str, default=None,
  95. help='Optional arguments to pass to the websocket server at launch.')(f)
  96. return f
  97. def chip_repl_runner_options(f):
  98. f = click.option('--repl_storage_path', type=str, default='/tmp/repl-storage.json',
  99. help='Path to persistent storage configuration file.')(f)
  100. f = click.option('--commission_on_network_dut', type=bool, default=False,
  101. help='Prior to running test should we try to commission DUT on network.')(f)
  102. f = click.option('--runner', type=str, default=None, show_default=True,
  103. help='The runner to run the test with.')(f)
  104. return f
  105. @dataclass
  106. class ParserGroup:
  107. builder_config: TestParserBuilderConfig
  108. pseudo_clusters: PseudoClusters
  109. pass_parser_group = click.make_pass_decorator(ParserGroup)
  110. # YAML test file contains configurable options defined in their config section.
  111. #
  112. # Those options are test specific and as such can not be listed directly here but
  113. # instead are retrieved from the target test file (if there is a single file) and
  114. # are exposed to click dynamically.
  115. #
  116. # The following code extracts those and, to make it easy for the user to see
  117. # which options are available, list them in a custom section when --help
  118. # is invoked.
  119. class YamlTestParserGroup(click.Group):
  120. def format_options(self, ctx, formatter):
  121. """Writes all the options into the formatter if they exist."""
  122. if ctx.custom_options:
  123. params_copy = self.params
  124. non_custom_params = list(filter(lambda x: x.name not in ctx.custom_options, self.params))
  125. custom_params = list(filter(lambda x: x.name in ctx.custom_options, self.params))
  126. self.params = non_custom_params
  127. super().format_options(ctx, formatter)
  128. self.params = params_copy
  129. opts = []
  130. for param in custom_params:
  131. rv = param.get_help_record(ctx)
  132. if rv is not None:
  133. opts.append(rv)
  134. if opts:
  135. custom_section_title = ctx.params.get('test_name') + ' Options'
  136. with formatter.section(custom_section_title):
  137. formatter.write_dl(opts)
  138. else:
  139. super().format_options(ctx, formatter)
  140. def parse_args(self, ctx, args):
  141. # Run the parser on the supported arguments first in order to get a
  142. # the necessary informations to get read the test file and add
  143. # the potential additional arguments.
  144. supported_args = self.__remove_custom_args(ctx, args)
  145. super().parse_args(ctx, supported_args)
  146. # Add the potential new arguments to the list of supported params and
  147. # run the parser a new time to read those.
  148. self.__add_custom_params(ctx)
  149. return super().parse_args(ctx, args)
  150. def __remove_custom_args(self, ctx, args):
  151. # Remove all the unsupported options from the command line string.
  152. params_name = [param.name for param in self.params]
  153. supported_args = []
  154. skipArgument = False
  155. for arg in args:
  156. if arg.startswith('--') and arg not in params_name:
  157. skipArgument = True
  158. continue
  159. if skipArgument:
  160. skipArgument = False
  161. continue
  162. supported_args.append(arg)
  163. return supported_args
  164. def __add_custom_params(self, ctx):
  165. tests_finder = TestsFinder(ctx.params.get('configuration_directory'), ctx.params.get('configuration_name'))
  166. tests = tests_finder.get(ctx.params.get('test_name'))
  167. custom_options = {}
  168. # There is a single test, extract the custom config
  169. if len(tests) == 1:
  170. try:
  171. custom_options = TestConfigParser.get_config(tests[0])
  172. except Exception:
  173. pass
  174. for key, value in custom_options.items():
  175. param = click.Option(['--' + key], default=value, show_default=True)
  176. # click converts parameter name to lowercase internally, so we need to override
  177. # this behavior in order to override the correct key.
  178. param.name = key
  179. self.params.append(param)
  180. ctx.custom_options = custom_options
  181. CONTEXT_SETTINGS = dict(
  182. default_map={
  183. 'chiptool': {
  184. 'adapter': 'matter_chip_tool_adapter.adapter',
  185. 'server_name': 'chip-tool',
  186. 'server_arguments': 'interactive server',
  187. },
  188. 'darwinframeworktool': {
  189. 'adapter': 'matter_chip_tool_adapter.adapter',
  190. 'server_name': 'darwin-framework-tool',
  191. 'server_arguments': 'interactive server',
  192. },
  193. 'app1': {
  194. 'configuration_directory': 'examples/placeholder/linux/apps/app1',
  195. 'adapter': 'matter_placeholder_adapter.adapter',
  196. 'server_name': 'chip-app1',
  197. 'server_arguments': '--interactive',
  198. },
  199. 'app2': {
  200. 'configuration_directory': 'examples/placeholder/linux/apps/app2',
  201. 'adapter': 'matter_placeholder_adapter.adapter',
  202. 'server_name': 'chip-app2',
  203. 'server_arguments': '--interactive',
  204. },
  205. 'chip-repl': {
  206. 'adapter': 'matter_yamltest_repl_adapter.adapter',
  207. 'runner': 'matter_yamltest_repl_adapter.runner',
  208. },
  209. },
  210. max_content_width=120,
  211. )
  212. @click.group(cls=YamlTestParserGroup, context_settings=CONTEXT_SETTINGS)
  213. @click.argument('test_name')
  214. @test_parser_options
  215. @click.pass_context
  216. def runner_base(ctx, configuration_directory: str, test_name: str, configuration_name: str, pics: str, specifications_paths: str, stop_on_error: bool, use_default_pseudo_clusters: bool, additional_pseudo_clusters_directory: str, **kwargs):
  217. pseudo_clusters = get_custom_pseudo_clusters(
  218. additional_pseudo_clusters_directory) if use_default_pseudo_clusters else PseudoClusters([])
  219. specifications = SpecDefinitionsFromPaths(specifications_paths.split(','), pseudo_clusters)
  220. tests_finder = TestsFinder(configuration_directory, configuration_name)
  221. test_list = tests_finder.get(test_name)
  222. if len(test_list) == 0:
  223. raise Exception(f"No tests found for test name '{test_name}'")
  224. parser_config = TestParserConfig(pics, specifications, kwargs)
  225. parser_builder_config = TestParserBuilderConfig(test_list, parser_config, hooks=TestParserLogger())
  226. parser_builder_config.options.stop_on_error = stop_on_error
  227. while ctx:
  228. ctx.obj = ParserGroup(parser_builder_config, pseudo_clusters)
  229. ctx = ctx.parent
  230. @runner_base.command()
  231. @pass_parser_group
  232. def parse(parser_group: ParserGroup):
  233. """Parse the test suite."""
  234. runner_config = None
  235. runner = TestRunner()
  236. loop = asyncio.get_event_loop()
  237. return loop.run_until_complete(runner.run(parser_group.builder_config, runner_config))
  238. @runner_base.command()
  239. @pass_parser_group
  240. def dry_run(parser_group: ParserGroup):
  241. """Simulate a run of the test suite."""
  242. runner_config = TestRunnerConfig(hooks=TestRunnerLogger())
  243. runner = TestRunner()
  244. loop = asyncio.get_event_loop()
  245. return loop.run_until_complete(runner.run(parser_group.builder_config, runner_config))
  246. @runner_base.command()
  247. @test_runner_options
  248. @pass_parser_group
  249. def run(parser_group: ParserGroup, adapter: str, stop_on_error: bool, stop_on_warning: bool, stop_at_number: int, show_adapter_logs: bool, show_adapter_logs_on_error: bool, use_test_harness_log_format: bool, delay_in_ms: int):
  250. """Run the test suite."""
  251. adapter = __import__(adapter, fromlist=[None]).Adapter(parser_group.builder_config.parser_config.definitions)
  252. runner_options = TestRunnerOptions(stop_on_error, stop_on_warning, stop_at_number, delay_in_ms)
  253. runner_hooks = TestRunnerLogger(show_adapter_logs, show_adapter_logs_on_error, use_test_harness_log_format)
  254. runner_config = TestRunnerConfig(adapter, parser_group.pseudo_clusters, runner_options, runner_hooks)
  255. runner = TestRunner()
  256. loop = asyncio.get_event_loop()
  257. return loop.run_until_complete(runner.run(parser_group.builder_config, runner_config))
  258. @runner_base.command()
  259. @test_runner_options
  260. @websocket_runner_options
  261. @pass_parser_group
  262. def websocket(parser_group: ParserGroup, adapter: str, stop_on_error: bool, stop_on_warning: bool, stop_at_number: int, show_adapter_logs: bool, show_adapter_logs_on_error: bool, use_test_harness_log_format: bool, delay_in_ms: int, server_address: str, server_port: int, server_path: str, server_name: str, server_arguments: str):
  263. """Run the test suite using websockets."""
  264. adapter = __import__(adapter, fromlist=[None]).Adapter(parser_group.builder_config.parser_config.definitions)
  265. runner_options = TestRunnerOptions(stop_on_error, stop_on_warning, stop_at_number, delay_in_ms)
  266. runner_hooks = TestRunnerLogger(show_adapter_logs, show_adapter_logs_on_error, use_test_harness_log_format)
  267. runner_config = TestRunnerConfig(adapter, parser_group.pseudo_clusters, runner_options, runner_hooks)
  268. if server_path is None and server_name:
  269. paths_finder = PathsFinder()
  270. server_path = paths_finder.get(server_name)
  271. websocket_runner_hooks = WebSocketRunnerLogger()
  272. websocket_runner_config = WebSocketRunnerConfig(
  273. server_address, server_port, server_path, server_arguments, websocket_runner_hooks)
  274. runner = WebSocketRunner(websocket_runner_config)
  275. loop = asyncio.get_event_loop()
  276. return loop.run_until_complete(runner.run(parser_group.builder_config, runner_config))
  277. @runner_base.command()
  278. @test_runner_options
  279. @chip_repl_runner_options
  280. @pass_parser_group
  281. def chip_repl(parser_group: ParserGroup, adapter: str, stop_on_error: bool, stop_on_warning: bool, stop_at_number: int, show_adapter_logs: bool, show_adapter_logs_on_error: bool, use_test_harness_log_format: bool, delay_in_ms: int, runner: str, repl_storage_path: str, commission_on_network_dut: bool):
  282. """Run the test suite using chip-repl."""
  283. adapter = __import__(adapter, fromlist=[None]).Adapter(parser_group.builder_config.parser_config.definitions)
  284. runner_options = TestRunnerOptions(stop_on_error, stop_on_warning, stop_at_number, delay_in_ms)
  285. runner_hooks = TestRunnerLogger(show_adapter_logs, show_adapter_logs_on_error, use_test_harness_log_format)
  286. runner_config = TestRunnerConfig(adapter, parser_group.pseudo_clusters, runner_options, runner_hooks)
  287. runner = __import__(runner, fromlist=[None]).Runner(repl_storage_path, commission_on_network_dut)
  288. loop = asyncio.get_event_loop()
  289. return loop.run_until_complete(runner.run(parser_group.builder_config, runner_config))
  290. @runner_base.command()
  291. @test_runner_options
  292. @websocket_runner_options
  293. @click.pass_context
  294. def chiptool(ctx, *args, **kwargs):
  295. """Run the test suite using chip-tool."""
  296. return ctx.forward(websocket)
  297. @runner_base.command()
  298. @test_runner_options
  299. @websocket_runner_options
  300. @click.pass_context
  301. def darwinframeworktool(ctx, *args, **kwargs):
  302. """Run the test suite using darwin-framework-tool."""
  303. return ctx.forward(websocket)
  304. @runner_base.command()
  305. @test_runner_options
  306. @websocket_runner_options
  307. @click.pass_context
  308. def app1(ctx, *args, **kwargs):
  309. """Run the test suite using app1."""
  310. return ctx.forward(websocket)
  311. @runner_base.command()
  312. @test_runner_options
  313. @websocket_runner_options
  314. @click.pass_context
  315. def app2(ctx, *args, **kwargs):
  316. """Run the test suite using app2."""
  317. return ctx.forward(websocket)
  318. if __name__ == '__main__':
  319. success = False
  320. try:
  321. # By default click runs in standalone mode and it will handle exceptions and the
  322. # different commands return values for us. For example it will set sys.exit to
  323. # 0 if the test runs fails unless an exception is raised. Simple test failure
  324. # does not raise exception but we want to set the exit code to 1.
  325. # So standalone_mode is set to False to let us manage this exit behavior.
  326. success = runner_base(standalone_mode=False)
  327. except Exception:
  328. print('')
  329. traceback.print_exc()
  330. sys.exit(0 if success else 1)