Show test results on PRs (#73)

This commit is contained in:
Jason Mitchell
2026-10-12 19:15:42 +02:00
committed by GitHub
parent be3d2919b6
commit 042346f789
5 changed files with 529 additions and 18 deletions
+41 -18
View File
@@ -31,6 +31,11 @@ on:
required: false
default: false
type: boolean
disable-test-comment:
description: 'Do not create or update the PR test-results comment'
required: false
default: false
type: boolean
horizonqa:
description: 'Run Horizon-QA in CI mode during runServer'
required: false
@@ -54,6 +59,7 @@ on:
env:
ARTIFACT_RETENTION_DAYS: ${{ github.event.repository.private && 5 || 30 }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -153,13 +159,43 @@ jobs:
id: build_mod
run: xvfb-run --server-args="-screen 0 1366x768x24" ./gradlew --build-cache --info --stacktrace build
- name: Summarize test results
id: test_results
if: ${{ !cancelled() && steps.build_mod.conclusion != 'skipped' }}
continue-on-error: true
run: python3 .gtnh-workflows/scripts/test_results.py render
- name: Attach test report
id: test_report
if: ${{ !cancelled() && steps.test_results.outputs.report != '' }}
uses: actions/upload-artifact@v7
continue-on-error: true
with:
path: ${{ steps.test_results.outputs.report }}
retention-days: ${{ env.ARTIFACT_RETENTION_DAYS }}
archive: false
- name: Publish test results
if: ${{ !cancelled() && steps.test_results.outputs.report != '' }}
continue-on-error: true
run: python3 .gtnh-workflows/scripts/test_results.py publish
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Fork PRs get a read-only token: job summary only, no status or comment
CAN_PUBLISH: ${{ !github.event.pull_request || github.event.pull_request.head.repo.full_name == github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPORT_URL: ${{ steps.test_report.outputs.artifact-url }}
DISABLE_COMMENT: ${{ inputs.disable-test-comment }}
- name: Attach gradle reports
if: failure() && steps.build_mod.conclusion == 'failure'
uses: actions/upload-artifact@v7
continue-on-error: true
with:
name: ${{ github.repository_id }}-reports
path: build/reports/
path: |
**/build/reports/
**/build/test-results/**/*.xml
retention-days: ${{ env.ARTIFACT_RETENTION_DAYS }}
- name: Attempt to make a PR fixing spotless errors
@@ -169,27 +205,14 @@ jobs:
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository }}
run: |
git reset --hard
git checkout "${PR_BRANCH}"
./gradlew --build-cache --info --stacktrace spotlessApply || exit 1
git diff --exit-code && exit 1
git config user.name "GitHub GTNH Actions"
git config user.email "<>"
git switch -c "${FIXED_BRANCH}"
git commit -am "spotlessApply"
git push --force-with-lease origin "${FIXED_BRANCH}"
gh pr create \
--head "${FIXED_BRANCH}" \
--base "${PR_BRANCH}" \
--title "Spotless apply for branch ${PR_BRANCH} for #${{ github.event.pull_request.number }}" \
--body "Automatic spotless apply to fix formatting errors, applies to PR #${{ github.event.pull_request.number }}" \
2>&1 | tee pr-message.log || true
gh pr comment "${PR_BRANCH}" -F pr-message.log || true
shell: bash # ensures set -eo pipefail
chmod +x .gtnh-workflows/scripts/spotless_fix_pr
.gtnh-workflows/scripts/spotless_fix_pr
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_BRANCH: ${{ github.head_ref }}
FIXED_BRANCH: ${{ github.head_ref }}-spotless-fixes
PR_NUMBER: ${{ github.event.pull_request.number }}
- name: Run server for up to ${{ inputs.timeout }} seconds
id: run_server
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
GRADLEW="${GRADLEW:-./gradlew}"
git reset --hard
git checkout "${PR_BRANCH}"
if ! probe_output="$("${GRADLEW}" --build-cache -q help --task spotlessApply 2>&1)"; then
if grep -q "Task 'spotlessApply' not found" <<< "${probe_output}"; then
echo "spotlessApply does not exist in this build; nothing to do"
exit 0
fi
echo "${probe_output}"
echo "Could not determine whether spotlessApply exists"
exit 1
fi
"${GRADLEW}" --build-cache --info --stacktrace spotlessApply
if git diff --exit-code; then
echo "spotlessApply changed nothing; no fix PR created"
exit 0
fi
git config user.name "GitHub GTNH Actions"
git config user.email "<>"
git switch -c "${FIXED_BRANCH}"
git commit -am "spotlessApply"
git push --force-with-lease origin "${FIXED_BRANCH}"
gh pr create \
--head "${FIXED_BRANCH}" \
--base "${PR_BRANCH}" \
--title "Spotless apply for branch ${PR_BRANCH} for #${PR_NUMBER}" \
--body "Automatic spotless apply to fix formatting errors, applies to PR #${PR_NUMBER}" \
2>&1 | tee pr-message.log || true
gh pr comment "${PR_BRANCH}" -F pr-message.log || true
+105
View File
@@ -0,0 +1,105 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Test results</title>
<style>
:root { color-scheme: light dark; }
body { font: 15px system-ui, sans-serif; margin: 24px auto; max-width: 1200px; padding: 0 16px; }
table { border-collapse: collapse; width: 100%; }
td { padding: 8px; border-bottom: 1px solid #8884; vertical-align: top; }
td:first-child { width: 5em; }
td:last-child { width: 6em; text-align: right; white-space: nowrap; }
small { display: block; opacity: .7; overflow-wrap: anywhere; }
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
.bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 16px 0; }
.bar input { flex: 1; min-width: 14em; }
button, input { font: inherit; padding: 6px 12px; border: 1px solid #8888; border-radius: 6px; background: none; color: inherit; }
button { cursor: pointer; }
button.on { background: #0969da; border-color: #0969da; color: #fff; }
details.g { border: 1px solid #8884; border-radius: 6px; margin: 8px 0; padding: 0 12px; }
details.g > summary { cursor: pointer; padding: 10px 0; }
.passed { color: #1a7f37; }
.failed { color: #cf222e; font-weight: 600; }
.skipped { color: #9a6700; }
tr[data-slow] td:last-child { color: #9a6700; font-weight: 600; }
</style>
<h1>Test results</h1>
{{head}}
<div class="bar">
<button data-f="" class="on">All</button>
<button data-f="failed">Failed</button>
<button data-f="skipped">Skipped</button>
<button data-f="passed">Passed</button>
<button data-f="slow">Slow</button>
<input id="q" placeholder="Search name, class or output" aria-label="Search">
<button id="open">Expand all</button>
<button id="close">Collapse all</button>
</div>
<div class="bar">
Sort within each group:
<button data-s="run" class="on">Run order</button>
<button data-s="name">Name</button>
<button data-s="slow">Slowest</button>
</div>
<p id="n"></p>
{{body}}
<script>
const rows = [...document.querySelectorAll('tr[data-o]')];
const groups = [...document.querySelectorAll('details.g')];
const filters = [...document.querySelectorAll('button[data-f]')];
const sorts = [...document.querySelectorAll('button[data-s]')];
const failedSection = document.getElementById('failed');
const q = document.getElementById('q');
const shown = 'tr[data-o]:not([hidden])';
const matches = (row, filter) => !filter || (filter === 'slow' ? 'slow' in row.dataset : row.dataset.o === filter);
const order = {
run: (a, b) => a.pos - b.pos,
name: (a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : a.pos - b.pos,
slow: (a, b) => b.dataset.t - a.dataset.t || a.pos - b.pos
};
let active = '';
// Filtering opens the groups that match and hides the rest; clearing it restores the collapsed default.
function apply() {
const text = q.value.toLowerCase();
let count = 0;
for (const row of rows) {
row.hidden = !(matches(row, active) && row.cells[1].textContent.toLowerCase().includes(text));
if (!row.hidden) count++;
}
for (const group of groups) {
const hit = group.querySelector(shown);
group.hidden = !hit;
group.open = !!((active || text) && hit);
}
if (failedSection) failedSection.hidden = !failedSection.querySelector(shown);
document.getElementById('n').textContent = 'Showing ' + count + ' of ' + rows.length + ' tests';
}
rows.forEach((row, pos) => {
row.pos = pos;
row.key = row.querySelector('b').textContent.toLowerCase();
});
for (const button of filters) {
button.textContent += ' ' + rows.filter(row => matches(row, button.dataset.f)).length;
button.addEventListener('click', () => {
active = button.dataset.f;
for (const other of filters) other.classList.toggle('on', other === button);
apply();
});
}
for (const button of sorts) {
button.addEventListener('click', () => {
for (const other of sorts) other.classList.toggle('on', other === button);
for (const body of document.querySelectorAll('tbody')) {
for (const row of [...body.rows].sort(order[button.dataset.s])) body.append(row);
}
});
}
q.addEventListener('input', apply);
document.getElementById('open').addEventListener('click', () => { for (const group of groups) group.open = true; });
document.getElementById('close').addEventListener('click', () => { for (const group of groups) group.open = false; });
apply();
</script>
</html>
+342
View File
@@ -0,0 +1,342 @@
#!/usr/bin/env python3
"""Render Gradle JUnit XML into a summary and report (render); post them to the job summary, commit and PR (publish)."""
import glob
import json
import os
import re
import statistics
import subprocess
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
PRUNE = {'.git', '.gradle', '.gtnh-workflows'}
MAX_SUMMARY = 45000 # ASCII chars; under the job summary limit
MAX_NAME = 200
MAX_LINE = 300 # chars
MAX_LINES = 10 # per failure
MAX_FAILURES = 5 # shown with output
MAX_LISTED = 10 # GitHub shows at most 10 error annotations per step
MARKER = '<!-- gtnh-test-results -->'
STATUS_CONTEXT = 'Tests'
SLOW_FLOOR = 1.0 # seconds
SLOW_FACTOR = 10 # x median
OUTCOMES = ('failed', 'passed', 'skipped')
HEADING = '### Test results: {}'
OMITTED = '{} more not shown; see the report.'
def report_dir():
return Path(os.environ['RUNNER_TEMP'], 'test-report')
def find_reports(root):
"""Yield (task key, project dir, xml path) for <project>/build/test-results/<task>/TEST-*.xml."""
for current, dirs, files in os.walk(root):
dirs[:] = sorted(d for d in dirs if d not in PRUNE)
path = Path(current)
if path.parent.name != 'test-results' or path.parent.parent.name != 'build':
continue
project = path.parents[2]
key = ':'.join(project.relative_to(root).parts) + ':' + path.name
for name in sorted(files):
if name.startswith('TEST-') and name.endswith('.xml'):
yield key, project, path / name
def one_line(text):
return ' '.join(text.split())[:MAX_NAME]
def parse(path):
root = ET.parse(path).getroot()
started = {case: suite.get('timestamp', '') for suite in root.iter('testsuite') for case in suite.findall('testcase')}
cases = []
for case in root.iter('testcase'):
outcome, detail = 'passed', ''
for tag, result in (('skipped', 'skipped'), ('failure', 'failed'), ('error', 'failed')):
node = case.find(tag)
if node is not None:
outcome = result
detail = (node.text or '').strip() or node.get('message', '')
try:
seconds = round(float(case.get('time', 0)), 3)
except ValueError:
seconds = 0.0
cases.append(dict(cls=one_line(case.get('classname', '')), name=one_line(case.get('name', '')),
outcome=outcome, seconds=seconds if 0 < seconds < 1e9 else 0.0,
detail=detail.replace('\r\n', '\n').replace('\r', '\n'), started=started.get(case, '')))
return cases
def count(tests):
return {o: sum(t['outcome'] == o for t in tests) for o in OUTCOMES}
def total_seconds(tests):
return sum(t['seconds'] for t in tests)
class Results:
def __init__(self, root):
self.tests, self.errors = [], []
for key, project, path in find_reports(root):
try:
self.tests += [dict(case, task=key, project=project) for case in parse(path)]
except (ET.ParseError, OSError, LookupError, ValueError) as exc:
self.errors.append(f'{path.relative_to(root)}: {exc}')
# Run order: task, suite timestamp, then file and in-file position (stable sort).
self.tests.sort(key=lambda t: (t['task'], t['started']))
self.ran = [t for t in self.tests if t['outcome'] != 'skipped']
typical = statistics.median(t['seconds'] for t in self.ran) if self.ran else 0
self.cutoff = round(max(SLOW_FLOOR, SLOW_FACTOR * typical), 3)
for t in self.tests:
t['slow'] = t['outcome'] != 'skipped' and t['seconds'] >= self.cutoff
self.slow = [t for t in self.tests if t['slow']]
self.failures = [t for t in self.tests if t['outcome'] == 'failed']
self.by_task = {}
for t in self.tests:
self.by_task.setdefault(t['task'], []).append(t)
self.broken = bool(self.errors or self.failures)
counts = count(self.tests)
parts = [f'{counts[o]} {o}' for o in OUTCOMES if counts[o] or o == 'passed']
if self.errors:
parts.insert(0, f'{len(self.errors)} unreadable')
self.title = ', '.join(parts)
def el(tag, *children, **attrs):
"""Element from strings and elements in order. Untrusted text goes in only as a string child; markup() escapes it."""
node = ET.Element(tag, attrs)
last = None
for child in children:
if not isinstance(child, str):
node.append(child)
last = child
elif last is None:
node.text = (node.text or '') + child
else:
last.tail = (last.tail or '') + child
return node
def markup(node):
# ASCII with numeric references: writing cannot fail on the locale encoding or on unencodable file names.
return ET.tostring(node, encoding='ascii', method='html').decode()
def code(text):
return el('code', ' '.join(text.split()))
def trim(detail):
# A blank line would end the surrounding HTML block in the markdown summary.
lines = [cut for cut in (line[:MAX_LINE].rstrip() for line in detail.splitlines()) if cut]
return '\n'.join(lines[:MAX_LINES] + ['...'] * (len(lines) > MAX_LINES))
def short(test):
return test['cls'].rsplit('.', 1)[-1] + ' > ' + test['name']
def named(test):
return code(test['task'][:MAX_NAME]), ' ', code(short(test))
def unreadable(error):
return 'Unreadable report: ', code(error[:MAX_LINE])
def make_summary(results):
"""Markdown with untrusted text only inside HTML blocks, where GitHub parses no markdown. ASCII, so chars == bytes."""
def row(label, group):
c = count(group)
numbers = [str(len(group)), str(c['passed']), str(c['skipped']),
el('b', str(c['failed'])) if c['failed'] else '0', f'{total_seconds(group):.1f}s']
return markup(el('tr', el('td', label), *(el('td', n, align='right') for n in numbers)))
out = [HEADING.format(results.title), '', '<table>',
markup(el('tr', *(el('th', name) for name in ('Task', 'Tests', 'Passed', 'Skipped', 'Failed', 'Time'))))]
total = [row(el('b', 'Total'), results.tests), '</table>', '']
room = MAX_SUMMARY - sum(len(text) + 1 for text in out + total) - len(OMITTED) - 10
omitted = max(0, len(results.failures) - MAX_FAILURES)
def add(*lines):
nonlocal room, omitted
cost = sum(len(text) + 1 for text in lines)
if cost > room:
omitted += 1
else:
out.extend(lines)
room -= cost
for task, group in results.by_task.items():
add(row(code(task), group))
out += total
for t in results.failures[:MAX_FAILURES]:
add(markup(el('details', el('summary', el('b', 'Failed'), ' ', *named(t)),
'\n', el('pre', trim(t['detail'])), '\n')), '')
for error in results.errors:
add(markup(el('p', *unreadable(error))))
if omitted:
out += ['', OMITTED.format(omitted)]
return '\n'.join(out)
def make_comment(results):
entries = [named(t) for t in results.failures] + [unreadable(error) for error in results.errors]
items = [el('li', *entry) for entry in entries[:MAX_LISTED]]
if len(entries) > MAX_LISTED:
items.append(el('li', f'and {len(entries) - MAX_LISTED} more'))
return HEADING.format(results.title) + ('\n\n' + markup(el('ul', *items)) if items else '')
def source_location(test, root):
"""Best effort (path, line)."""
if not test['cls']:
return None, None
outer = test['cls'].split('$', 1)[0]
parts = [glob.escape(p) for p in outer.split('.')]
found = sorted(test['project'].glob('/'.join(['src', '*', '*', *parts]) + '.*'))
if not found:
return None, None
frame = re.search(re.escape(outer) + r'[\w$.]*\(' + re.escape(found[0].name) + r':(\d+)\)', test['detail'])
return found[0].relative_to(root).as_posix(), frame.group(1) if frame else None
def command_escape(value, prop=False):
value = str(value).replace('%', '%25').replace('\r', '%0D').replace('\n', '%0A')
return value.replace(':', '%3A').replace(',', '%2C') if prop else value
def annotate(results, root):
for t in results.failures[:MAX_LISTED]:
path, line = source_location(t, root)
props = dict(title=short(t), file=path, line=line)
joined = ','.join(f'{k}={command_escape(v, True)}' for k, v in props.items() if v)
message = (t['detail'].strip().splitlines() or ['Test failed'])[0][:MAX_LINE]
print(f'::error {joined}::{command_escape(message)}')
def make_html(results):
def row(t, show_task=False):
cell = el('td', el('b', t['name']), el('small', t['cls'] + (' - ' + t['task'] if show_task else '')))
if t['outcome'] == 'failed':
cell.append(el('details', el('pre', t['detail']), open=''))
elif t['detail']:
cell.append(el('small', t['detail'].splitlines()[0][:MAX_LINE], **{'class': 'skipped'}))
seconds = f'{t["seconds"]:.3f}'
attrs = {'data-o': t['outcome'], 'data-t': seconds}
if t['slow']:
attrs['data-slow'] = ''
return el('tr', el('td', t['outcome'], **{'class': t['outcome']}), cell, el('td', seconds + 's'), **attrs)
def table(tests, show_task=False):
return el('table', el('tbody', *(row(t, show_task) for t in tests)))
meta = ' - '.join(os.environ[k] for k in ('GITHUB_REPOSITORY', 'HEAD_SHA', 'GITHUB_RUN_ID') if os.environ.get(k))
head = [el('p', results.title), el('p', el('small', meta))]
head += [el('p', 'Unreadable report: ' + error, **{'class': 'failed'}) for error in results.errors]
head.append(el('p', f'Slow: {results.cutoff:.3f}s or more ({len(results.slow)} of {len(results.ran)} executed tests, '
f'{total_seconds(results.slow):.1f}s of {total_seconds(results.ran):.1f}s)'))
body = []
if results.failures:
body.append(el('section', el('h2', 'Failed'), table(results.failures, True), id='failed'))
for task, group in results.by_task.items():
rest = [t for t in group if t['outcome'] != 'failed']
if rest:
c = count(rest)
slow = sum(t['slow'] for t in rest)
stats = f' - {c["passed"]} passed, {c["skipped"]} skipped, {total_seconds(rest):.1f}s' + (f', {slow} slow' if slow else '')
body.append(el('details', el('summary', el('b', task), stats), table(rest), **{'class': 'g'}))
parts = {name: '\n'.join(map(markup, nodes)) for name, nodes in (('head', head), ('body', body))}
template = Path(__file__).with_name('test_report.html').read_text()
return re.sub(r'\{\{(head|body)\}\}', lambda m: parts[m.group(1)], template)
def render():
root = Path.cwd()
results = Results(root)
if not (results.tests or results.errors):
print('No test results found')
return
summary = make_summary(results)
out = report_dir()
out.mkdir(parents=True, exist_ok=True)
(out / 'summary.md').write_text(summary)
(out / 'comment.md').write_text(make_comment(results))
(out / 'report.html').write_text(make_html(results))
(out / 'result.json').write_text(json.dumps(dict(title=results.title, broken=results.broken)))
with open(os.environ['GITHUB_OUTPUT'], 'a') as output:
output.write(f'report={out / "report.html"}\n')
annotate(results, root)
print(results.title)
def gh(*args, body=None):
data = None if body is None else json.dumps(body)
done = subprocess.run(['gh', 'api', *args] + (['--input', '-'] if data else []), input=data, text=True,
capture_output=True)
if done.returncode:
raise RuntimeError(f'gh api {" ".join(args)}: {done.stderr.strip()}')
return done.stdout
def publish():
env = os.environ
repo, sha = env['GITHUB_REPOSITORY'], env['HEAD_SHA']
result = json.loads((report_dir() / 'result.json').read_text())
title, broken = result['title'], result['broken']
state = 'failure' if broken else 'success'
run_url = f'{env["GITHUB_SERVER_URL"]}/{repo}/actions/runs/{env["GITHUB_RUN_ID"]}'
links = f'[Run and logs]({run_url})'
if env.get('REPORT_URL'):
links += f' - [View report (all tests)]({env["REPORT_URL"]})'
body, short_body = (f'{links}\n\n' + (report_dir() / name).read_text() for name in ('summary.md', 'comment.md'))
problems = []
def attempt(fn, *args, **kwargs):
try:
fn(*args, **kwargs)
except (RuntimeError, ValueError, KeyError) as exc:
problems.append(exc)
if env['CAN_PUBLISH'] == 'true':
attempt(gh, '-X', 'POST', f'repos/{repo}/statuses/{sha}', body=dict(
state=state, context=STATUS_CONTEXT, description=title, target_url=run_url))
pr = env.get('PR_NUMBER')
if pr and env.get('DISABLE_COMMENT') != 'true':
attempt(comment, repo, pr, sha, broken, f'{MARKER}\n{short_body}')
with open(env['GITHUB_STEP_SUMMARY'], 'a') as step:
step.write(body + '\n')
for problem in problems:
print(f'::warning title=Publishing test results::{command_escape(problem)}')
if problems:
sys.exit(1)
def comment(repo, pr, sha, broken, body):
# A re-run of an old run must not overwrite a newer head's result.
if json.loads(gh(f'repos/{repo}/pulls/{pr}'))['head']['sha'] != sha:
print('PR head has moved on; not commenting')
return
mine = f'.[] | select(.user.login == "github-actions[bot]" and ((.body // "") | startswith("{MARKER}"))) | .id'
existing = gh('--paginate', f'repos/{repo}/issues/{pr}/comments?per_page=100', '--jq', mine).split()
if existing:
gh('-X', 'PATCH', f'repos/{repo}/issues/comments/{existing[0]}', body=dict(body=body))
elif broken:
gh('-X', 'POST', f'repos/{repo}/issues/{pr}/comments', body=dict(body=body))
if __name__ == '__main__':
# Annotations carry test names and paths; printing them must not depend on the locale.
sys.stdout.reconfigure(encoding='utf-8', errors='backslashreplace')
if sys.argv[1:] == ['render']:
render()
elif sys.argv[1:] == ['publish']:
publish()
else:
sys.exit('usage: test_results.py render | publish')
+3
View File
@@ -8,6 +8,7 @@ on:
jobs:
build-and-test:
# Publishing test results (commit status, PR comment) needs statuses and pull-requests write
uses: GTNewHorizons/GTNH-Actions-Workflows/.github/workflows/build-and-test.yml@master
secrets: inherit
# with:
@@ -15,6 +16,8 @@ jobs:
# workspace: setupDecompWorkspace
# client-only: false
# disable-server-auto-stop: false
# disable-spotless-pr: false
# disable-test-comment: false
# horizonqa: true
# horizonqa-tests: ''
# horizonqa-allow-no-tests: false