misc.python.materialize.mzcompose.test_result

  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
 10from __future__ import annotations
 11
 12import re
 13from dataclasses import dataclass
 14
 15from materialize import MZ_ROOT
 16from materialize.ui import CommandFailureCausedUIError, UIError
 17from materialize.util import filter_cmd
 18
 19PEM_CONTENT_RE = r"-----BEGIN ([A-Z ]+)-----[^-]+-----END [A-Z ]+-----"
 20PEM_CONTENT_REPLACEMENT = r"<\1>"
 21
 22
 23@dataclass
 24class TestResult:
 25    __test__ = False
 26
 27    duration: float
 28    errors: list[TestFailureDetails]
 29
 30    def is_successful(self) -> bool:
 31        return len(self.errors) == 0
 32
 33
 34@dataclass
 35class TestFailureDetails:
 36    __test__ = False
 37
 38    message: str
 39    details: str | None
 40    additional_details_header: str | None = None
 41    additional_details: str | None = None
 42    test_class_name_override: str | None = None
 43    """The test class usually describes the framework."""
 44    test_case_name_override: str | None = None
 45    """The test case usually describes the workflow, unless more fine-grained information is available."""
 46    location: str | None = None
 47    """depending on the check, this may either be a file name or a path"""
 48    line_number: int | None = None
 49
 50    def location_as_file_name(self) -> str | None:
 51        if self.location is None:
 52            return None
 53
 54        if "/" in self.location:
 55            return self.location[self.location.rindex("/") + 1 :]
 56
 57        return self.location
 58
 59
 60class FailedTestExecutionError(UIError):
 61    """
 62    An UIError that is caused by a failing test.
 63    """
 64
 65    def __init__(
 66        self,
 67        errors: list[TestFailureDetails],
 68        error_summary: str = "At least one test failed",
 69    ):
 70        super().__init__(error_summary)
 71        self.errors = errors
 72
 73
 74def try_determine_errors_from_cmd_execution(
 75    e: CommandFailureCausedUIError, test_context: str | None
 76) -> list[TestFailureDetails]:
 77    # Combine both streams: testdrive prints each marked error to stdout and
 78    # the final error report to stderr, so either stream alone is incomplete.
 79    output_parts = [s for s in [e.stdout, e.stderr] if s]
 80    output = "\n".join(output_parts) if output_parts else None
 81
 82    if "running docker compose failed" in str(e):
 83        return [determine_error_from_docker_compose_failure(e, output, test_context)]
 84
 85    if output is None:
 86        return []
 87
 88    error_chunks = extract_error_chunks_from_output(output)
 89
 90    fallback_file_path = try_determine_error_location_from_cmd(e.cmd)
 91    if fallback_file_path is not None and ":" in fallback_file_path:
 92        parts = fallback_file_path.split(":")
 93        fallback_file_path, fallback_line_number = parts[0], int(parts[1])
 94    else:
 95        fallback_line_number = None
 96
 97    collected_errors = []
 98    for chunk in error_chunks:
 99        match = re.search(r"([^.]+\.td):(\d+):\d+:", chunk)
100        if match is not None:
101            # for .td files like Postgres CDC, file_path will just contain the file name
102            file_path = match.group(1)
103            line_number = int(match.group(2))
104        else:
105            # for .py files like platform checks, file_path will be a path
106            file_path = fallback_file_path
107            line_number = fallback_line_number
108
109        message = (
110            f"Executing {file_path if file_path is not None else 'command'} failed!"
111        )
112
113        failure_details = TestFailureDetails(
114            message,
115            details=chunk,
116            test_case_name_override=test_context,
117            location=file_path,
118            line_number=line_number,
119        )
120
121        if failure_details not in collected_errors:
122            collected_errors.append(failure_details)
123
124    return collected_errors
125
126
127def determine_error_from_docker_compose_failure(
128    e: CommandFailureCausedUIError, output: str | None, test_context: str | None
129) -> TestFailureDetails:
130    command = to_sanitized_command_str(e.cmd)
131    context_prefix = f"{test_context}: " if test_context is not None else ""
132    return TestFailureDetails(
133        f"{context_prefix}Docker compose failed: {command}",
134        details=output,
135        test_case_name_override=test_context,
136        location=None,
137        line_number=None,
138    )
139
140
141def try_determine_error_location_from_cmd(cmd: list[str]) -> str | None:
142    root_path_as_string = f"{MZ_ROOT}/"
143    for cmd_part in cmd:
144        if type(cmd_part) == str and cmd_part.startswith("--source="):
145            return cmd_part.removeprefix("--source=").replace(root_path_as_string, "")
146
147    return None
148
149
150def extract_error_chunks_from_output(output: str) -> list[str]:
151    pos = output.find("+++ !!! Error Report")
152    if pos == -1:
153        return []
154
155    error_output = output[:pos]
156    # Testdrive prints "^^^ +++" *before* each error, so everything up to the
157    # first marker is regular output, not an error.
158    error_chunks = error_output.split("^^^ +++")[1:]
159
160    return [chunk.strip() for chunk in error_chunks if len(chunk.strip()) > 0]
161
162
163def to_sanitized_command_str(cmd: list[str]) -> str:
164    # Ensure all elements are strings (cmd may contain Path objects)
165    str_cmd = [str(x) for x in cmd]
166    command_str = " ".join(filter_cmd(str_cmd))
167    return re.sub(PEM_CONTENT_RE, PEM_CONTENT_REPLACEMENT, command_str)
PEM_CONTENT_RE = '-----BEGIN ([A-Z ]+)-----[^-]+-----END [A-Z ]+-----'
PEM_CONTENT_REPLACEMENT = '<\\1>'
@dataclass
class TestResult:
24@dataclass
25class TestResult:
26    __test__ = False
27
28    duration: float
29    errors: list[TestFailureDetails]
30
31    def is_successful(self) -> bool:
32        return len(self.errors) == 0
TestResult( duration: float, errors: list[TestFailureDetails])
duration: float
errors: list[TestFailureDetails]
def is_successful(self) -> bool:
31    def is_successful(self) -> bool:
32        return len(self.errors) == 0
@dataclass
class TestFailureDetails:
35@dataclass
36class TestFailureDetails:
37    __test__ = False
38
39    message: str
40    details: str | None
41    additional_details_header: str | None = None
42    additional_details: str | None = None
43    test_class_name_override: str | None = None
44    """The test class usually describes the framework."""
45    test_case_name_override: str | None = None
46    """The test case usually describes the workflow, unless more fine-grained information is available."""
47    location: str | None = None
48    """depending on the check, this may either be a file name or a path"""
49    line_number: int | None = None
50
51    def location_as_file_name(self) -> str | None:
52        if self.location is None:
53            return None
54
55        if "/" in self.location:
56            return self.location[self.location.rindex("/") + 1 :]
57
58        return self.location
TestFailureDetails( message: str, details: str | None, additional_details_header: str | None = None, additional_details: str | None = None, test_class_name_override: str | None = None, test_case_name_override: str | None = None, location: str | None = None, line_number: int | None = None)
message: str
details: str | None
additional_details_header: str | None = None
additional_details: str | None = None
test_class_name_override: str | None = None

The test class usually describes the framework.

test_case_name_override: str | None = None

The test case usually describes the workflow, unless more fine-grained information is available.

location: str | None = None

depending on the check, this may either be a file name or a path

line_number: int | None = None
def location_as_file_name(self) -> str | None:
51    def location_as_file_name(self) -> str | None:
52        if self.location is None:
53            return None
54
55        if "/" in self.location:
56            return self.location[self.location.rindex("/") + 1 :]
57
58        return self.location
class FailedTestExecutionError(materialize.ui.UIError):
61class FailedTestExecutionError(UIError):
62    """
63    An UIError that is caused by a failing test.
64    """
65
66    def __init__(
67        self,
68        errors: list[TestFailureDetails],
69        error_summary: str = "At least one test failed",
70    ):
71        super().__init__(error_summary)
72        self.errors = errors

An UIError that is caused by a failing test.

FailedTestExecutionError( errors: list[TestFailureDetails], error_summary: str = 'At least one test failed')
66    def __init__(
67        self,
68        errors: list[TestFailureDetails],
69        error_summary: str = "At least one test failed",
70    ):
71        super().__init__(error_summary)
72        self.errors = errors
errors
def try_determine_errors_from_cmd_execution( e: materialize.ui.CommandFailureCausedUIError, test_context: str | None) -> list[TestFailureDetails]:
 75def try_determine_errors_from_cmd_execution(
 76    e: CommandFailureCausedUIError, test_context: str | None
 77) -> list[TestFailureDetails]:
 78    # Combine both streams: testdrive prints each marked error to stdout and
 79    # the final error report to stderr, so either stream alone is incomplete.
 80    output_parts = [s for s in [e.stdout, e.stderr] if s]
 81    output = "\n".join(output_parts) if output_parts else None
 82
 83    if "running docker compose failed" in str(e):
 84        return [determine_error_from_docker_compose_failure(e, output, test_context)]
 85
 86    if output is None:
 87        return []
 88
 89    error_chunks = extract_error_chunks_from_output(output)
 90
 91    fallback_file_path = try_determine_error_location_from_cmd(e.cmd)
 92    if fallback_file_path is not None and ":" in fallback_file_path:
 93        parts = fallback_file_path.split(":")
 94        fallback_file_path, fallback_line_number = parts[0], int(parts[1])
 95    else:
 96        fallback_line_number = None
 97
 98    collected_errors = []
 99    for chunk in error_chunks:
100        match = re.search(r"([^.]+\.td):(\d+):\d+:", chunk)
101        if match is not None:
102            # for .td files like Postgres CDC, file_path will just contain the file name
103            file_path = match.group(1)
104            line_number = int(match.group(2))
105        else:
106            # for .py files like platform checks, file_path will be a path
107            file_path = fallback_file_path
108            line_number = fallback_line_number
109
110        message = (
111            f"Executing {file_path if file_path is not None else 'command'} failed!"
112        )
113
114        failure_details = TestFailureDetails(
115            message,
116            details=chunk,
117            test_case_name_override=test_context,
118            location=file_path,
119            line_number=line_number,
120        )
121
122        if failure_details not in collected_errors:
123            collected_errors.append(failure_details)
124
125    return collected_errors
def determine_error_from_docker_compose_failure( e: materialize.ui.CommandFailureCausedUIError, output: str | None, test_context: str | None) -> TestFailureDetails:
128def determine_error_from_docker_compose_failure(
129    e: CommandFailureCausedUIError, output: str | None, test_context: str | None
130) -> TestFailureDetails:
131    command = to_sanitized_command_str(e.cmd)
132    context_prefix = f"{test_context}: " if test_context is not None else ""
133    return TestFailureDetails(
134        f"{context_prefix}Docker compose failed: {command}",
135        details=output,
136        test_case_name_override=test_context,
137        location=None,
138        line_number=None,
139    )
def try_determine_error_location_from_cmd(cmd: list[str]) -> str | None:
142def try_determine_error_location_from_cmd(cmd: list[str]) -> str | None:
143    root_path_as_string = f"{MZ_ROOT}/"
144    for cmd_part in cmd:
145        if type(cmd_part) == str and cmd_part.startswith("--source="):
146            return cmd_part.removeprefix("--source=").replace(root_path_as_string, "")
147
148    return None
def extract_error_chunks_from_output(output: str) -> list[str]:
151def extract_error_chunks_from_output(output: str) -> list[str]:
152    pos = output.find("+++ !!! Error Report")
153    if pos == -1:
154        return []
155
156    error_output = output[:pos]
157    # Testdrive prints "^^^ +++" *before* each error, so everything up to the
158    # first marker is regular output, not an error.
159    error_chunks = error_output.split("^^^ +++")[1:]
160
161    return [chunk.strip() for chunk in error_chunks if len(chunk.strip()) > 0]
def to_sanitized_command_str(cmd: list[str]) -> str:
164def to_sanitized_command_str(cmd: list[str]) -> str:
165    # Ensure all elements are strings (cmd may contain Path objects)
166    str_cmd = [str(x) for x in cmd]
167    command_str = " ".join(filter_cmd(str_cmd))
168    return re.sub(PEM_CONTENT_RE, PEM_CONTENT_REPLACEMENT, command_str)