test_result.py 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. # Copyright Materialize, Inc. and contributors. All rights reserved.
  2. #
  3. # Use of this software is governed by the Business Source License
  4. # included in the LICENSE file at the root of this repository.
  5. #
  6. # As of the Change Date specified in that file, in accordance with
  7. # the Business Source License, use of this software will be governed
  8. # by the Apache License, Version 2.0.
  9. from __future__ import annotations
  10. import re
  11. from dataclasses import dataclass
  12. from materialize import MZ_ROOT
  13. from materialize.ui import CommandFailureCausedUIError, UIError
  14. PEM_CONTENT_RE = r"-----BEGIN ([A-Z ]+)-----[^-]+-----END [A-Z ]+-----"
  15. PEM_CONTENT_REPLACEMENT = r"<\1>"
  16. @dataclass
  17. class TestResult:
  18. __test__ = False
  19. duration: float
  20. errors: list[TestFailureDetails]
  21. def is_successful(self) -> bool:
  22. return len(self.errors) == 0
  23. @dataclass
  24. class TestFailureDetails:
  25. __test__ = False
  26. message: str
  27. details: str | None
  28. additional_details_header: str | None = None
  29. additional_details: str | None = None
  30. test_class_name_override: str | None = None
  31. """The test class usually describes the framework."""
  32. test_case_name_override: str | None = None
  33. """The test case usually describes the workflow, unless more fine-grained information is available."""
  34. location: str | None = None
  35. """depending on the check, this may either be a file name or a path"""
  36. line_number: int | None = None
  37. def location_as_file_name(self) -> str | None:
  38. if self.location is None:
  39. return None
  40. if "/" in self.location:
  41. return self.location[self.location.rindex("/") + 1 :]
  42. return self.location
  43. class FailedTestExecutionError(UIError):
  44. """
  45. An UIError that is caused by a failing test.
  46. """
  47. def __init__(
  48. self,
  49. errors: list[TestFailureDetails],
  50. error_summary: str = "At least one test failed",
  51. ):
  52. super().__init__(error_summary)
  53. self.errors = errors
  54. def try_determine_errors_from_cmd_execution(
  55. e: CommandFailureCausedUIError, test_context: str | None
  56. ) -> list[TestFailureDetails]:
  57. output = e.stderr or e.stdout
  58. if "running docker compose failed" in str(e):
  59. return [determine_error_from_docker_compose_failure(e, output, test_context)]
  60. if output is None:
  61. return []
  62. error_chunks = extract_error_chunks_from_output(output)
  63. collected_errors = []
  64. for chunk in error_chunks:
  65. match = re.search(r"([^.]+\.td):(\d+):\d+:", chunk)
  66. if match is not None:
  67. # for .td files like Postgres CDC, file_path will just contain the file name
  68. file_path = match.group(1)
  69. line_number = int(match.group(2))
  70. else:
  71. # for .py files like platform checks, file_path will be a path
  72. file_path = try_determine_error_location_from_cmd(e.cmd)
  73. if file_path is None or ":" not in file_path:
  74. line_number = None
  75. else:
  76. parts = file_path.split(":")
  77. file_path = parts[0]
  78. line_number = int(parts[1])
  79. message = (
  80. f"Executing {file_path if file_path is not None else 'command'} failed!"
  81. )
  82. failure_details = TestFailureDetails(
  83. message,
  84. details=chunk,
  85. test_case_name_override=test_context,
  86. location=file_path,
  87. line_number=line_number,
  88. )
  89. if failure_details in collected_errors:
  90. # do not add an identical error again
  91. pass
  92. else:
  93. collected_errors.append(failure_details)
  94. return collected_errors
  95. def determine_error_from_docker_compose_failure(
  96. e: CommandFailureCausedUIError, output: str | None, test_context: str | None
  97. ) -> TestFailureDetails:
  98. command = to_sanitized_command_str(e.cmd)
  99. context_prefix = f"{test_context}: " if test_context is not None else ""
  100. return TestFailureDetails(
  101. f"{context_prefix}Docker compose failed: {command}",
  102. details=output,
  103. test_case_name_override=test_context,
  104. location=None,
  105. line_number=None,
  106. )
  107. def try_determine_error_location_from_cmd(cmd: list[str]) -> str | None:
  108. root_path_as_string = f"{MZ_ROOT}/"
  109. for cmd_part in cmd:
  110. if type(cmd_part) == str and cmd_part.startswith("--source="):
  111. return cmd_part.removeprefix("--source=").replace(root_path_as_string, "")
  112. return None
  113. def extract_error_chunks_from_output(output: str) -> list[str]:
  114. if "+++ !!! Error Report" not in output:
  115. return []
  116. error_output = output[: output.index("+++ !!! Error Report") - 1]
  117. error_chunks = error_output.split("^^^ +++")
  118. return [chunk.strip() for chunk in error_chunks if len(chunk.strip()) > 0]
  119. def to_sanitized_command_str(cmd: list[str]) -> str:
  120. command_str = " ".join([str(x) for x in cmd])
  121. return re.sub(PEM_CONTENT_RE, PEM_CONTENT_REPLACEMENT, command_str)