robotcode-runner 2.6.2__py3-none-any.whl → 2.8.0.dev548__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,6 +29,7 @@ from .._markdown import (
29
29
  make_md_highlighter,
30
30
  md_escape,
31
31
  md_table,
32
+ metadata_md,
32
33
  path_paren,
33
34
  timing_suffix,
34
35
  )
@@ -51,7 +52,8 @@ from ._models import (
51
52
  # `show`); `LogTest` is the `log`-specific record carrying a body tree.
52
53
  # Both expose the fields the per-test renderers need (`full_name`,
53
54
  # `status`, `message`, `source`, `rel_source`, `lineno`, `elapsed_seconds`,
54
- # `start_time`), so we treat them interchangeably at the renderer level.
55
+ # `start_time`, `metadata`), so we treat them interchangeably at the renderer
56
+ # level.
55
57
  _TestLike = Union[TestResultItem, LogTest]
56
58
 
57
59
  _LEVEL_ORDER = {"TRACE": 0, "DEBUG": 1, "INFO": 2, "WARN": 3, "ERROR": 4, "FAIL": 5}
@@ -172,6 +174,7 @@ def render_show(
172
174
  data: ShowResult,
173
175
  *,
174
176
  show_tags: bool,
177
+ show_metadata: bool = False,
175
178
  full_paths: bool = False,
176
179
  show_timing: bool = False,
177
180
  sort_field: Optional[str] = None,
@@ -197,6 +200,8 @@ def render_show(
197
200
  if show_tags and t.tags:
198
201
  tags = ", ".join(highlight_md(md_escape(tag), highlight) for tag in t.tags)
199
202
  out.append(f" - _Tags:_ {tags}")
203
+ if show_metadata and t.metadata:
204
+ out.append(f" - _Metadata:_ {metadata_md(t.metadata, highlight)}")
200
205
 
201
206
  if data.truncated:
202
207
  out.append("")
@@ -490,6 +495,9 @@ def _test_header_md(
490
495
  head += _source_paren(t, full_paths=full_paths)
491
496
  head += f" {bold_status(t.status)}"
492
497
  head += timing_suffix(t.elapsed_seconds, t.start_time, show_timing=show_timing)
498
+ if t.metadata:
499
+ # a paragraph, not a list: a list would merge with the list of the test body below
500
+ head += f"\n\n_Metadata:_ {metadata_md(t.metadata, highlight)}"
493
501
  return head
494
502
 
495
503
 
@@ -16,7 +16,7 @@ All subcommands respect the global `-f/--format` option:
16
16
  import re
17
17
  import shutil
18
18
  from pathlib import Path
19
- from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, Iterator, List, Optional, Tuple
19
+ from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, Iterator, List, Optional, Set, Tuple
20
20
 
21
21
  import click
22
22
  from robot.api import SuiteVisitor
@@ -41,11 +41,11 @@ from robot.result import (
41
41
  TryBranch,
42
42
  While,
43
43
  )
44
- from robot.result.executionerrors import ExecutionErrors
45
44
  from robot.result.model import BodyItem, StatusMixin
46
45
  from robot.utils import normalize
47
46
 
48
- from robotcode.modifiers import ByLongName, ExcludedByLongName
47
+ from robotcode.modifiers import ByLongName, ByTestMetadata, ExcludedByLongName, ExcludedByTestMetadata
48
+ from robotcode.modifiers.metadata_modifiers import MetadataPattern
49
49
  from robotcode.plugin import Application, OutputFormat, pass_application
50
50
  from robotcode.plugin.click_helper.types import add_options
51
51
  from robotcode.robot.config.loader import load_robot_config_from_path
@@ -63,7 +63,15 @@ from robotcode.robot.utils import RF_VERSION
63
63
  if TYPE_CHECKING:
64
64
  from robot.result import Error, Group, Var # type: ignore[attr-defined,unused-ignore]
65
65
 
66
- from .._search import ByStatus, SearchMatcher, SearchModifier, make_search_matcher
66
+ from .._robot_version import RobotVersionOption
67
+ from .._search import (
68
+ ByStatus,
69
+ SearchMatcher,
70
+ SearchModifier,
71
+ get_test_metadata,
72
+ make_search_matcher,
73
+ named_metadata,
74
+ )
67
75
  from . import _html, _render
68
76
  from ._models import (
69
77
  ArtifactRef,
@@ -143,6 +151,27 @@ RESULT_FILTER_OPTIONS = [
143
151
  metavar="NAME",
144
152
  help="Exclude tests/tasks or suites by long name (exact match).",
145
153
  ),
154
+ click.option(
155
+ "-btm",
156
+ "--by-test-metadata",
157
+ "by_test_metadata",
158
+ multiple=True,
159
+ metavar="PATTERN",
160
+ # test metadata exists since Robot Framework 7.5
161
+ cls=RobotVersionOption,
162
+ since=(7, 5),
163
+ help="Select tests/tasks by their metadata, for example `Issue:4409 AND Author:Hans*`.",
164
+ ),
165
+ click.option(
166
+ "-ebtm",
167
+ "--exclude-by-test-metadata",
168
+ "exclude_by_test_metadata",
169
+ multiple=True,
170
+ metavar="PATTERN",
171
+ cls=RobotVersionOption,
172
+ since=(7, 5),
173
+ help="Exclude tests/tasks by their metadata. Same patterns as `--by-test-metadata`.",
174
+ ),
146
175
  ]
147
176
 
148
177
 
@@ -199,7 +228,8 @@ SEARCH_OPTIONS = [
199
228
  help=(
200
229
  "Only include tests with at least one case-insensitive substring "
201
230
  "match against TEXT. Searches test name, full name, failure "
202
- "message, documentation, template name, timeout, tags, the "
231
+ "message, documentation, template name, timeout, tags, test "
232
+ "metadata names and values, the "
203
233
  "parent suite's Documentation / Metadata, every executed "
204
234
  "keyword's name / arguments / [Documentation] / [Tags] / "
205
235
  "[Timeout] / failure message, and log messages. Mutually "
@@ -275,7 +305,7 @@ def results() -> None:
275
305
  "show_failed",
276
306
  default=False,
277
307
  show_default=True,
278
- help="Include the list of failed tests (with messages) above the counts table.",
308
+ help="Include the list of failed tests (with messages) below the counts table.",
279
309
  )
280
310
  @FULL_PATHS_OPTION
281
311
  @pass_application
@@ -288,6 +318,8 @@ def summary(
288
318
  test_globs: Tuple[str, ...],
289
319
  by_longname: Tuple[str, ...],
290
320
  exclude_by_longname: Tuple[str, ...],
321
+ by_test_metadata: Tuple[str, ...],
322
+ exclude_by_test_metadata: Tuple[str, ...],
291
323
  search_substring: Optional[str],
292
324
  search_regex: Optional[str],
293
325
  output_file: Optional[Path],
@@ -297,7 +329,7 @@ def summary(
297
329
  """\
298
330
  Print headline counts and overall status for a finished run.
299
331
 
300
- Pass `--failed` to also list failed tests above the counts.
332
+ Pass `--failed` to also list failed tests below the counts.
301
333
  Filter options narrow what is counted.
302
334
 
303
335
  \b
@@ -310,10 +342,14 @@ def summary(
310
342
  robotcode --format json results summary
311
343
  ```
312
344
  """
345
+ # before the result file is read, so an invalid pattern fails at once
346
+ matcher = make_search_matcher(search_substring, search_regex)
313
347
  profile, root_folder = _resolve_profile(app)
314
348
  with app.chdir(root_folder):
315
349
  path = _resolve_output_file(app, profile, output_file)
316
350
  execution = _load_execution_result(path)
351
+ # Before the tree filters, which would turn the copies of filtered-out messages into execution messages.
352
+ execution_messages = _execution_messages(execution)
317
353
 
318
354
  filters_active = bool(
319
355
  status_filters
@@ -323,10 +359,11 @@ def summary(
323
359
  or test_globs
324
360
  or by_longname
325
361
  or exclude_by_longname
362
+ or by_test_metadata
363
+ or exclude_by_test_metadata
326
364
  or search_substring
327
365
  or search_regex
328
366
  )
329
- matcher = make_search_matcher(search_substring, search_regex)
330
367
  _apply_tree_filters(
331
368
  execution.suite,
332
369
  include_tags,
@@ -337,11 +374,13 @@ def summary(
337
374
  exclude_by_longname,
338
375
  status_filters,
339
376
  matcher,
377
+ by_test_metadata=by_test_metadata,
378
+ exclude_by_test_metadata=exclude_by_test_metadata,
340
379
  )
341
380
  counts = _collect_counts(execution.suite)
342
381
  failed = _collect_failures(execution.suite) if show_failed else None
343
- exec_msg_counts = _count_execution_messages(execution.errors)
344
- msg_counts = _count_all_messages(execution)
382
+ exec_msg_counts = _count_execution_messages(execution_messages)
383
+ msg_counts = _count_all_messages(execution.suite, execution_messages)
345
384
 
346
385
  data = SummaryResult(
347
386
  file=_make_file_info(path),
@@ -363,6 +402,8 @@ def summary(
363
402
  exclude_by_longname,
364
403
  search_substring,
365
404
  search_regex,
405
+ by_test_metadata=by_test_metadata,
406
+ exclude_by_test_metadata=exclude_by_test_metadata,
366
407
  )
367
408
  if filters_active
368
409
  else None,
@@ -393,12 +434,22 @@ def summary(
393
434
  )
394
435
  @FULL_PATHS_OPTION
395
436
  @click.option(
396
- "--tags/--no-tags",
437
+ "--show-tags/--no-show-tags",
397
438
  "show_tags",
398
439
  default=False,
399
440
  show_default=True,
400
441
  help="Append the tag list after each test.",
401
442
  )
443
+ @click.option(
444
+ "--show-metadata/--no-show-metadata",
445
+ "show_metadata",
446
+ default=False,
447
+ show_default=True,
448
+ # test metadata exists since Robot Framework 7.5
449
+ cls=RobotVersionOption,
450
+ since=(7, 5),
451
+ help="Append the metadata after each test.",
452
+ )
402
453
  @click.option(
403
454
  "--timing/--no-timing",
404
455
  "show_timing",
@@ -437,6 +488,8 @@ def show(
437
488
  test_globs: Tuple[str, ...],
438
489
  by_longname: Tuple[str, ...],
439
490
  exclude_by_longname: Tuple[str, ...],
491
+ by_test_metadata: Tuple[str, ...],
492
+ exclude_by_test_metadata: Tuple[str, ...],
440
493
  shortcut_failed: bool,
441
494
  shortcut_passed: bool,
442
495
  shortcut_skipped: bool,
@@ -447,6 +500,7 @@ def show(
447
500
  message_chars: int,
448
501
  full_paths: bool,
449
502
  show_tags: bool,
503
+ show_metadata: bool,
450
504
  show_timing: bool,
451
505
  sort_field: Optional[str],
452
506
  reverse: bool,
@@ -462,20 +516,21 @@ def show(
462
516
  ```
463
517
  robotcode results show
464
518
  robotcode results show --failed
465
- robotcode results show --failed --skipped --tags
519
+ robotcode results show --failed --skipped --show-tags
466
520
  robotcode results show -i smoke -e wipANDnotready
467
521
  robotcode results show -s "MyProject.Login.*"
468
522
  robotcode results show --top 20
469
523
  robotcode results show --search "AssertionError"
470
524
  ```
471
525
  """
526
+ # before the result file is read, so an invalid pattern fails at once
527
+ matcher = make_search_matcher(search_substring, search_regex)
472
528
  profile, root_folder = _resolve_profile(app)
473
529
  with app.chdir(root_folder):
474
530
  path = _resolve_output_file(app, profile, output_file)
475
531
  execution = _load_execution_result(path)
476
532
 
477
533
  status_filters = _apply_status_shortcuts(status_filters, shortcut_failed, shortcut_passed, shortcut_skipped)
478
- matcher = make_search_matcher(search_substring, search_regex)
479
534
  _apply_tree_filters(
480
535
  execution.suite,
481
536
  include_tags,
@@ -486,6 +541,8 @@ def show(
486
541
  exclude_by_longname,
487
542
  status_filters,
488
543
  matcher,
544
+ by_test_metadata=by_test_metadata,
545
+ exclude_by_test_metadata=exclude_by_test_metadata,
489
546
  )
490
547
 
491
548
  all_items = [_make_test_item(t, message_chars=message_chars) for t in _iter_all_tests(execution.suite)]
@@ -506,6 +563,8 @@ def show(
506
563
  or test_globs
507
564
  or by_longname
508
565
  or exclude_by_longname
566
+ or by_test_metadata
567
+ or exclude_by_test_metadata
509
568
  or search_substring
510
569
  or search_regex
511
570
  )
@@ -524,6 +583,8 @@ def show(
524
583
  exclude_by_longname,
525
584
  search_substring,
526
585
  search_regex,
586
+ by_test_metadata=by_test_metadata,
587
+ exclude_by_test_metadata=exclude_by_test_metadata,
527
588
  )
528
589
  if filters_active
529
590
  else None,
@@ -537,6 +598,7 @@ def show(
537
598
  _render.render_show(
538
599
  data,
539
600
  show_tags=show_tags,
601
+ show_metadata=show_metadata,
540
602
  full_paths=full_paths,
541
603
  show_timing=show_timing,
542
604
  sort_field=sort_field,
@@ -619,7 +681,10 @@ def show(
619
681
  "show_execution_messages",
620
682
  default=False,
621
683
  show_default=True,
622
- help=("Also show parser/discovery messages from output.xml's `<errors>` section (deduplicated)."),
684
+ help=(
685
+ "Also show the messages of output.xml's `<errors>` section that come from outside the tests, "
686
+ "such as parsing and import errors."
687
+ ),
623
688
  )
624
689
  @click.option(
625
690
  "--keyword-info/--no-keyword-info",
@@ -653,6 +718,8 @@ def log(
653
718
  test_globs: Tuple[str, ...],
654
719
  by_longname: Tuple[str, ...],
655
720
  exclude_by_longname: Tuple[str, ...],
721
+ by_test_metadata: Tuple[str, ...],
722
+ exclude_by_test_metadata: Tuple[str, ...],
656
723
  shortcut_failed: bool,
657
724
  shortcut_passed: bool,
658
725
  shortcut_skipped: bool,
@@ -690,13 +757,16 @@ def log(
690
757
  robotcode results log --execution-messages
691
758
  ```
692
759
  """
760
+ # before the result file is read, so an invalid pattern fails at once
761
+ matcher = make_search_matcher(search_substring, search_regex)
693
762
  profile, root_folder = _resolve_profile(app)
694
763
  with app.chdir(root_folder):
695
764
  path = _resolve_output_file(app, profile, output_file)
696
765
  execution = _load_execution_result(path)
766
+ # Before the tree filters, which would turn the copies of filtered-out messages into execution messages.
767
+ execution_messages = _execution_messages(execution) if show_execution_messages else []
697
768
 
698
769
  status_filters = _apply_status_shortcuts(status_filters, shortcut_failed, shortcut_passed, shortcut_skipped)
699
- matcher = make_search_matcher(search_substring, search_regex)
700
770
  _apply_tree_filters(
701
771
  execution.suite,
702
772
  include_tags,
@@ -707,6 +777,8 @@ def log(
707
777
  exclude_by_longname,
708
778
  status_filters,
709
779
  matcher,
780
+ by_test_metadata=by_test_metadata,
781
+ exclude_by_test_metadata=exclude_by_test_metadata,
710
782
  )
711
783
 
712
784
  collector = _LogCollector(
@@ -721,7 +793,7 @@ def log(
721
793
 
722
794
  exec_messages: Optional[List[LogEntry]] = None
723
795
  if show_execution_messages:
724
- exec_messages = _collect_execution_messages(execution.errors, raw_html=raw_html) or None
796
+ exec_messages = _collect_execution_messages(execution_messages, raw_html=raw_html) or None
725
797
 
726
798
  extracted_count = 0
727
799
  extract_abs: Optional[Path] = None
@@ -738,6 +810,8 @@ def log(
738
810
  or test_globs
739
811
  or by_longname
740
812
  or exclude_by_longname
813
+ or by_test_metadata
814
+ or exclude_by_test_metadata
741
815
  or search_substring
742
816
  or search_regex
743
817
  )
@@ -761,6 +835,8 @@ def log(
761
835
  exclude_by_longname,
762
836
  search_substring,
763
837
  search_regex,
838
+ by_test_metadata=by_test_metadata,
839
+ exclude_by_test_metadata=exclude_by_test_metadata,
764
840
  )
765
841
  if filters_active
766
842
  else None,
@@ -821,6 +897,8 @@ def stats(
821
897
  test_globs: Tuple[str, ...],
822
898
  by_longname: Tuple[str, ...],
823
899
  exclude_by_longname: Tuple[str, ...],
900
+ by_test_metadata: Tuple[str, ...],
901
+ exclude_by_test_metadata: Tuple[str, ...],
824
902
  shortcut_failed: bool,
825
903
  shortcut_passed: bool,
826
904
  shortcut_skipped: bool,
@@ -848,13 +926,14 @@ def stats(
848
926
  robotcode --format json results stats --by tag
849
927
  ```
850
928
  """
929
+ # before the result file is read, so an invalid pattern fails at once
930
+ matcher = make_search_matcher(search_substring, search_regex)
851
931
  profile, root_folder = _resolve_profile(app)
852
932
  with app.chdir(root_folder):
853
933
  path = _resolve_output_file(app, profile, output_file)
854
934
  execution = _load_execution_result(path)
855
935
 
856
936
  status_filters = _apply_status_shortcuts(status_filters, shortcut_failed, shortcut_passed, shortcut_skipped)
857
- matcher = make_search_matcher(search_substring, search_regex)
858
937
  _apply_tree_filters(
859
938
  execution.suite,
860
939
  include_tags,
@@ -865,6 +944,8 @@ def stats(
865
944
  exclude_by_longname,
866
945
  status_filters,
867
946
  matcher,
947
+ by_test_metadata=by_test_metadata,
948
+ exclude_by_test_metadata=exclude_by_test_metadata,
868
949
  )
869
950
  tests = list(_iter_all_tests(execution.suite))
870
951
 
@@ -884,6 +965,8 @@ def stats(
884
965
  or test_globs
885
966
  or by_longname
886
967
  or exclude_by_longname
968
+ or by_test_metadata
969
+ or exclude_by_test_metadata
887
970
  or search_substring
888
971
  or search_regex
889
972
  )
@@ -900,6 +983,8 @@ def stats(
900
983
  exclude_by_longname,
901
984
  search_substring,
902
985
  search_regex,
986
+ by_test_metadata=by_test_metadata,
987
+ exclude_by_test_metadata=exclude_by_test_metadata,
903
988
  )
904
989
  if filters_active
905
990
  else None,
@@ -1003,6 +1088,8 @@ def diff(
1003
1088
  test_globs: Tuple[str, ...],
1004
1089
  by_longname: Tuple[str, ...],
1005
1090
  exclude_by_longname: Tuple[str, ...],
1091
+ by_test_metadata: Tuple[str, ...],
1092
+ exclude_by_test_metadata: Tuple[str, ...],
1006
1093
  search_substring: Optional[str],
1007
1094
  search_regex: Optional[str],
1008
1095
  full_paths: bool,
@@ -1026,6 +1113,8 @@ def diff(
1026
1113
  robotcode --format json results diff baseline.xml
1027
1114
  ```
1028
1115
  """
1116
+ # before the result file is read, so an invalid pattern fails at once
1117
+ matcher = make_search_matcher(search_substring, search_regex)
1029
1118
  profile, root_folder = _resolve_profile(app)
1030
1119
  with app.chdir(root_folder):
1031
1120
  baseline_path = baseline.resolve()
@@ -1037,7 +1126,6 @@ def diff(
1037
1126
  baseline_exec = _load_execution_result(baseline_path)
1038
1127
  current_exec = _load_execution_result(current_path)
1039
1128
 
1040
- matcher = make_search_matcher(search_substring, search_regex)
1041
1129
  for suite in (baseline_exec.suite, current_exec.suite):
1042
1130
  _apply_tree_filters(
1043
1131
  suite,
@@ -1049,6 +1137,8 @@ def diff(
1049
1137
  exclude_by_longname,
1050
1138
  status_filters,
1051
1139
  matcher,
1140
+ by_test_metadata=by_test_metadata,
1141
+ exclude_by_test_metadata=exclude_by_test_metadata,
1052
1142
  )
1053
1143
 
1054
1144
  baseline_tests = {_get_full_name(t): t for t in _iter_all_tests(baseline_exec.suite)}
@@ -1089,6 +1179,8 @@ def diff(
1089
1179
  or test_globs
1090
1180
  or by_longname
1091
1181
  or exclude_by_longname
1182
+ or by_test_metadata
1183
+ or exclude_by_test_metadata
1092
1184
  or search_substring
1093
1185
  or search_regex
1094
1186
  )
@@ -1110,6 +1202,8 @@ def diff(
1110
1202
  exclude_by_longname,
1111
1203
  search_substring,
1112
1204
  search_regex,
1205
+ by_test_metadata=by_test_metadata,
1206
+ exclude_by_test_metadata=exclude_by_test_metadata,
1113
1207
  )
1114
1208
  if filters_active
1115
1209
  else None,
@@ -1246,12 +1340,27 @@ def _candidate_paths(*, search_root: Path, out_dir: Path, out_name: str) -> List
1246
1340
  return cands
1247
1341
 
1248
1342
 
1343
+ # Robot Framework < 7.5 fails with one of these messages on a result file
1344
+ # that carries test-level `[Metadata]`: the first for `output.xml`, the
1345
+ # second for `output.json` (RF 7.2-7.4).
1346
+ _TEST_METADATA_READ_ERRORS = (
1347
+ "Incompatible child element 'meta' for 'test'",
1348
+ "'robot.result.TestCase' object does not have attribute 'metadata'",
1349
+ )
1350
+
1351
+
1352
+ def _test_metadata_hint(error: str) -> str:
1353
+ if not any(text in error for text in _TEST_METADATA_READ_ERRORS):
1354
+ return ""
1355
+ return (
1356
+ "\nThe file was written by Robot Framework 7.5 or newer and contains test metadata; "
1357
+ "reading it requires Robot Framework 7.5+."
1358
+ )
1359
+
1360
+
1249
1361
  def _load_execution_result(path: Path) -> Result:
1250
1362
  if path.suffix.lower() == ".json" and RF_VERSION < (7, 0):
1251
- raise click.ClickException(
1252
- f"Reading JSON result files requires Robot Framework 7.0+; "
1253
- f"got {'.'.join(str(v) for v in RF_VERSION)} (file: {path})."
1254
- )
1363
+ raise click.ClickException(f"Reading JSON result files requires Robot Framework 7.0+ (file: {path}).")
1255
1364
  try:
1256
1365
  from robot.api import ExecutionResult
1257
1366
  except ImportError as e:
@@ -1259,7 +1368,7 @@ def _load_execution_result(path: Path) -> Result:
1259
1368
  try:
1260
1369
  return ExecutionResult(str(path))
1261
1370
  except DataError as e:
1262
- raise click.ClickException(f"failed to parse {path}: {e}") from e
1371
+ raise click.ClickException(f"failed to parse {path}: {e}{_test_metadata_hint(str(e))}") from e
1263
1372
  except Exception as e:
1264
1373
  raise click.ClickException(f"failed to parse {path}: {e}") from e
1265
1374
 
@@ -1370,16 +1479,21 @@ def _apply_tree_filters(
1370
1479
  exclude_by_longname: Tuple[str, ...] = (),
1371
1480
  status_filters: Tuple[str, ...] = (),
1372
1481
  matcher: Optional[SearchMatcher] = None,
1482
+ by_test_metadata: Tuple[str, ...] = (),
1483
+ exclude_by_test_metadata: Tuple[str, ...] = (),
1373
1484
  ) -> None:
1374
1485
  """Apply every filter to the result tree in-place.
1375
1486
 
1376
1487
  Delegates the standard Robot filters (`--include`/`--exclude`,
1377
1488
  `--suite`, `--test`) to `TestSuite.filter()`, then bundles the
1378
- project-specific filters (`-bl`/`-ebl`, `--status`, search) into a
1379
- single `ModelModifier` pass so subsequent code can iterate the
1380
- suite naturally — every surviving test has passed every filter.
1489
+ project-specific filters (`-bl`/`-ebl`, `-btm`/`-ebtm`, `--status`,
1490
+ search) into a single `ModelModifier` pass so subsequent code can
1491
+ iterate the suite naturally — every surviving test has passed every
1492
+ filter.
1381
1493
  """
1382
1494
  try:
1495
+ test_metadata_patterns = [MetadataPattern(p) for p in by_test_metadata]
1496
+ excluded_test_metadata_patterns = [MetadataPattern(p) for p in exclude_by_test_metadata]
1383
1497
  suite.filter(
1384
1498
  included_suites=list(suite_globs) or None,
1385
1499
  included_tests=list(test_globs) or None,
@@ -1394,6 +1508,10 @@ def _apply_tree_filters(
1394
1508
  modifiers.append(ByLongName(*by_longname))
1395
1509
  if exclude_by_longname:
1396
1510
  modifiers.append(ExcludedByLongName(*exclude_by_longname))
1511
+ if test_metadata_patterns:
1512
+ modifiers.append(ByTestMetadata(*test_metadata_patterns))
1513
+ if excluded_test_metadata_patterns:
1514
+ modifiers.append(ExcludedByTestMetadata(*excluded_test_metadata_patterns))
1397
1515
  if matcher is not None:
1398
1516
  modifiers.append(SearchModifier(matcher))
1399
1517
  if status_filters:
@@ -1435,6 +1553,8 @@ def _filters_dict(
1435
1553
  exclude_by_longname: Tuple[str, ...] = (),
1436
1554
  search_substring: Optional[str] = None,
1437
1555
  search_regex: Optional[str] = None,
1556
+ by_test_metadata: Tuple[str, ...] = (),
1557
+ exclude_by_test_metadata: Tuple[str, ...] = (),
1438
1558
  ) -> Dict[str, List[str]]:
1439
1559
  out: Dict[str, List[str]] = {}
1440
1560
  if status_filters:
@@ -1451,6 +1571,11 @@ def _filters_dict(
1451
1571
  out["by-longname"] = list(by_longname)
1452
1572
  if exclude_by_longname:
1453
1573
  out["exclude-by-longname"] = list(exclude_by_longname)
1574
+ # Metadata patterns are echoed as given, unlike the canonicalised tag patterns.
1575
+ if by_test_metadata:
1576
+ out["by-test-metadata"] = list(by_test_metadata)
1577
+ if exclude_by_test_metadata:
1578
+ out["exclude-by-test-metadata"] = list(exclude_by_test_metadata)
1454
1579
  if search_substring:
1455
1580
  out["search"] = [search_substring]
1456
1581
  if search_regex:
@@ -1549,6 +1674,7 @@ def _make_test_item(test: TestCase, *, message_chars: int) -> TestResultItem:
1549
1674
  message=truncated_msg,
1550
1675
  full_message=full_msg,
1551
1676
  tags=[normalize(str(t), ignore="_") for t in test.tags] if test.tags else None,
1677
+ metadata=get_test_metadata(test) or None,
1552
1678
  elapsed_seconds=_elapsed_seconds(test),
1553
1679
  start_time=_iso(_start_time(test)),
1554
1680
  source=src_str,
@@ -1562,12 +1688,40 @@ def _collect_failures(suite: TestSuite) -> List[TestResultItem]:
1562
1688
  return [_make_test_item(t, message_chars=0) for t in _iter_all_tests(suite) if t.status == "FAIL"]
1563
1689
 
1564
1690
 
1565
- def _count_execution_messages(errors: Optional[ExecutionErrors]) -> Dict[str, int]:
1566
- """Count execution.errors messages grouped by level. Returns empty dict if none."""
1567
- if errors is None:
1568
- return {}
1691
+ class _LoggedMessages(ResultVisitor):
1692
+ """Collect the WARN/ERROR messages of a result tree by the key Robot Framework links them with."""
1693
+
1694
+ def __init__(self) -> None:
1695
+ super().__init__()
1696
+ self.keys: Set[Tuple[Any, ...]] = set()
1697
+
1698
+ def visit_message(self, msg: Message) -> None:
1699
+ if (msg.level or "").upper() in ("WARN", "ERROR"):
1700
+ self.keys.add(_message_key(msg))
1701
+
1702
+
1703
+ def _message_key(msg: Message) -> Tuple[Any, ...]:
1704
+ return (msg.message, msg.level, msg.timestamp)
1705
+
1706
+
1707
+ def _execution_messages(execution: Result) -> List[Message]:
1708
+ """The messages of `output.xml`'s <errors> section that come from outside the tests, such as parsing and
1709
+ import errors.
1710
+
1711
+ Robot Framework also copies every WARN and ERROR message logged by a test or keyword into <errors> and links the
1712
+ copy to its original by message, level and timestamp. Those copies are left out here, matched the same way.
1713
+ Call this before the suite tree is filtered."""
1714
+ if execution.errors is None:
1715
+ return []
1716
+ logged = _LoggedMessages()
1717
+ execution.suite.visit(logged)
1718
+ return [m for m in execution.errors.messages if _message_key(m) not in logged.keys]
1719
+
1720
+
1721
+ def _count_execution_messages(messages: List[Message]) -> Dict[str, int]:
1722
+ """Count execution messages grouped by level. Returns empty dict if none."""
1569
1723
  counts: Dict[str, int] = {}
1570
- for m in errors.messages:
1724
+ for m in messages:
1571
1725
  level = (m.level or "INFO").upper()
1572
1726
  counts[level] = counts.get(level, 0) + 1
1573
1727
  return counts
@@ -1586,24 +1740,21 @@ class _MessageCounter(ResultVisitor):
1586
1740
  self.counts[level] = self.counts.get(level, 0) + 1
1587
1741
 
1588
1742
 
1589
- def _count_all_messages(execution: Result) -> Dict[str, int]:
1590
- """Tally WARN/ERROR/FAIL across parser/discovery AND test runtime messages."""
1743
+ def _count_all_messages(suite: TestSuite, execution_messages: List[Message]) -> Dict[str, int]:
1744
+ """Tally WARN/ERROR/FAIL across the test runtime messages and the execution messages, each message once."""
1591
1745
  counter = _MessageCounter()
1592
- # Parser / discovery errors live next to the suite tree, not inside it.
1593
- for m in execution.errors.messages:
1746
+ for m in execution_messages:
1594
1747
  counter.visit_message(m)
1595
1748
  # Runtime messages — the visitor handles all suite/test/keyword traversal.
1596
- execution.suite.visit(counter)
1749
+ suite.visit(counter)
1597
1750
  return counter.counts
1598
1751
 
1599
1752
 
1600
- def _collect_execution_messages(errors: Optional[ExecutionErrors], *, raw_html: bool = False) -> List[LogEntry]:
1601
- """Collect parser/discovery messages from `output.xml`'s <errors> section
1602
- as LogEntry items, preserving order and individual timestamps."""
1603
- if errors is None:
1604
- return []
1753
+ def _collect_execution_messages(messages: List[Message], *, raw_html: bool = False) -> List[LogEntry]:
1754
+ """Collect execution messages (see `_execution_messages`) as LogEntry items, preserving order and individual
1755
+ timestamps."""
1605
1756
  out: List[LogEntry] = []
1606
- for m in errors.messages:
1757
+ for m in messages:
1607
1758
  level = (m.level or "INFO").upper()
1608
1759
  text = m.message or ""
1609
1760
  is_html = bool(m.html)
@@ -1672,7 +1823,7 @@ class _LogCollector(ResultVisitor):
1672
1823
  name=suite.name,
1673
1824
  status=suite.status,
1674
1825
  doc=suite.doc or None,
1675
- metadata=dict(suite.metadata) if suite.metadata else None,
1826
+ metadata=named_metadata(suite.metadata) or None,
1676
1827
  source=src_str,
1677
1828
  rel_source=_rel_to_cwd(src_str),
1678
1829
  elapsed_seconds=_elapsed_seconds(suite),
@@ -1707,6 +1858,7 @@ class _LogCollector(ResultVisitor):
1707
1858
  elapsed_seconds=_elapsed_seconds(test),
1708
1859
  start_time=_iso(_start_time(test)),
1709
1860
  suite=parent_full_name,
1861
+ metadata=get_test_metadata(test) or None,
1710
1862
  )
1711
1863
  )
1712
1864