From 534ffe56f9a6591148956e54d9e3aa755841fa9e Mon Sep 17 00:00:00 2001 From: gayanW Date: Wed, 21 Oct 2026 17:05:27 +0900 Subject: [PATCH 1/5] Add command 'record coverage' --- launchable/commands/record/__init__.py | 2 + launchable/commands/record/coverage.py | 127 +++++++++++++++++++++ tests/commands/record/test_coverage.py | 146 +++++++++++++++++++++++++ 3 files changed, 275 insertions(+) create mode 100644 launchable/commands/record/coverage.py create mode 100644 tests/commands/record/test_coverage.py diff --git a/launchable/commands/record/__init__.py b/launchable/commands/record/__init__.py index 99bc4deba..e62d64c18 100644 --- a/launchable/commands/record/__init__.py +++ b/launchable/commands/record/__init__.py @@ -5,6 +5,7 @@ from .attachment import attachment from .build import build from .commit import commit +from .coverage import coverage from .session import session from .tests import tests @@ -21,3 +22,4 @@ def record(): record.add_alias('test', tests) # type: ignore record.add_command(session) record.add_command(attachment) +record.add_command(coverage) diff --git a/launchable/commands/record/coverage.py b/launchable/commands/record/coverage.py new file mode 100644 index 000000000..67660cf91 --- /dev/null +++ b/launchable/commands/record/coverage.py @@ -0,0 +1,127 @@ +import glob +import os +from io import BytesIO +from pathlib import Path +from typing import List, Optional, Tuple + +import click +from tabulate import tabulate + +from ...utils.launchable_client import LaunchableClient +from ..helper import require_session + + +class CoverageStatus: + SUCCESS = "✓ Recorded successfully" + FAILED = "⚠ Failed to record" + + +@click.command(help="Record a coverage report (LCOV format) for a test session. Auto-discovery " + "(when no files are given) is Bazel-only for now; other runners must pass " + "coverage report file(s) directly.") +@click.option( + '--session', + 'session', + help='In the format builds//test_sessions/', + type=str, +) +@click.argument('coverage_files', nargs=-1) +@click.pass_context +def coverage( + context: click.core.Context, + coverage_files: Tuple[str, ...], + session: Optional[str] = None, +): + client = LaunchableClient(app=context.obj) + sources: List[str] = [] + + try: + session = require_session(session) + assert session is not None + + if coverage_files: + sources = list(coverage_files) + blocks = [_read(f) for f in sources] + else: + # No files given: auto-discover from the current directory + # Auto discovery is Bazel only for now. + sources, blocks = discover_bazel_coverage(Path(".")) + + # LCOV tracefiles are just a sequence of SF:...end_of_record blocks, so concatenating + # multiple files is itself a valid tracefile. Send them as one upload rather than one + # request per file. The server already unions repeated SF: blocks for the same file within + # one upload, so this is correct even when the inputs cover overlapping files (e.g. one + # report from a unit-test run, another from an integration-test run touching the same + # source file) + content = b"\n".join(blocks) + status, files_recorded = post_coverage(client, session, content) + except Exception as e: + client.print_exception_and_recover(e) + status, files_recorded = CoverageStatus.FAILED, None + + display_summary(sources, status, files_recorded) + + +def discover_bazel_coverage(workspace: "str | Path") -> Tuple[List[str], List[bytes]]: + """ + Finds every per-target coverage.dat under /bazel-testlogs + + Bazel's own coverage.dat carries no target identity on its own, though TN (Test Name) is a standard LCOV field. + So we derive TN by file path (same approach used by `record tests bazel` + (bazel-testlogs///coverage.dat -> TN://:). + """ + base = Path(workspace).joinpath('bazel-testlogs') + if not base.exists(): + raise click.UsageError( + "No such directory: %s. " + "Pass coverage report file(s) directly as arguments instead." % str(base)) + + sources: List[str] = [] + blocks: List[bytes] = [] + for report_file in sorted(glob.iglob(os.path.join(str(base), '**', 'coverage.dat'), recursive=True)): + pkg_and_target = report_file[len(str(base)) + 1:-len("/coverage.dat")] + package, target = os.path.dirname(pkg_and_target), os.path.basename(pkg_and_target) + label = "//{}:{}".format(package, target) + sources.append(report_file) + blocks.append(_tag_with_test_name(_read(report_file), label)) + + if not blocks: + raise click.UsageError("No coverage.dat files found under %s" % str(base)) + + return sources, blocks + + +def _tag_with_test_name(content: bytes, label: str) -> bytes: + tn_line = "TN:{}\n".format(label).encode('utf-8') + tagged = [] + for line in content.splitlines(keepends=True): + if line.startswith(b"SF:"): + tagged.append(tn_line) + tagged.append(line) + return b"".join(tagged) + + +def _read(path: str) -> bytes: + with open(path, mode='rb') as f: + return f.read() + + +def post_coverage(client: LaunchableClient, session: str, content: bytes) -> Tuple[str, Optional[int]]: + try: + res = client.request( + "post", "{}/coverage".format(session), compress=True, payload=BytesIO(content), + additional_headers={"Content-Type": "text/plain"}) + res.raise_for_status() + summary = res.json() + return CoverageStatus.SUCCESS, summary.get("sourceFilesRecorded") + except Exception as e: + click.echo("Failed to upload coverage report: {}".format(str(e)), err=True) + return CoverageStatus.FAILED, None + + +def display_summary(sources: List[str], status: str, source_files: Optional[int]): + if sources: + click.echo(tabulate([[f] for f in sources], ["Coverage files found"], tablefmt="github")) + click.echo() + rows = [[len(sources), source_files if source_files is not None else "-", status]] + click.echo(tabulate(rows, ["Coverage Files", "Source Files", "Status"], tablefmt="github")) diff --git a/tests/commands/record/test_coverage.py b/tests/commands/record/test_coverage.py new file mode 100644 index 000000000..a11ffd8ce --- /dev/null +++ b/tests/commands/record/test_coverage.py @@ -0,0 +1,146 @@ +import gzip +import json +import os +import tempfile +from unittest import mock + +import responses # type: ignore + +from launchable.utils.http_client import get_base_url +from tests.cli_test_case import CliTestCase + + +class CoverageTest(CliTestCase): + @responses.activate + @mock.patch.dict(os.environ, {"LAUNCHABLE_TOKEN": CliTestCase.launchable_token}) + def test_coverage(self): + TEST_CONTENT = b"SF:src/Foo.java\nDA:1,1\nend_of_record\n" + + coverage_file = tempfile.NamedTemporaryFile(delete=False) + coverage_file.write(TEST_CONTENT) + coverage_file.close() + + body = None + + def verify_body(request): + nonlocal body + body = gzip.decompress(b''.join(list(request.body))) + return (200, [], json.dumps({"sourceFilesRecorded": 1, "filesWithData": 1})) + + responses.add_callback( + responses.POST, + f"{get_base_url()}/intake/organizations/{self.organization}/workspaces/" + f"{self.workspace}/builds/{self.build_name}/test_sessions/{self.session_id}/coverage", + callback=verify_body) + + result = self.cli("record", "coverage", "--session", self.session, coverage_file.name) + self.assert_success(result) + self.assertEqual(TEST_CONTENT, body) + self.assertIn(coverage_file.name, result.output) + self.assertIn("✓ Recorded successfully", result.output) + self.assertIn("Coverage Files", result.output) + self.assertIn("Source Files", result.output) + + os.unlink(coverage_file.name) + + @responses.activate + @mock.patch.dict(os.environ, {"LAUNCHABLE_TOKEN": CliTestCase.launchable_token}) + def test_coverage_concatenates_multiple_files_into_one_request(self): + content_a = b"SF:src/A.java\nDA:1,1\nend_of_record\n" + content_b = b"SF:src/B.java\nDA:1,0\nend_of_record\n" + + file_a = tempfile.NamedTemporaryFile(delete=False) + file_a.write(content_a) + file_a.close() + file_b = tempfile.NamedTemporaryFile(delete=False) + file_b.write(content_b) + file_b.close() + + requests_received = [] + + def verify_body(request): + requests_received.append(gzip.decompress(b''.join(list(request.body)))) + return (200, [], json.dumps({"sourceFilesRecorded": 2, "filesWithData": 1})) + + responses.add_callback( + responses.POST, + f"{get_base_url()}/intake/organizations/{self.organization}/workspaces/" + f"{self.workspace}/builds/{self.build_name}/test_sessions/{self.session_id}/coverage", + callback=verify_body) + + result = self.cli("record", "coverage", "--session", self.session, file_a.name, file_b.name) + + self.assert_success(result) + # Exactly one request, not one per file, carrying both files' content concatenated. + self.assertEqual(1, len(requests_received)) + self.assertEqual(content_a + b"\n" + content_b, requests_received[0]) + self.assertIn(file_a.name, result.output) + self.assertIn(file_b.name, result.output) + self.assertIn("Coverage Files", result.output) + self.assertIn("Source Files", result.output) + + os.unlink(file_a.name) + os.unlink(file_b.name) + + @responses.activate + @mock.patch.dict(os.environ, {"LAUNCHABLE_TOKEN": CliTestCase.launchable_token}) + def test_coverage_bazel_auto_discovery_tags_each_target_and_sends_one_request(self): + with tempfile.TemporaryDirectory() as workspace: + testlogs = os.path.join(workspace, "bazel-testlogs") + net_dir = os.path.join(testlogs, "net", "AllTests") + strings_dir = os.path.join(testlogs, "util", "strings", "AllTests") + os.makedirs(net_dir) + os.makedirs(strings_dir) + + with open(os.path.join(net_dir, "coverage.dat"), "wb") as f: + f.write(b"SF:src/net/URIs.java\nDA:1,1\nend_of_record\n") + with open(os.path.join(strings_dir, "coverage.dat"), "wb") as f: + f.write(b"SF:src/util/strings/Strings.java\nDA:1,0\nend_of_record\n") + + requests_received = [] + + def verify_body(request): + requests_received.append(gzip.decompress(b''.join(list(request.body)))) + return (200, [], json.dumps({"sourceFilesRecorded": 2, "filesWithData": 1})) + + responses.add_callback( + responses.POST, + f"{get_base_url()}/intake/organizations/{self.organization}/workspaces/" + f"{self.workspace}/builds/{self.build_name}/test_sessions/{self.session_id}/coverage", + callback=verify_body) + + cwd = os.getcwd() + os.chdir(workspace) + try: + result = self.cli("record", "coverage", "--session", self.session) + finally: + os.chdir(cwd) + + self.assert_success(result) + self.assertEqual(1, len(requests_received)) + body = requests_received[0] + + # Each target's SF: block is preceded by a TN: record derived from its path, since + # Bazel's own coverage.dat carries no target identity on its own. + self.assertIn(b"TN://net:AllTests\nSF:src/net/URIs.java\n", body) + self.assertIn(b"TN://util/strings:AllTests\nSF:src/util/strings/Strings.java\n", body) + + @responses.activate + @mock.patch.dict(os.environ, {"LAUNCHABLE_TOKEN": CliTestCase.launchable_token}) + def test_coverage_upload_failure(self): + coverage_file = tempfile.NamedTemporaryFile(delete=False) + coverage_file.write(b"not valid lcov") + coverage_file.close() + + responses.add( + responses.POST, + "{}/intake/organizations/{}/workspaces/{}/builds/{}/test_sessions/{}/coverage".format( + get_base_url(), self.organization, self.workspace, self.build_name, self.session_id), + json={"error": "No SF: blocks found in coverage report"}, + status=400) + + result = self.cli("record", "coverage", "--session", self.session, coverage_file.name) + + self.assertIn("⚠ Failed to record", result.output) + + os.unlink(coverage_file.name) From f1a02c0c723fc434c292966fe02b58a7464d9a68 Mon Sep 17 00:00:00 2001 From: gayanW Date: Wed, 21 Oct 2026 17:48:45 +0900 Subject: [PATCH 2/5] Normalize Bazel label separator for nested packages on Windows --- launchable/commands/record/coverage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/launchable/commands/record/coverage.py b/launchable/commands/record/coverage.py index 67660cf91..0d0a127b8 100644 --- a/launchable/commands/record/coverage.py +++ b/launchable/commands/record/coverage.py @@ -81,7 +81,7 @@ def discover_bazel_coverage(workspace: "str | Path") -> Tuple[List[str], List[by for report_file in sorted(glob.iglob(os.path.join(str(base), '**', 'coverage.dat'), recursive=True)): pkg_and_target = report_file[len(str(base)) + 1:-len("/coverage.dat")] package, target = os.path.dirname(pkg_and_target), os.path.basename(pkg_and_target) - label = "//{}:{}".format(package, target) + label = "//{}:{}".format(package.replace(os.sep, '/'), target) sources.append(report_file) blocks.append(_tag_with_test_name(_read(report_file), label)) From b6868983ed5a9820d8f3a090c68732b73a0ee31d Mon Sep 17 00:00:00 2001 From: gayanW Date: Thu, 22 Oct 2026 16:28:30 +0900 Subject: [PATCH 3/5] Show warning when session already had coverage recorded --- launchable/commands/record/coverage.py | 28 +++++++++++++++++--------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/launchable/commands/record/coverage.py b/launchable/commands/record/coverage.py index 0d0a127b8..772d0ebd2 100644 --- a/launchable/commands/record/coverage.py +++ b/launchable/commands/record/coverage.py @@ -16,9 +16,10 @@ class CoverageStatus: FAILED = "⚠ Failed to record" -@click.command(help="Record a coverage report (LCOV format) for a test session. Auto-discovery " - "(when no files are given) is Bazel-only for now; other runners must pass " - "coverage report file(s) directly.") +@click.command(help="Record coverage data (LCOV format) related to a test session. " + "Passing COVERAGE_FILES is optional for bazel; " + "files are auto discovered when the command is run inside project root. " + "Other test runners must pass their coverage report file(s) directly.") @click.option( '--session', 'session', @@ -54,12 +55,12 @@ def coverage( # report from a unit-test run, another from an integration-test run touching the same # source file) content = b"\n".join(blocks) - status, files_recorded = post_coverage(client, session, content) + status, files_recorded, is_reupload = post_coverage(client, session, content) except Exception as e: client.print_exception_and_recover(e) - status, files_recorded = CoverageStatus.FAILED, None + status, files_recorded, is_reupload = CoverageStatus.FAILED, None, False - display_summary(sources, status, files_recorded) + display_summary(sources, status, files_recorded, is_reupload) def discover_bazel_coverage(workspace: "str | Path") -> Tuple[List[str], List[bytes]]: @@ -106,22 +107,29 @@ def _read(path: str) -> bytes: return f.read() -def post_coverage(client: LaunchableClient, session: str, content: bytes) -> Tuple[str, Optional[int]]: +def post_coverage(client: LaunchableClient, session: str, content: bytes) -> Tuple[str, Optional[int], bool]: try: res = client.request( "post", "{}/coverage".format(session), compress=True, payload=BytesIO(content), additional_headers={"Content-Type": "text/plain"}) res.raise_for_status() summary = res.json() - return CoverageStatus.SUCCESS, summary.get("sourceFilesRecorded") + return CoverageStatus.SUCCESS, summary.get("sourceFilesRecorded"), summary.get("isReupload", False) except Exception as e: click.echo("Failed to upload coverage report: {}".format(str(e)), err=True) - return CoverageStatus.FAILED, None + return CoverageStatus.FAILED, None, False -def display_summary(sources: List[str], status: str, source_files: Optional[int]): +def display_summary(sources: List[str], status: str, source_files: Optional[int], is_reupload: bool = False): if sources: click.echo(tabulate([[f] for f in sources], ["Coverage files found"], tablefmt="github")) click.echo() rows = [[len(sources), source_files if source_files is not None else "-", status]] click.echo(tabulate(rows, ["Coverage Files", "Source Files", "Status"], tablefmt="github")) + if is_reupload: + click.echo( + click.style( + "⚠ This session already had coverage recorded. The previous upload was overwritten. " + "Refer to --help if you intended to record multiple files", + fg="yellow"), + err=True) From bff7170dd109c1e887b277c812dc6bd4fa32e7fe Mon Sep 17 00:00:00 2001 From: gayanW Date: Mon, 26 Oct 2026 15:20:58 +0900 Subject: [PATCH 4/5] Fix merge conflict in tests.py --- smart_tests/commands/record/tests.py | 30 ---------------------------- 1 file changed, 30 deletions(-) diff --git a/smart_tests/commands/record/tests.py b/smart_tests/commands/record/tests.py index 4f3737295..d5fa56b5b 100644 --- a/smart_tests/commands/record/tests.py +++ b/smart_tests/commands/record/tests.py @@ -530,36 +530,6 @@ def summarize_case_events(events: Iterable[CaseEventType]) -> Tuple[int, int, in tests = Group(name="tests", callback=RecordTests, help="Record test results") -def summarize_case_events(events: Iterable[CaseEventType]) -> Tuple[int, int, int, float]: - """ - Summarizes a stream of case events into (test_count, success_count, fail_count, duration_minutes), - for the "Tests found / Tests passed / Tests failed" table printed after `record tests` runs. - - Grouped by testPath rather than counted per event: a flaky/rerun testcase produces multiple - events (see CaseEvent.from_case_and_suite), but it's still a single test. A test that ever - passed counts as passed (even if it also failed on earlier attempts); a test that only ever - failed counts as failed. - """ - tests: Dict[Tuple, Dict[str, bool]] = {} - duration = float(0) - - for tc in events: - key = tuple((p["type"], p["name"]) for p in tc.get("testPath", [])) - entry = tests.setdefault(key, {"passed": False, "failed": False}) - status = tc.get("status") - if status == 1: - entry["passed"] = True - elif status == 0: - entry["failed"] = True - duration += float(tc.get("duration") or 0) # sec - - test_count = len(tests) - success_count = sum(1 for e in tests.values() if e["passed"]) - fail_count = sum(1 for e in tests.values() if e["failed"] and not e["passed"]) - - return test_count, success_count, fail_count, duration / 60 # sec to min - - # if we fail to determine the timestamp of the build, we err on the side of collecting more test reports # than no test reports, so we use the 'epoch' timestamp INVALID_TIMESTAMP = datetime.datetime.fromtimestamp(0) From 1135e399b661cb5fe00dab7e4c38699f222a5681 Mon Sep 17 00:00:00 2001 From: gayanW Date: Mon, 26 Oct 2026 15:29:35 +0900 Subject: [PATCH 5/5] Update tests.py to match main --- smart_tests/commands/record/tests.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/smart_tests/commands/record/tests.py b/smart_tests/commands/record/tests.py index d5fa56b5b..1f41fc01b 100644 --- a/smart_tests/commands/record/tests.py +++ b/smart_tests/commands/record/tests.py @@ -497,6 +497,9 @@ def _get_test_session_url(self) -> str: f"{self.workspace}/test-sessions/{self.test_session_id}") +tests = Group(name="tests", callback=RecordTests, help="Record test results") + + def summarize_case_events(events: Iterable[CaseEventType]) -> Tuple[int, int, int, float]: """ Summarizes a stream of case events into (test_count, success_count, fail_count, duration_minutes), @@ -527,9 +530,6 @@ def summarize_case_events(events: Iterable[CaseEventType]) -> Tuple[int, int, in return test_count, success_count, fail_count, duration / 60 # sec to min -tests = Group(name="tests", callback=RecordTests, help="Record test results") - - # if we fail to determine the timestamp of the build, we err on the side of collecting more test reports # than no test reports, so we use the 'epoch' timestamp INVALID_TIMESTAMP = datetime.datetime.fromtimestamp(0)