From 0c8357cb101e11d98bf4389daf5b8536add525d4 Mon Sep 17 00:00:00 2001
From: Denver Coneybeare <dconeybe@google.com>
Date: Tue, 22 Apr 2025 14:57:09 -0400
Subject: [PATCH 1/5] logcat_error_report.py added, and dataconnect.yml
 modified to use it

---
 .github/workflows/dataconnect.yml             |  15 +-
 firebase-dataconnect/ci/README.md             |   2 +-
 .../ci/logcat_error_report.py                 | 175 ++++++++++++++++++
 .../ci/logcat_error_report_test.py            | 149 +++++++++++++++
 firebase-dataconnect/ci/pyproject.toml        |   3 +
 5 files changed, 342 insertions(+), 2 deletions(-)
 create mode 100644 firebase-dataconnect/ci/logcat_error_report.py
 create mode 100644 firebase-dataconnect/ci/logcat_error_report_test.py

diff --git a/.github/workflows/dataconnect.yml b/.github/workflows/dataconnect.yml
index 543425ab14a..18f141dadd7 100644
--- a/.github/workflows/dataconnect.yml
+++ b/.github/workflows/dataconnect.yml
@@ -66,6 +66,12 @@ jobs:
         with:
           node-version: ${{ env.FDC_NODEJS_VERSION }}
 
+      - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0
+        with:
+          python-version: ${{ env.FDC_PYTHON_VERSION }}
+
+      - run: pip install -r firebase-dataconnect/ci/requirements.txt
+
       - name: Install Firebase Tools ("firebase" command-line tool)
         run: |
           set -euo pipefail
@@ -228,7 +234,14 @@ jobs:
       - name: Verify "Gradle connectedCheck" Step Was Successful
         if: steps.connectedCheck.outcome != 'success'
         run: |
-          set -euo pipefail
+          set -xveuo pipefail
+
+          if [[ ! -e logcat.log ]] ; then
+            echo "WARNING dsdta43sxk: logcat log file not found; skipping scanning for test failures" >&2
+          else
+            python firebase-dataconnect/ci/logcat_error_report.py --logcat-file=logcat.log
+          fi
+
           echo 'Failing because the outcome of the "Gradle connectedCheck" step ("${{ steps.connectedCheck.outcome }}") was not successful'
           exit 1
 
diff --git a/firebase-dataconnect/ci/README.md b/firebase-dataconnect/ci/README.md
index 59a36dc1aa0..8dd8c015eb9 100644
--- a/firebase-dataconnect/ci/README.md
+++ b/firebase-dataconnect/ci/README.md
@@ -18,5 +18,5 @@ pip install -r requirements.txt
 Then, run all of these presubmit checks by running the following command:
 
 ```
-ruff check && ruff format && pyright && pytest && echo 'SUCCESS!!!!!!!!!!!!!!!'
+ruff check --fix && ruff format && pyright && pytest && echo 'SUCCESS!!!!!!!!!!!!!!!'
 ```
diff --git a/firebase-dataconnect/ci/logcat_error_report.py b/firebase-dataconnect/ci/logcat_error_report.py
new file mode 100644
index 00000000000..be0969027bd
--- /dev/null
+++ b/firebase-dataconnect/ci/logcat_error_report.py
@@ -0,0 +1,175 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import argparse
+import dataclasses
+import logging
+import pathlib
+import re
+import tempfile
+import typing
+
+if typing.TYPE_CHECKING:
+  from _typeshed import SupportsWrite
+
+TEST_STARTED_TOKEN = "TestRunner: started:"  # noqa: S105
+TEST_STARTED_PATTERN = r"(\W|^)" + re.escape(TEST_STARTED_TOKEN) + r"\s+(?P<name>.*\S)"
+TEST_FAILED_TOKEN = "TestRunner: failed:"  # noqa: S105
+TEST_FAILED_PATTERN = r"(\W|^)" + re.escape(TEST_FAILED_TOKEN) + r"\s+(?P<name>.*\S)"
+TEST_FINISHED_TOKEN = "TestRunner: finished:"  # noqa: S105
+TEST_FINISHED_PATTERN = r"(\W|^)" + re.escape(TEST_FINISHED_TOKEN) + r"\s+(?P<name>.*\S)"
+
+
+@dataclasses.dataclass
+class TestResult:
+  test_name: str
+  output_file: pathlib.Path
+  passed: bool
+
+
+def main() -> None:
+  args = parse_args()
+  logging.basicConfig(format="%(message)s", level=args.log_level)
+
+  if args.work_dir is None:
+    work_temp_dir = tempfile.TemporaryDirectory("dd9rh9apdf")
+    work_dir = pathlib.Path(work_temp_dir.name)
+    logging.debug("Using temporary directory as work directory: %s", work_dir)
+  else:
+    work_temp_dir = None
+    work_dir = args.work_dir
+    logging.debug("Using specified directory as work directory: %s", work_dir)
+    work_dir.mkdir(parents=True, exist_ok=True)
+
+  logging.info("Extracting test failures from %s", args.logcat_file)
+  test_results: list[TestResult] = []
+  cur_test_result: TestResult | None = None
+  cur_test_result_output_file: SupportsWrite[str] | None = None
+
+  with args.logcat_file.open("rt", encoding="utf8", errors="ignore") as logcat_file_handle:
+    for line in logcat_file_handle:
+      test_started_match = TEST_STARTED_TOKEN in line and re.search(TEST_STARTED_PATTERN, line)
+      if test_started_match:
+        test_name = test_started_match.group("name")
+        logging.debug('Found "Test Started" logcat line for test: %s', test_name)
+        if cur_test_result_output_file is not None:
+          cur_test_result_output_file.close()
+        test_output_file = work_dir / f"{len(test_results)}.txt"
+        cur_test_result = TestResult(test_name=test_name, output_file=test_output_file, passed=True)
+        test_results.append(cur_test_result)
+        cur_test_result_output_file = test_output_file.open("wt", encoding="utf8", errors="replace")
+
+      if cur_test_result_output_file is not None:
+        cur_test_result_output_file.write(line)
+
+      test_failed_match = TEST_FAILED_TOKEN in line and re.search(TEST_FAILED_PATTERN, line)
+      if test_failed_match:
+        test_name = test_failed_match.group("name")
+        logging.warning("FAILED TEST: %s", test_name)
+        if cur_test_result is None:
+          logging.warning(
+            "WARNING: failed test reported without matching test started: %s", test_name
+          )
+        else:
+          cur_test_result.passed = False
+
+      test_finished_match = TEST_FINISHED_TOKEN in line and re.search(TEST_FINISHED_PATTERN, line)
+      if test_finished_match:
+        test_name = test_finished_match.group("name")
+        logging.debug('Found "Test Finished" logcat line for test: %s', test_name)
+        if cur_test_result_output_file is not None:
+          cur_test_result_output_file.close()
+        cur_test_result_output_file = None
+        cur_test_result = None
+
+  if cur_test_result_output_file is not None:
+    cur_test_result_output_file.close()
+  del cur_test_result_output_file
+
+  passed_tests = [test_result for test_result in test_results if test_result.passed]
+  failed_tests = [test_result for test_result in test_results if not test_result.passed]
+  print_line(
+    f"Found results for {len(test_results)} tests:"
+    f"{len(passed_tests)} passed, {len(failed_tests)} failed"
+  )
+
+  if len(failed_tests) > 0:
+    fail_number = 0
+    for failed_test_result in failed_tests:
+      fail_number += 1
+      print_line("")
+      print_line(f"Failure {fail_number}/{len(failed_tests)}: {failed_test_result.test_name}:")
+      try:
+        with failed_test_result.output_file.open(
+          "rt", encoding="utf8", errors="ignore"
+        ) as test_output_file:
+          for line in test_output_file:
+            print_line(line.rstrip())
+      except OSError:
+        logging.warning("WARNING: reading file failed: %s", failed_test_result.output_file)
+        continue
+
+  if work_temp_dir is not None:
+    logging.debug("Cleaning up temporary directory: %s", work_dir)
+  del work_dir
+  del work_temp_dir
+
+
+def print_line(line: str) -> None:
+  print(line)  # noqa: T201
+
+
+class ParsedArgs(typing.Protocol):
+  logcat_file: pathlib.Path
+  log_level: int
+  work_dir: pathlib.Path | None
+
+
+def parse_args() -> ParsedArgs:
+  arg_parser = argparse.ArgumentParser()
+  arg_parser.add_argument(
+    "--logcat-file",
+    required=True,
+    help="The text file containing the logcat logs to scan.",
+  )
+  arg_parser.add_argument(
+    "--work-dir",
+    default=None,
+    help="The directory into which to write temporary files; "
+    "if not specified, use a temporary directory that is deleted "
+    "when this script completes; this is primarily intended for "
+    "developers of this script to use in testing and debugging",
+  )
+  arg_parser.add_argument(
+    "--verbose",
+    action="store_const",
+    dest="log_level",
+    default=logging.INFO,
+    const=logging.DEBUG,
+    help="Include debug logging output",
+  )
+
+  parse_result = arg_parser.parse_args()
+
+  parse_result.logcat_file = pathlib.Path(parse_result.logcat_file)
+  parse_result.work_dir = (
+    None if parse_result.work_dir is None else pathlib.Path(parse_result.work_dir)
+  )
+  return typing.cast("ParsedArgs", parse_result)
+
+
+if __name__ == "__main__":
+  main()
diff --git a/firebase-dataconnect/ci/logcat_error_report_test.py b/firebase-dataconnect/ci/logcat_error_report_test.py
new file mode 100644
index 00000000000..59030ec7c4e
--- /dev/null
+++ b/firebase-dataconnect/ci/logcat_error_report_test.py
@@ -0,0 +1,149 @@
+# Copyright 2025 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import re
+
+import pytest
+
+import logcat_error_report as sut
+
+
+class TestRegularExpressionPatterns:
+  @pytest.mark.parametrize(
+    "string",
+    [
+      "",
+      "XTestRunner: started: fooTest1234",
+      "TestRunner: started:fooTest1234",
+      pytest.param(
+        "TestRunner: started: fooTest1234",
+        marks=pytest.mark.xfail(
+          reason="make sure that the test would otherwise pass on match",
+          strict=True,
+        ),
+      ),
+    ],
+  )
+  def test_test_started_pattern_no_match(self, string: str) -> None:
+    assert re.search(sut.TEST_STARTED_PATTERN, string) is None
+
+  @pytest.mark.parametrize(
+    ("string", "expected_name"),
+    [
+      ("TestRunner: started: fooTest1234", "fooTest1234"),
+      ("  TestRunner: started: fooTest1234", "fooTest1234"),
+      ("TestRunner: started:   fooTest1234", "fooTest1234"),
+      ("TestRunner: started: fooTest1234  ", "fooTest1234"),
+      ("TestRunner: started: fooTest1234(abc.123)", "fooTest1234(abc.123)"),
+      ("TestRunner: started: a $ 2 ^ %% .  ", "a $ 2 ^ %% ."),
+      pytest.param(
+        "i do not match the pattern",
+        None,
+        marks=pytest.mark.xfail(
+          reason="make sure that the test would otherwise pass on match",
+          strict=True,
+        ),
+      ),
+    ],
+  )
+  def test_test_started_pattern_match(self, string: str, expected_name: str) -> None:
+    match = re.search(sut.TEST_STARTED_PATTERN, string)
+    assert match is not None
+    assert match.group("name") == expected_name
+
+  @pytest.mark.parametrize(
+    "string",
+    [
+      "",
+      "XTestRunner: finished: fooTest1234",
+      "TestRunner: finished:fooTest1234",
+      pytest.param(
+        "TestRunner: finished: fooTest1234",
+        marks=pytest.mark.xfail(
+          reason="make sure that the test would otherwise pass on match",
+          strict=True,
+        ),
+      ),
+    ],
+  )
+  def test_test_finished_pattern_no_match(self, string: str) -> None:
+    assert re.search(sut.TEST_FINISHED_PATTERN, string) is None
+
+  @pytest.mark.parametrize(
+    ("string", "expected_name"),
+    [
+      ("TestRunner: finished: fooTest1234", "fooTest1234"),
+      ("  TestRunner: finished: fooTest1234", "fooTest1234"),
+      ("TestRunner: finished:   fooTest1234", "fooTest1234"),
+      ("TestRunner: finished: fooTest1234  ", "fooTest1234"),
+      ("TestRunner: finished: fooTest1234(abc.123)", "fooTest1234(abc.123)"),
+      ("TestRunner: finished: a $ 2 ^ %% .  ", "a $ 2 ^ %% ."),
+      pytest.param(
+        "i do not match the pattern",
+        None,
+        marks=pytest.mark.xfail(
+          reason="make sure that the test would otherwise pass on match",
+          strict=True,
+        ),
+      ),
+    ],
+  )
+  def test_test_finished_pattern_match(self, string: str, expected_name: str) -> None:
+    match = re.search(sut.TEST_FINISHED_PATTERN, string)
+    assert match is not None
+    assert match.group("name") == expected_name
+
+  @pytest.mark.parametrize(
+    "string",
+    [
+      "",
+      "XTestRunner: failed: fooTest1234",
+      "TestRunner: failed:fooTest1234",
+      pytest.param(
+        "TestRunner: failed: fooTest1234",
+        marks=pytest.mark.xfail(
+          reason="make sure that the test would otherwise pass on match",
+          strict=True,
+        ),
+      ),
+    ],
+  )
+  def test_test_failed_pattern_no_match(self, string: str) -> None:
+    assert re.search(sut.TEST_FAILED_PATTERN, string) is None
+
+  @pytest.mark.parametrize(
+    ("string", "expected_name"),
+    [
+      ("TestRunner: failed: fooTest1234", "fooTest1234"),
+      ("  TestRunner: failed: fooTest1234", "fooTest1234"),
+      ("TestRunner: failed:   fooTest1234", "fooTest1234"),
+      ("TestRunner: failed: fooTest1234  ", "fooTest1234"),
+      ("TestRunner: failed: fooTest1234(abc.123)", "fooTest1234(abc.123)"),
+      ("TestRunner: failed: a $ 2 ^ %% .  ", "a $ 2 ^ %% ."),
+      pytest.param(
+        "i do not match the pattern",
+        None,
+        marks=pytest.mark.xfail(
+          reason="make sure that the test would otherwise pass on match",
+          strict=True,
+        ),
+      ),
+    ],
+  )
+  def test_test_failed_pattern_match(self, string: str, expected_name: str) -> None:
+    match = re.search(sut.TEST_FAILED_PATTERN, string)
+    assert match is not None
+    assert match.group("name") == expected_name
diff --git a/firebase-dataconnect/ci/pyproject.toml b/firebase-dataconnect/ci/pyproject.toml
index 3afddaa1c6c..71800e3a086 100644
--- a/firebase-dataconnect/ci/pyproject.toml
+++ b/firebase-dataconnect/ci/pyproject.toml
@@ -16,6 +16,7 @@ indent-width = 2
 [tool.ruff.lint]
 select = ["ALL"]
 ignore = [
+  "C901", # function is too complex
   "COM812", # missing-trailing-comma
   "D100", # Missing docstring in public module
   "D101", # Missing docstring in public class
@@ -29,6 +30,8 @@ ignore = [
   "E501", # Line too long (will be fixed by the formatter)
   "EM101", # Exception must not use a string literal, assign to variable first
   "LOG015", # root-logger-call
+  "PLR0912", # Too many branches
+  "PLR0915", # Too many statements
   "TRY003", # Avoid specifying long messages outside the exception class
 ]
 

From 96d98508b2658b2efe3679295aff3e5c5fd3eb2b Mon Sep 17 00:00:00 2001
From: Denver Coneybeare <dconeybe@google.com>
Date: Tue, 22 Apr 2025 16:33:32 -0400
Subject: [PATCH 2/5] REVERT ME: forced test failure to verify the github
 workflow logic

---
 .../google/firebase/dataconnect/AnyScalarIntegrationTest.kt  | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/AnyScalarIntegrationTest.kt b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/AnyScalarIntegrationTest.kt
index f5f50df20ce..fdf5b394248 100644
--- a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/AnyScalarIntegrationTest.kt
+++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/AnyScalarIntegrationTest.kt
@@ -67,6 +67,11 @@ class AnyScalarIntegrationTest : DataConnectIntegrationTestBase() {
   // type AnyScalarNonNullable @table { value: Any!, tag: String, position: Int }
   //////////////////////////////////////////////////////////////////////////////////////////////////
 
+  @Test
+  fun forcedFailureRevertMeBeforeMerging() {
+    throw Exception("vzmht3nbgr forced error!")
+  }
+
   @Test
   fun anyScalarNonNullable_MutationVariableEdgeCases() =
     runTest(timeout = 60.seconds) {

From 5e89ec01f5822e630652b7d6d1b024ccec33278c Mon Sep 17 00:00:00 2001
From: Denver Coneybeare <dconeybe@google.com>
Date: Tue, 22 Apr 2025 16:49:48 -0400
Subject: [PATCH 3/5] logcat_error_report.py: add missing space in log message
 after ":"

---
 firebase-dataconnect/ci/logcat_error_report.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/firebase-dataconnect/ci/logcat_error_report.py b/firebase-dataconnect/ci/logcat_error_report.py
index be0969027bd..fe316fd8a11 100644
--- a/firebase-dataconnect/ci/logcat_error_report.py
+++ b/firebase-dataconnect/ci/logcat_error_report.py
@@ -102,7 +102,7 @@ def main() -> None:
   passed_tests = [test_result for test_result in test_results if test_result.passed]
   failed_tests = [test_result for test_result in test_results if not test_result.passed]
   print_line(
-    f"Found results for {len(test_results)} tests:"
+    f"Found results for {len(test_results)} tests: "
     f"{len(passed_tests)} passed, {len(failed_tests)} failed"
   )
 

From 29a6ad34609d7cf482630a377fa96c31845e708e Mon Sep 17 00:00:00 2001
From: Denver Coneybeare <dconeybe@google.com>
Date: Tue, 22 Apr 2025 16:51:06 -0400
Subject: [PATCH 4/5] dataconnect.yml: clean up output of step that calls
 logcat_error_report.py

---
 .github/workflows/dataconnect.yml | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/.github/workflows/dataconnect.yml b/.github/workflows/dataconnect.yml
index 18f141dadd7..247157caeef 100644
--- a/.github/workflows/dataconnect.yml
+++ b/.github/workflows/dataconnect.yml
@@ -234,12 +234,14 @@ jobs:
       - name: Verify "Gradle connectedCheck" Step Was Successful
         if: steps.connectedCheck.outcome != 'success'
         run: |
-          set -xveuo pipefail
+          set -euo pipefail
 
           if [[ ! -e logcat.log ]] ; then
             echo "WARNING dsdta43sxk: logcat log file not found; skipping scanning for test failures" >&2
           else
+            echo "Scanning logcat output for failure details"
             python firebase-dataconnect/ci/logcat_error_report.py --logcat-file=logcat.log
+            echo
           fi
 
           echo 'Failing because the outcome of the "Gradle connectedCheck" step ("${{ steps.connectedCheck.outcome }}") was not successful'

From f1ec66cf858e83957678d3f30564ff7a92e32cc2 Mon Sep 17 00:00:00 2001
From: Denver Coneybeare <dconeybe@google.com>
Date: Tue, 22 Apr 2025 16:53:09 -0400
Subject: [PATCH 5/5] Revert "REVERT ME: forced test failure to verify the
 github workflow logic"

This reverts commit 96d98508b2658b2efe3679295aff3e5c5fd3eb2b.
---
 .../google/firebase/dataconnect/AnyScalarIntegrationTest.kt  | 5 -----
 1 file changed, 5 deletions(-)

diff --git a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/AnyScalarIntegrationTest.kt b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/AnyScalarIntegrationTest.kt
index fdf5b394248..f5f50df20ce 100644
--- a/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/AnyScalarIntegrationTest.kt
+++ b/firebase-dataconnect/src/androidTest/kotlin/com/google/firebase/dataconnect/AnyScalarIntegrationTest.kt
@@ -67,11 +67,6 @@ class AnyScalarIntegrationTest : DataConnectIntegrationTestBase() {
   // type AnyScalarNonNullable @table { value: Any!, tag: String, position: Int }
   //////////////////////////////////////////////////////////////////////////////////////////////////
 
-  @Test
-  fun forcedFailureRevertMeBeforeMerging() {
-    throw Exception("vzmht3nbgr forced error!")
-  }
-
   @Test
   fun anyScalarNonNullable_MutationVariableEdgeCases() =
     runTest(timeout = 60.seconds) {