Skip to content

Commit f63b6d9

Browse files
authored
Merge f1ec66c into 534cc53
2 parents 534cc53 + f1ec66c commit f63b6d9

File tree

5 files changed

+343
-1
lines changed

5 files changed

+343
-1
lines changed

.github/workflows/dataconnect.yml

+15
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ jobs:
6666
with:
6767
node-version: ${{ env.FDC_NODEJS_VERSION }}
6868

69+
- uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0
70+
with:
71+
python-version: ${{ env.FDC_PYTHON_VERSION }}
72+
73+
- run: pip install -r firebase-dataconnect/ci/requirements.txt
74+
6975
- name: Install Firebase Tools ("firebase" command-line tool)
7076
run: |
7177
set -euo pipefail
@@ -229,6 +235,15 @@ jobs:
229235
if: steps.connectedCheck.outcome != 'success'
230236
run: |
231237
set -euo pipefail
238+
239+
if [[ ! -e logcat.log ]] ; then
240+
echo "WARNING dsdta43sxk: logcat log file not found; skipping scanning for test failures" >&2
241+
else
242+
echo "Scanning logcat output for failure details"
243+
python firebase-dataconnect/ci/logcat_error_report.py --logcat-file=logcat.log
244+
echo
245+
fi
246+
232247
echo 'Failing because the outcome of the "Gradle connectedCheck" step ("${{ steps.connectedCheck.outcome }}") was not successful'
233248
exit 1
234249

firebase-dataconnect/ci/README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,5 @@ pip install -r requirements.txt
1818
Then, run all of these presubmit checks by running the following command:
1919

2020
```
21-
ruff check && ruff format && pyright && pytest && echo 'SUCCESS!!!!!!!!!!!!!!!'
21+
ruff check --fix && ruff format && pyright && pytest && echo 'SUCCESS!!!!!!!!!!!!!!!'
2222
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from __future__ import annotations
16+
17+
import argparse
18+
import dataclasses
19+
import logging
20+
import pathlib
21+
import re
22+
import tempfile
23+
import typing
24+
25+
if typing.TYPE_CHECKING:
26+
from _typeshed import SupportsWrite
27+
28+
TEST_STARTED_TOKEN = "TestRunner: started:" # noqa: S105
29+
TEST_STARTED_PATTERN = r"(\W|^)" + re.escape(TEST_STARTED_TOKEN) + r"\s+(?P<name>.*\S)"
30+
TEST_FAILED_TOKEN = "TestRunner: failed:" # noqa: S105
31+
TEST_FAILED_PATTERN = r"(\W|^)" + re.escape(TEST_FAILED_TOKEN) + r"\s+(?P<name>.*\S)"
32+
TEST_FINISHED_TOKEN = "TestRunner: finished:" # noqa: S105
33+
TEST_FINISHED_PATTERN = r"(\W|^)" + re.escape(TEST_FINISHED_TOKEN) + r"\s+(?P<name>.*\S)"
34+
35+
36+
@dataclasses.dataclass
37+
class TestResult:
38+
test_name: str
39+
output_file: pathlib.Path
40+
passed: bool
41+
42+
43+
def main() -> None:
44+
args = parse_args()
45+
logging.basicConfig(format="%(message)s", level=args.log_level)
46+
47+
if args.work_dir is None:
48+
work_temp_dir = tempfile.TemporaryDirectory("dd9rh9apdf")
49+
work_dir = pathlib.Path(work_temp_dir.name)
50+
logging.debug("Using temporary directory as work directory: %s", work_dir)
51+
else:
52+
work_temp_dir = None
53+
work_dir = args.work_dir
54+
logging.debug("Using specified directory as work directory: %s", work_dir)
55+
work_dir.mkdir(parents=True, exist_ok=True)
56+
57+
logging.info("Extracting test failures from %s", args.logcat_file)
58+
test_results: list[TestResult] = []
59+
cur_test_result: TestResult | None = None
60+
cur_test_result_output_file: SupportsWrite[str] | None = None
61+
62+
with args.logcat_file.open("rt", encoding="utf8", errors="ignore") as logcat_file_handle:
63+
for line in logcat_file_handle:
64+
test_started_match = TEST_STARTED_TOKEN in line and re.search(TEST_STARTED_PATTERN, line)
65+
if test_started_match:
66+
test_name = test_started_match.group("name")
67+
logging.debug('Found "Test Started" logcat line for test: %s', test_name)
68+
if cur_test_result_output_file is not None:
69+
cur_test_result_output_file.close()
70+
test_output_file = work_dir / f"{len(test_results)}.txt"
71+
cur_test_result = TestResult(test_name=test_name, output_file=test_output_file, passed=True)
72+
test_results.append(cur_test_result)
73+
cur_test_result_output_file = test_output_file.open("wt", encoding="utf8", errors="replace")
74+
75+
if cur_test_result_output_file is not None:
76+
cur_test_result_output_file.write(line)
77+
78+
test_failed_match = TEST_FAILED_TOKEN in line and re.search(TEST_FAILED_PATTERN, line)
79+
if test_failed_match:
80+
test_name = test_failed_match.group("name")
81+
logging.warning("FAILED TEST: %s", test_name)
82+
if cur_test_result is None:
83+
logging.warning(
84+
"WARNING: failed test reported without matching test started: %s", test_name
85+
)
86+
else:
87+
cur_test_result.passed = False
88+
89+
test_finished_match = TEST_FINISHED_TOKEN in line and re.search(TEST_FINISHED_PATTERN, line)
90+
if test_finished_match:
91+
test_name = test_finished_match.group("name")
92+
logging.debug('Found "Test Finished" logcat line for test: %s', test_name)
93+
if cur_test_result_output_file is not None:
94+
cur_test_result_output_file.close()
95+
cur_test_result_output_file = None
96+
cur_test_result = None
97+
98+
if cur_test_result_output_file is not None:
99+
cur_test_result_output_file.close()
100+
del cur_test_result_output_file
101+
102+
passed_tests = [test_result for test_result in test_results if test_result.passed]
103+
failed_tests = [test_result for test_result in test_results if not test_result.passed]
104+
print_line(
105+
f"Found results for {len(test_results)} tests: "
106+
f"{len(passed_tests)} passed, {len(failed_tests)} failed"
107+
)
108+
109+
if len(failed_tests) > 0:
110+
fail_number = 0
111+
for failed_test_result in failed_tests:
112+
fail_number += 1
113+
print_line("")
114+
print_line(f"Failure {fail_number}/{len(failed_tests)}: {failed_test_result.test_name}:")
115+
try:
116+
with failed_test_result.output_file.open(
117+
"rt", encoding="utf8", errors="ignore"
118+
) as test_output_file:
119+
for line in test_output_file:
120+
print_line(line.rstrip())
121+
except OSError:
122+
logging.warning("WARNING: reading file failed: %s", failed_test_result.output_file)
123+
continue
124+
125+
if work_temp_dir is not None:
126+
logging.debug("Cleaning up temporary directory: %s", work_dir)
127+
del work_dir
128+
del work_temp_dir
129+
130+
131+
def print_line(line: str) -> None:
132+
print(line) # noqa: T201
133+
134+
135+
class ParsedArgs(typing.Protocol):
136+
logcat_file: pathlib.Path
137+
log_level: int
138+
work_dir: pathlib.Path | None
139+
140+
141+
def parse_args() -> ParsedArgs:
142+
arg_parser = argparse.ArgumentParser()
143+
arg_parser.add_argument(
144+
"--logcat-file",
145+
required=True,
146+
help="The text file containing the logcat logs to scan.",
147+
)
148+
arg_parser.add_argument(
149+
"--work-dir",
150+
default=None,
151+
help="The directory into which to write temporary files; "
152+
"if not specified, use a temporary directory that is deleted "
153+
"when this script completes; this is primarily intended for "
154+
"developers of this script to use in testing and debugging",
155+
)
156+
arg_parser.add_argument(
157+
"--verbose",
158+
action="store_const",
159+
dest="log_level",
160+
default=logging.INFO,
161+
const=logging.DEBUG,
162+
help="Include debug logging output",
163+
)
164+
165+
parse_result = arg_parser.parse_args()
166+
167+
parse_result.logcat_file = pathlib.Path(parse_result.logcat_file)
168+
parse_result.work_dir = (
169+
None if parse_result.work_dir is None else pathlib.Path(parse_result.work_dir)
170+
)
171+
return typing.cast("ParsedArgs", parse_result)
172+
173+
174+
if __name__ == "__main__":
175+
main()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from __future__ import annotations
16+
17+
import re
18+
19+
import pytest
20+
21+
import logcat_error_report as sut
22+
23+
24+
class TestRegularExpressionPatterns:
25+
@pytest.mark.parametrize(
26+
"string",
27+
[
28+
"",
29+
"XTestRunner: started: fooTest1234",
30+
"TestRunner: started:fooTest1234",
31+
pytest.param(
32+
"TestRunner: started: fooTest1234",
33+
marks=pytest.mark.xfail(
34+
reason="make sure that the test would otherwise pass on match",
35+
strict=True,
36+
),
37+
),
38+
],
39+
)
40+
def test_test_started_pattern_no_match(self, string: str) -> None:
41+
assert re.search(sut.TEST_STARTED_PATTERN, string) is None
42+
43+
@pytest.mark.parametrize(
44+
("string", "expected_name"),
45+
[
46+
("TestRunner: started: fooTest1234", "fooTest1234"),
47+
(" TestRunner: started: fooTest1234", "fooTest1234"),
48+
("TestRunner: started: fooTest1234", "fooTest1234"),
49+
("TestRunner: started: fooTest1234 ", "fooTest1234"),
50+
("TestRunner: started: fooTest1234(abc.123)", "fooTest1234(abc.123)"),
51+
("TestRunner: started: a $ 2 ^ %% . ", "a $ 2 ^ %% ."),
52+
pytest.param(
53+
"i do not match the pattern",
54+
None,
55+
marks=pytest.mark.xfail(
56+
reason="make sure that the test would otherwise pass on match",
57+
strict=True,
58+
),
59+
),
60+
],
61+
)
62+
def test_test_started_pattern_match(self, string: str, expected_name: str) -> None:
63+
match = re.search(sut.TEST_STARTED_PATTERN, string)
64+
assert match is not None
65+
assert match.group("name") == expected_name
66+
67+
@pytest.mark.parametrize(
68+
"string",
69+
[
70+
"",
71+
"XTestRunner: finished: fooTest1234",
72+
"TestRunner: finished:fooTest1234",
73+
pytest.param(
74+
"TestRunner: finished: fooTest1234",
75+
marks=pytest.mark.xfail(
76+
reason="make sure that the test would otherwise pass on match",
77+
strict=True,
78+
),
79+
),
80+
],
81+
)
82+
def test_test_finished_pattern_no_match(self, string: str) -> None:
83+
assert re.search(sut.TEST_FINISHED_PATTERN, string) is None
84+
85+
@pytest.mark.parametrize(
86+
("string", "expected_name"),
87+
[
88+
("TestRunner: finished: fooTest1234", "fooTest1234"),
89+
(" TestRunner: finished: fooTest1234", "fooTest1234"),
90+
("TestRunner: finished: fooTest1234", "fooTest1234"),
91+
("TestRunner: finished: fooTest1234 ", "fooTest1234"),
92+
("TestRunner: finished: fooTest1234(abc.123)", "fooTest1234(abc.123)"),
93+
("TestRunner: finished: a $ 2 ^ %% . ", "a $ 2 ^ %% ."),
94+
pytest.param(
95+
"i do not match the pattern",
96+
None,
97+
marks=pytest.mark.xfail(
98+
reason="make sure that the test would otherwise pass on match",
99+
strict=True,
100+
),
101+
),
102+
],
103+
)
104+
def test_test_finished_pattern_match(self, string: str, expected_name: str) -> None:
105+
match = re.search(sut.TEST_FINISHED_PATTERN, string)
106+
assert match is not None
107+
assert match.group("name") == expected_name
108+
109+
@pytest.mark.parametrize(
110+
"string",
111+
[
112+
"",
113+
"XTestRunner: failed: fooTest1234",
114+
"TestRunner: failed:fooTest1234",
115+
pytest.param(
116+
"TestRunner: failed: fooTest1234",
117+
marks=pytest.mark.xfail(
118+
reason="make sure that the test would otherwise pass on match",
119+
strict=True,
120+
),
121+
),
122+
],
123+
)
124+
def test_test_failed_pattern_no_match(self, string: str) -> None:
125+
assert re.search(sut.TEST_FAILED_PATTERN, string) is None
126+
127+
@pytest.mark.parametrize(
128+
("string", "expected_name"),
129+
[
130+
("TestRunner: failed: fooTest1234", "fooTest1234"),
131+
(" TestRunner: failed: fooTest1234", "fooTest1234"),
132+
("TestRunner: failed: fooTest1234", "fooTest1234"),
133+
("TestRunner: failed: fooTest1234 ", "fooTest1234"),
134+
("TestRunner: failed: fooTest1234(abc.123)", "fooTest1234(abc.123)"),
135+
("TestRunner: failed: a $ 2 ^ %% . ", "a $ 2 ^ %% ."),
136+
pytest.param(
137+
"i do not match the pattern",
138+
None,
139+
marks=pytest.mark.xfail(
140+
reason="make sure that the test would otherwise pass on match",
141+
strict=True,
142+
),
143+
),
144+
],
145+
)
146+
def test_test_failed_pattern_match(self, string: str, expected_name: str) -> None:
147+
match = re.search(sut.TEST_FAILED_PATTERN, string)
148+
assert match is not None
149+
assert match.group("name") == expected_name

firebase-dataconnect/ci/pyproject.toml

+3
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ indent-width = 2
1616
[tool.ruff.lint]
1717
select = ["ALL"]
1818
ignore = [
19+
"C901", # function is too complex
1920
"COM812", # missing-trailing-comma
2021
"D100", # Missing docstring in public module
2122
"D101", # Missing docstring in public class
@@ -29,6 +30,8 @@ ignore = [
2930
"E501", # Line too long (will be fixed by the formatter)
3031
"EM101", # Exception must not use a string literal, assign to variable first
3132
"LOG015", # root-logger-call
33+
"PLR0912", # Too many branches
34+
"PLR0915", # Too many statements
3235
"TRY003", # Avoid specifying long messages outside the exception class
3336
]
3437

0 commit comments

Comments
 (0)