From 62d3764e43f3358c034d97a23104cb89b6ede734 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jelmer=20Vernoo=C4=B3?= Date: Mon, 27 Apr 2026 10:59:44 +0100 Subject: [PATCH] Add gojson2subunit script for `go test -json` streams --- pyproject.toml | 1 + python/subunit/__init__.py | 148 +++++++++++ .../subunit/filter_scripts/gojson2subunit.py | 38 +++ python/tests/test_gojson2subunit.py | 229 ++++++++++++++++++ 4 files changed, 416 insertions(+) create mode 100644 python/subunit/filter_scripts/gojson2subunit.py create mode 100644 python/tests/test_gojson2subunit.py diff --git a/pyproject.toml b/pyproject.toml index b9295c1..a07390d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,7 @@ where = ["python"] "subunit2gtk" = "subunit.filter_scripts.subunit2gtk:main" "subunit2junitxml" = "subunit.filter_scripts.subunit2junitxml:main" "subunit2pyunit" = "subunit.filter_scripts.subunit2pyunit:main" +"gojson2subunit" = "subunit.filter_scripts.gojson2subunit:main" "tap2subunit" = "subunit.filter_scripts.tap2subunit:main" [tool.setuptools.dynamic] diff --git a/python/subunit/__init__.py b/python/subunit/__init__.py index eb18e68..b89c41d 100644 --- a/python/subunit/__init__.py +++ b/python/subunit/__init__.py @@ -1113,6 +1113,154 @@ def _emit_test(): return 0 +def GoJSON2SubUnit(gojson, output_stream): + """Filter a `go test -json` stream into a subunit v2 byte stream. + + `go test -json` (or `go tool test2json`) emits one JSON object per line. + Each object carries an `Action` (`run`, `output`, `pass`, `fail`, `skip`, + `pause`, `cont`, `bench`, `start`), a `Package`, an optional `Test`, and + on terminal events an `Elapsed` and `Time`. This function maps each + test's lifecycle to a pair of subunit packets — `inprogress` at the + `run` event and the final status at the terminal event — so the + consumer can derive a duration. Captured `output` lines for the test + are folded into a single `text/plain; charset=UTF8` attachment on the + terminal packet. + + Test IDs are formed as ``.``. Subtests keep Go's + native ``Parent/Sub`` form, so the resulting ID is + ``./``, which round-trips through + ``go test -run '^Parent$/^Sub$'``. + + Package-level failures (no `Test` field on a `fail` event — typically + a build error) are reported as a synthetic `` [build]`` test + so the failure shows up alongside the test results instead of being + silently swallowed. + + :param gojson: An iterable of text lines (e.g. ``sys.stdin``) carrying + the `go test -json` stream. + :param output_stream: A binary stream to write subunit v2 bytes to. + :return: 0 if no test failed, 1 otherwise — matching the convention + used by `TAP2SubUnit`. + """ + import json + + output = StreamResultToBytes(output_stream) + UTF8_TEXT = "text/plain; charset=UTF8" + # Per-test buffered `output` chunks plus the start timestamp captured + # on the `run` event, keyed by full test_id. + buffers = {} + start_times = {} + # Per-package buffered output, used to attribute build / setup failures + # that don't carry a `Test` field. + pkg_buffers = {} + any_failed = False + + def parse_time(value): + if not value: + return None + try: + return iso8601.parse_date(value) + except (TypeError, ValueError, iso8601.ParseError): + return None + + def make_test_id(pkg, test): + # Both package and test are required for an unambiguous ID; the + # caller checks for `Test` before reaching here. + return "{}.{}".format(pkg, test) + + for line in gojson: + line = line.strip() + if not line: + continue + try: + event = json.loads(line) + except (TypeError, ValueError): + # `go test -json` occasionally interleaves a non-JSON banner + # (e.g. on a panic during package init). Drop it rather than + # aborting the whole stream. + continue + if not isinstance(event, dict): + continue + + action = event.get("Action") + pkg = event.get("Package") or "" + test = event.get("Test") + timestamp = parse_time(event.get("Time")) + + if action == "output": + chunk = event.get("Output", "") + if test: + buffers.setdefault(make_test_id(pkg, test), []).append(chunk) + elif pkg: + pkg_buffers.setdefault(pkg, []).append(chunk) + continue + + if action == "run" and test: + test_id = make_test_id(pkg, test) + start_times[test_id] = timestamp + output.status( + test_id=test_id, + test_status="inprogress", + timestamp=timestamp, + ) + continue + + if action in ("pass", "fail", "skip") and test: + status = {"pass": "success", "fail": "fail", "skip": "skip"}[action] + test_id = make_test_id(pkg, test) + chunks = buffers.pop(test_id, []) + start_times.pop(test_id, None) + file_bytes = ("".join(chunks)).encode("utf-8") if chunks else None + output.status( + test_id=test_id, + test_status=status, + eof=True, + file_name="go test output" if file_bytes else None, + file_bytes=file_bytes, + mime_type=UTF8_TEXT if file_bytes else None, + timestamp=timestamp, + ) + if action == "fail": + any_failed = True + continue + + if action == "fail" and not test and pkg: + # Package-level failure (build error, init panic, etc.). Emit + # a synthetic test so the failure is visible. + chunks = pkg_buffers.pop(pkg, []) + file_bytes = ("".join(chunks)).encode("utf-8") if chunks else None + output.status( + test_id="{} [build]".format(pkg), + test_status="fail", + eof=True, + file_name="go test output" if file_bytes else None, + file_bytes=file_bytes, + mime_type=UTF8_TEXT if file_bytes else None, + timestamp=timestamp, + ) + any_failed = True + continue + + # `pass`/`skip` without `Test` is a package-level summary; harmless + # to drop. `pause`/`cont`/`start`/`bench` aren't terminal — skip. + + # Any tests still in-progress at EOF were aborted (the runner died + # mid-test). Surface them as failures so they're not silently lost. + for test_id, chunks in list(buffers.items()): + file_bytes = ("".join(chunks)).encode("utf-8") if chunks else None + output.status( + test_id=test_id, + test_status="fail", + eof=True, + file_name="go test output" if file_bytes else None, + file_bytes=file_bytes, + mime_type=UTF8_TEXT if file_bytes else None, + ) + any_failed = True + + return 1 if any_failed else 0 + + def tag_stream(original, filtered, tags): """Alter tags on a stream. diff --git a/python/subunit/filter_scripts/gojson2subunit.py b/python/subunit/filter_scripts/gojson2subunit.py new file mode 100644 index 0000000..d648c5d --- /dev/null +++ b/python/subunit/filter_scripts/gojson2subunit.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +# subunit: extensions to python unittest to get test results from subprocesses. +# Copyright (C) 2026 Jelmer Vernooij +# +# Licensed under either the Apache License, Version 2.0 or the BSD 3-clause +# license at the users choice. A copy of both licenses are available in the +# project source as Apache-2.0 and BSD. You may not use this file except in +# compliance with one of these two licences. +# +# Unless required by applicable law or agreed to in writing, software +# distributed under these licenses is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# license you chose for the specific language governing permissions and +# limitations under that license. +# + +"""A filter that reads a `go test -json` stream and outputs a subunit stream. + +Pipe Go's structured test output into this script: + + go test -json ./... | gojson2subunit + +The conversion preserves per-test elapsed time (via paired ``inprogress`` / +terminal subunit packets) and folds captured stdout/stderr lines into a +single ``text/plain`` attachment on each terminal packet. +""" + +import sys + +from subunit import GoJSON2SubUnit + + +def main(): + sys.exit(GoJSON2SubUnit(sys.stdin, sys.stdout.buffer)) + + +if __name__ == "__main__": + main() diff --git a/python/tests/test_gojson2subunit.py b/python/tests/test_gojson2subunit.py new file mode 100644 index 0000000..244259c --- /dev/null +++ b/python/tests/test_gojson2subunit.py @@ -0,0 +1,229 @@ +# +# subunit: extensions to python unittest to get test results from subprocesses. +# Copyright (C) 2026 Jelmer Vernooij +# +# Licensed under either the Apache License, Version 2.0 or the BSD 3-clause +# license at the users choice. A copy of both licenses are available in the +# project source as Apache-2.0 and BSD. You may not use this file except in +# compliance with one of these two licences. +# +# Unless required by applicable law or agreed to in writing, software +# distributed under these licenses is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# license you chose for the specific language governing permissions and +# limitations under that license. +# + +"""Tests for GoJSON2SubUnit.""" + +import json +from io import BytesIO, StringIO + +from testtools import TestCase +from testtools.testresult.doubles import StreamResult + +import subunit + +UTF8_TEXT = "text/plain; charset=UTF8" + + +def _ndjson(*events): + """Render an iterable of dicts as one JSON object per line.""" + return "\n".join(json.dumps(e) for e in events) + "\n" + + +class TestGoJSON2SubUnit(TestCase): + """Behavioural tests for `GoJSON2SubUnit`. + + Each test feeds a synthetic `go test -json` event stream in, decodes + the resulting subunit bytes back into events via `StreamResult`, and + asserts on the (status, test_id, test_status, ...) tuples. + """ + + def setUp(self): + super().setUp() + self.gojson = StringIO() + self.subunit = BytesIO() + + def _events(self): + self.subunit.seek(0) + sink = StreamResult() + subunit.ByteStreamToStreamResult(self.subunit).run(sink) + return sink._events + + def _statuses(self, events): + # Strip the verbose tuple down to (test_id, test_status) for clearer + # assertions when timing/file content isn't what's being tested. + return [(e[1], e[2]) for e in events if e[0] == "status"] + + def test_pass_emits_inprogress_then_success(self): + self.gojson.write( + _ndjson( + {"Action": "run", "Package": "pkg/a", "Test": "TestFoo"}, + {"Action": "pass", "Package": "pkg/a", "Test": "TestFoo", "Elapsed": 0.01}, + ) + ) + self.gojson.seek(0) + rc = subunit.GoJSON2SubUnit(self.gojson, self.subunit) + self.assertEqual(0, rc) + self.assertEqual( + [("pkg/a.TestFoo", "inprogress"), ("pkg/a.TestFoo", "success")], + self._statuses(self._events()), + ) + + def test_fail_returns_nonzero(self): + self.gojson.write( + _ndjson( + {"Action": "run", "Package": "pkg/a", "Test": "TestBad"}, + {"Action": "run", "Package": "pkg/a", "Test": "TestGood"}, + {"Action": "pass", "Package": "pkg/a", "Test": "TestGood", "Elapsed": 0.01}, + {"Action": "fail", "Package": "pkg/a", "Test": "TestBad", "Elapsed": 0.02}, + ) + ) + self.gojson.seek(0) + rc = subunit.GoJSON2SubUnit(self.gojson, self.subunit) + self.assertEqual(1, rc) + # Each test produces an inprogress packet and a terminal packet; + # check the terminal status (last one wins in dict()). + statuses = dict(self._statuses(self._events())) + self.assertEqual("fail", statuses["pkg/a.TestBad"]) + self.assertEqual("success", statuses["pkg/a.TestGood"]) + + def test_skip_status(self): + self.gojson.write( + _ndjson( + {"Action": "run", "Package": "pkg/a", "Test": "TestSkipped"}, + {"Action": "skip", "Package": "pkg/a", "Test": "TestSkipped", "Elapsed": 0.0}, + ) + ) + self.gojson.seek(0) + rc = subunit.GoJSON2SubUnit(self.gojson, self.subunit) + self.assertEqual(0, rc) + self.assertIn(("pkg/a.TestSkipped", "skip"), self._statuses(self._events())) + + def test_subtest_keeps_slash_separator(self): + # Go subtests are reported as "Parent/Sub"; the resulting test ID + # should be ".Parent/Sub" so it round-trips through + # `go test -run '^Parent$/^Sub$'`. + self.gojson.write( + _ndjson( + {"Action": "run", "Package": "pkg/a", "Test": "TestParent/sub_one"}, + {"Action": "pass", "Package": "pkg/a", "Test": "TestParent/sub_one", "Elapsed": 0.01}, + ) + ) + self.gojson.seek(0) + subunit.GoJSON2SubUnit(self.gojson, self.subunit) + self.assertIn( + ("pkg/a.TestParent/sub_one", "success"), + self._statuses(self._events()), + ) + + def test_output_attached_to_terminal_packet(self): + self.gojson.write( + _ndjson( + {"Action": "run", "Package": "pkg/a", "Test": "TestNoisy"}, + {"Action": "output", "Package": "pkg/a", "Test": "TestNoisy", "Output": "hello\n"}, + {"Action": "output", "Package": "pkg/a", "Test": "TestNoisy", "Output": "world\n"}, + {"Action": "fail", "Package": "pkg/a", "Test": "TestNoisy", "Elapsed": 0.01}, + ) + ) + self.gojson.seek(0) + subunit.GoJSON2SubUnit(self.gojson, self.subunit) + events = self._events() + # Find the terminal packet for TestNoisy and confirm both lines + # were folded into one attachment. + terminal = [e for e in events if e[0] == "status" and e[1] == "pkg/a.TestNoisy" and e[2] == "fail"] + self.assertEqual(1, len(terminal)) + # Tuple shape from StreamResult: + # ("status", test_id, test_status, test_tags, runnable, file_name, + # file_bytes, eof, mime_type, route_code, timestamp) + ev = terminal[0] + self.assertEqual("go test output", ev[5]) + self.assertEqual(b"hello\nworld\n", ev[6]) + self.assertEqual(UTF8_TEXT, ev[8]) + + def test_package_level_build_failure_synthesises_test(self): + # When `go test` can't build a package it emits a `fail` event with + # no `Test` field, preceded by `output` events scoped to the package. + self.gojson.write( + _ndjson( + {"Action": "output", "Package": "pkg/broken", "Output": "./x.go:1:1: syntax error\n"}, + {"Action": "fail", "Package": "pkg/broken", "Elapsed": 0.0}, + ) + ) + self.gojson.seek(0) + rc = subunit.GoJSON2SubUnit(self.gojson, self.subunit) + self.assertEqual(1, rc) + events = self._events() + terminal = [e for e in events if e[0] == "status" and e[1] == "pkg/broken [build]" and e[2] == "fail"] + self.assertEqual(1, len(terminal)) + self.assertEqual(b"./x.go:1:1: syntax error\n", terminal[0][6]) + + def test_garbage_lines_are_skipped(self): + # `go test -json` occasionally interleaves a non-JSON banner on + # certain failure paths; a junk line shouldn't abort the stream. + self.gojson.write("not json at all\n") + self.gojson.write( + _ndjson( + {"Action": "run", "Package": "pkg/a", "Test": "TestFoo"}, + {"Action": "pass", "Package": "pkg/a", "Test": "TestFoo", "Elapsed": 0.0}, + ) + ) + self.gojson.seek(0) + rc = subunit.GoJSON2SubUnit(self.gojson, self.subunit) + self.assertEqual(0, rc) + self.assertIn(("pkg/a.TestFoo", "success"), self._statuses(self._events())) + + def test_blank_lines_are_skipped(self): + self.gojson.write("\n\n") + self.gojson.write(_ndjson({"Action": "run", "Package": "pkg/a", "Test": "TestFoo"})) + self.gojson.write("\n") + self.gojson.write(_ndjson({"Action": "pass", "Package": "pkg/a", "Test": "TestFoo", "Elapsed": 0.0})) + self.gojson.seek(0) + rc = subunit.GoJSON2SubUnit(self.gojson, self.subunit) + self.assertEqual(0, rc) + self.assertIn(("pkg/a.TestFoo", "success"), self._statuses(self._events())) + + def test_unfinished_test_at_eof_is_failed(self): + # A test that started but never reached a terminal action — the + # runner died mid-test. Surface as a failure rather than dropping it. + self.gojson.write( + _ndjson( + {"Action": "run", "Package": "pkg/a", "Test": "TestStuck"}, + {"Action": "output", "Package": "pkg/a", "Test": "TestStuck", "Output": "panic\n"}, + ) + ) + self.gojson.seek(0) + rc = subunit.GoJSON2SubUnit(self.gojson, self.subunit) + self.assertEqual(1, rc) + events = self._events() + terminal = [e for e in events if e[0] == "status" and e[1] == "pkg/a.TestStuck" and e[2] == "fail"] + self.assertEqual(1, len(terminal)) + self.assertEqual(b"panic\n", terminal[0][6]) + + def test_timestamp_is_propagated(self): + # The `Time` on each event should land on the matching subunit packet. + self.gojson.write( + _ndjson( + { + "Time": "2026-01-02T03:04:05.000000Z", + "Action": "run", + "Package": "pkg/a", + "Test": "TestTimed", + }, + { + "Time": "2026-01-02T03:04:06.000000Z", + "Action": "pass", + "Package": "pkg/a", + "Test": "TestTimed", + "Elapsed": 1.0, + }, + ) + ) + self.gojson.seek(0) + subunit.GoJSON2SubUnit(self.gojson, self.subunit) + # The terminal packet's timestamp (last tuple element) must be set, + # which gives consumers a basis for computing duration. + terminal = [e for e in self._events() if e[0] == "status" and e[1] == "pkg/a.TestTimed" and e[2] == "success"] + self.assertEqual(1, len(terminal)) + self.assertIsNotNone(terminal[0][-1])