mirror of
https://github.com/GTNewHorizons/GTNH-Actions-Workflows.git
synced 2026-10-16 12:27:28 +02:00
Show test results on PRs (#73)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user