Skip to content

Commit a111907

Browse files
authored
Merge pull request #103 from testing-cabal/gotest2subunit
Add gojson2subunit script for `go test -json` streams
2 parents 9c56f4e + 62d3764 commit a111907

4 files changed

Lines changed: 416 additions & 0 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ where = ["python"]
6666
"subunit2gtk" = "subunit.filter_scripts.subunit2gtk:main"
6767
"subunit2junitxml" = "subunit.filter_scripts.subunit2junitxml:main"
6868
"subunit2pyunit" = "subunit.filter_scripts.subunit2pyunit:main"
69+
"gojson2subunit" = "subunit.filter_scripts.gojson2subunit:main"
6970
"tap2subunit" = "subunit.filter_scripts.tap2subunit:main"
7071

7172
[tool.setuptools.dynamic]

python/subunit/__init__.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1113,6 +1113,154 @@ def _emit_test():
11131113
return 0
11141114

11151115

1116+
def GoJSON2SubUnit(gojson, output_stream):
1117+
"""Filter a `go test -json` stream into a subunit v2 byte stream.
1118+
1119+
`go test -json` (or `go tool test2json`) emits one JSON object per line.
1120+
Each object carries an `Action` (`run`, `output`, `pass`, `fail`, `skip`,
1121+
`pause`, `cont`, `bench`, `start`), a `Package`, an optional `Test`, and
1122+
on terminal events an `Elapsed` and `Time`. This function maps each
1123+
test's lifecycle to a pair of subunit packets — `inprogress` at the
1124+
`run` event and the final status at the terminal event — so the
1125+
consumer can derive a duration. Captured `output` lines for the test
1126+
are folded into a single `text/plain; charset=UTF8` attachment on the
1127+
terminal packet.
1128+
1129+
Test IDs are formed as ``<package>.<TestName>``. Subtests keep Go's
1130+
native ``Parent/Sub`` form, so the resulting ID is
1131+
``<package>.<Parent>/<Sub>``, which round-trips through
1132+
``go test -run '^Parent$/^Sub$'``.
1133+
1134+
Package-level failures (no `Test` field on a `fail` event — typically
1135+
a build error) are reported as a synthetic ``<package> [build]`` test
1136+
so the failure shows up alongside the test results instead of being
1137+
silently swallowed.
1138+
1139+
:param gojson: An iterable of text lines (e.g. ``sys.stdin``) carrying
1140+
the `go test -json` stream.
1141+
:param output_stream: A binary stream to write subunit v2 bytes to.
1142+
:return: 0 if no test failed, 1 otherwise — matching the convention
1143+
used by `TAP2SubUnit`.
1144+
"""
1145+
import json
1146+
1147+
output = StreamResultToBytes(output_stream)
1148+
UTF8_TEXT = "text/plain; charset=UTF8"
1149+
# Per-test buffered `output` chunks plus the start timestamp captured
1150+
# on the `run` event, keyed by full test_id.
1151+
buffers = {}
1152+
start_times = {}
1153+
# Per-package buffered output, used to attribute build / setup failures
1154+
# that don't carry a `Test` field.
1155+
pkg_buffers = {}
1156+
any_failed = False
1157+
1158+
def parse_time(value):
1159+
if not value:
1160+
return None
1161+
try:
1162+
return iso8601.parse_date(value)
1163+
except (TypeError, ValueError, iso8601.ParseError):
1164+
return None
1165+
1166+
def make_test_id(pkg, test):
1167+
# Both package and test are required for an unambiguous ID; the
1168+
# caller checks for `Test` before reaching here.
1169+
return "{}.{}".format(pkg, test)
1170+
1171+
for line in gojson:
1172+
line = line.strip()
1173+
if not line:
1174+
continue
1175+
try:
1176+
event = json.loads(line)
1177+
except (TypeError, ValueError):
1178+
# `go test -json` occasionally interleaves a non-JSON banner
1179+
# (e.g. on a panic during package init). Drop it rather than
1180+
# aborting the whole stream.
1181+
continue
1182+
if not isinstance(event, dict):
1183+
continue
1184+
1185+
action = event.get("Action")
1186+
pkg = event.get("Package") or ""
1187+
test = event.get("Test")
1188+
timestamp = parse_time(event.get("Time"))
1189+
1190+
if action == "output":
1191+
chunk = event.get("Output", "")
1192+
if test:
1193+
buffers.setdefault(make_test_id(pkg, test), []).append(chunk)
1194+
elif pkg:
1195+
pkg_buffers.setdefault(pkg, []).append(chunk)
1196+
continue
1197+
1198+
if action == "run" and test:
1199+
test_id = make_test_id(pkg, test)
1200+
start_times[test_id] = timestamp
1201+
output.status(
1202+
test_id=test_id,
1203+
test_status="inprogress",
1204+
timestamp=timestamp,
1205+
)
1206+
continue
1207+
1208+
if action in ("pass", "fail", "skip") and test:
1209+
status = {"pass": "success", "fail": "fail", "skip": "skip"}[action]
1210+
test_id = make_test_id(pkg, test)
1211+
chunks = buffers.pop(test_id, [])
1212+
start_times.pop(test_id, None)
1213+
file_bytes = ("".join(chunks)).encode("utf-8") if chunks else None
1214+
output.status(
1215+
test_id=test_id,
1216+
test_status=status,
1217+
eof=True,
1218+
file_name="go test output" if file_bytes else None,
1219+
file_bytes=file_bytes,
1220+
mime_type=UTF8_TEXT if file_bytes else None,
1221+
timestamp=timestamp,
1222+
)
1223+
if action == "fail":
1224+
any_failed = True
1225+
continue
1226+
1227+
if action == "fail" and not test and pkg:
1228+
# Package-level failure (build error, init panic, etc.). Emit
1229+
# a synthetic test so the failure is visible.
1230+
chunks = pkg_buffers.pop(pkg, [])
1231+
file_bytes = ("".join(chunks)).encode("utf-8") if chunks else None
1232+
output.status(
1233+
test_id="{} [build]".format(pkg),
1234+
test_status="fail",
1235+
eof=True,
1236+
file_name="go test output" if file_bytes else None,
1237+
file_bytes=file_bytes,
1238+
mime_type=UTF8_TEXT if file_bytes else None,
1239+
timestamp=timestamp,
1240+
)
1241+
any_failed = True
1242+
continue
1243+
1244+
# `pass`/`skip` without `Test` is a package-level summary; harmless
1245+
# to drop. `pause`/`cont`/`start`/`bench` aren't terminal — skip.
1246+
1247+
# Any tests still in-progress at EOF were aborted (the runner died
1248+
# mid-test). Surface them as failures so they're not silently lost.
1249+
for test_id, chunks in list(buffers.items()):
1250+
file_bytes = ("".join(chunks)).encode("utf-8") if chunks else None
1251+
output.status(
1252+
test_id=test_id,
1253+
test_status="fail",
1254+
eof=True,
1255+
file_name="go test output" if file_bytes else None,
1256+
file_bytes=file_bytes,
1257+
mime_type=UTF8_TEXT if file_bytes else None,
1258+
)
1259+
any_failed = True
1260+
1261+
return 1 if any_failed else 0
1262+
1263+
11161264
def tag_stream(original, filtered, tags):
11171265
"""Alter tags on a stream.
11181266
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#!/usr/bin/env python3
2+
# subunit: extensions to python unittest to get test results from subprocesses.
3+
# Copyright (C) 2026 Jelmer Vernooij <jelmer@samba.org>
4+
#
5+
# Licensed under either the Apache License, Version 2.0 or the BSD 3-clause
6+
# license at the users choice. A copy of both licenses are available in the
7+
# project source as Apache-2.0 and BSD. You may not use this file except in
8+
# compliance with one of these two licences.
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under these licenses is distributed on an "AS IS" BASIS, WITHOUT
12+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
# license you chose for the specific language governing permissions and
14+
# limitations under that license.
15+
#
16+
17+
"""A filter that reads a `go test -json` stream and outputs a subunit stream.
18+
19+
Pipe Go's structured test output into this script:
20+
21+
go test -json ./... | gojson2subunit
22+
23+
The conversion preserves per-test elapsed time (via paired ``inprogress`` /
24+
terminal subunit packets) and folds captured stdout/stderr lines into a
25+
single ``text/plain`` attachment on each terminal packet.
26+
"""
27+
28+
import sys
29+
30+
from subunit import GoJSON2SubUnit
31+
32+
33+
def main():
34+
sys.exit(GoJSON2SubUnit(sys.stdin, sys.stdout.buffer))
35+
36+
37+
if __name__ == "__main__":
38+
main()

0 commit comments

Comments
 (0)