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
|
required: false
|
||||||
default: false
|
default: false
|
||||||
type: boolean
|
type: boolean
|
||||||
|
disable-test-comment:
|
||||||
|
description: 'Do not create or update the PR test-results comment'
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
horizonqa:
|
horizonqa:
|
||||||
description: 'Run Horizon-QA in CI mode during runServer'
|
description: 'Run Horizon-QA in CI mode during runServer'
|
||||||
required: false
|
required: false
|
||||||
@@ -54,6 +59,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
ARTIFACT_RETENTION_DAYS: ${{ github.event.repository.private && 5 || 30 }}
|
ARTIFACT_RETENTION_DAYS: ${{ github.event.repository.private && 5 || 30 }}
|
||||||
|
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
@@ -153,13 +159,43 @@ jobs:
|
|||||||
id: build_mod
|
id: build_mod
|
||||||
run: xvfb-run --server-args="-screen 0 1366x768x24" ./gradlew --build-cache --info --stacktrace build
|
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
|
- name: Attach gradle reports
|
||||||
if: failure() && steps.build_mod.conclusion == 'failure'
|
if: failure() && steps.build_mod.conclusion == 'failure'
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
name: ${{ github.repository_id }}-reports
|
name: ${{ github.repository_id }}-reports
|
||||||
path: build/reports/
|
path: |
|
||||||
|
**/build/reports/
|
||||||
|
**/build/test-results/**/*.xml
|
||||||
retention-days: ${{ env.ARTIFACT_RETENTION_DAYS }}
|
retention-days: ${{ env.ARTIFACT_RETENTION_DAYS }}
|
||||||
|
|
||||||
- name: Attempt to make a PR fixing spotless errors
|
- name: Attempt to make a PR fixing spotless errors
|
||||||
@@ -169,27 +205,14 @@ jobs:
|
|||||||
github.event_name == 'pull_request' &&
|
github.event_name == 'pull_request' &&
|
||||||
github.event.pull_request.head.repo.full_name == github.repository }}
|
github.event.pull_request.head.repo.full_name == github.repository }}
|
||||||
run: |
|
run: |
|
||||||
git reset --hard
|
chmod +x .gtnh-workflows/scripts/spotless_fix_pr
|
||||||
git checkout "${PR_BRANCH}"
|
.gtnh-workflows/scripts/spotless_fix_pr
|
||||||
./gradlew --build-cache --info --stacktrace spotlessApply || exit 1
|
shell: bash
|
||||||
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
|
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
PR_BRANCH: ${{ github.head_ref }}
|
PR_BRANCH: ${{ github.head_ref }}
|
||||||
FIXED_BRANCH: ${{ github.head_ref }}-spotless-fixes
|
FIXED_BRANCH: ${{ github.head_ref }}-spotless-fixes
|
||||||
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
|
||||||
- name: Run server for up to ${{ inputs.timeout }} seconds
|
- name: Run server for up to ${{ inputs.timeout }} seconds
|
||||||
id: run_server
|
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:
|
jobs:
|
||||||
build-and-test:
|
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
|
uses: GTNewHorizons/GTNH-Actions-Workflows/.github/workflows/build-and-test.yml@master
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
# with:
|
# with:
|
||||||
@@ -15,6 +16,8 @@ jobs:
|
|||||||
# workspace: setupDecompWorkspace
|
# workspace: setupDecompWorkspace
|
||||||
# client-only: false
|
# client-only: false
|
||||||
# disable-server-auto-stop: false
|
# disable-server-auto-stop: false
|
||||||
|
# disable-spotless-pr: false
|
||||||
|
# disable-test-comment: false
|
||||||
# horizonqa: true
|
# horizonqa: true
|
||||||
# horizonqa-tests: ''
|
# horizonqa-tests: ''
|
||||||
# horizonqa-allow-no-tests: false
|
# horizonqa-allow-no-tests: false
|
||||||
|
|||||||
Reference in New Issue
Block a user