pass extra metadata (repo refs, timestamps) to test results

This commit is contained in:
Ivan Avdeev
2026-04-09 15:24:54 -04:00
parent c6edc9f3fe
commit 75fa9cae18
5 changed files with 127 additions and 47 deletions
+6 -1
View File
@@ -20,7 +20,12 @@ build() {
rendertest() { rendertest() {
pushd /build/HLRTest/render pushd /build/HLRTest/render
WAYLAND_DISPLAY=/tmp/wayland-headless ./rendertest.py --xash-dir /opt/hl run WAYLAND_DISPLAY=/tmp/wayland-headless ./rendertest.py \
--xash-dir /opt/hl \
--xash-revision "${XASH3D_REVISION}" \
--pbr-revision "${PBR_REVISION}" \
--tests-revision "${HLRTEST_REVISION}" \
run
popd popd
} }
+38 -5
View File
@@ -3,7 +3,7 @@ set -eux
NAME=xash-builder NAME=xash-builder
HLRTEST_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")"/.. && pwd)" HLRTEST_REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")"/.. && pwd)"
source .env source .env
@@ -20,10 +20,37 @@ build() {
build-image build-image
} }
git_short() {
local dir="${1:-.}"
local hash dirty_flag
# Get short commit hash
hash=$(cd "$dir" && git rev-parse --short HEAD 2>/dev/null)
if [[ -z "$hash" ]]; then
echo "Not a git repository" >&2
return 1
fi
# Check for uncommitted changes (staged or unstaged)
dirty_flag=$(cd "$dir" && git status --porcelain --untracked-files=no 2>/dev/null)
if [[ -n "$dirty_flag" ]]; then
echo "${hash}-dirty"
else
echo "$hash"
fi
}
render-test() { render-test() {
OUTPUT="${HLRTEST_PATH}/render/work" local OUTPUT="${HLRTEST_REPO_DIR}/render/work"
# Make sure the image ubuntu user can write to the output
chmod -R o+rw "${OUTPUT}" local HLRTEST_REVISION=$(git_short "${HLRTEST_REPO_DIR}")
local XASH3D_REVISION=$(git_short "${XASH3D_RT_REPO_DIR}")
local PBR_REVISION=$(git_short "${HALFLIFE_PBR_REPO_DIR}")
# Make sure the image's ubuntu (uid=1000) user can write to the output
#chmod -R o+rw "${OUTPUT}"
podman run -it --rm \ podman run -it --rm \
--name ${NAME} \ --name ${NAME} \
@@ -34,14 +61,20 @@ render-test() {
--read-only \ --read-only \
--tmpfs /tmp \ --tmpfs /tmp \
\ \
-e HLRTEST_REVISION="${HLRTEST_REVISION}" \
-e XASH3D_REVISION="${XASH3D_REVISION}" \
-e PBR_REVISION="${PBR_REVISION}" \
\
-v /dev/dri/renderD128:/dev/dri/renderD128:ro \ -v /dev/dri/renderD128:/dev/dri/renderD128:ro \
\ \
-v /opt/hl \ -v /opt/hl \
-v /home/ubuntu \
-v ${HALFLIFE_PBR_REPO_DIR}/valve/pbr:/opt/hl/valve/pbr:ro \ -v ${HALFLIFE_PBR_REPO_DIR}/valve/pbr:/opt/hl/valve/pbr:ro \
-v ${HALFLIFE_PBR_REPO_DIR}/valve/bluenoise:/opt/hl/valve/bluenoise:ro \
\ \
-v ${XASH3D_RT_REPO_DIR}:/build/xash3d-fwgs:O \ -v ${XASH3D_RT_REPO_DIR}:/build/xash3d-fwgs:O \
\ \
-v ${HLRTEST_PATH}:/build/HLRTest:ro \ -v ${HLRTEST_REPO_DIR}:/build/HLRTest:ro \
-v ${OUTPUT}:/build/HLRTest/render/work \ -v ${OUTPUT}:/build/HLRTest/render/work \
\ \
ubuntu-xash-builder:latest \ ubuntu-xash-builder:latest \
+40 -32
View File
@@ -114,7 +114,7 @@ function isPassed(value) {
]; ];
} }
function makeTable(fields, data, attrs_func) { function makeTable(fields, tests, attrs_func) {
const table = Tag('table', null, null, [ const table = Tag('table', null, null, [
Tag('tr', null, null, (() => { Tag('tr', null, null, (() => {
let tds = []; let tds = [];
@@ -126,8 +126,8 @@ function makeTable(fields, data, attrs_func) {
]); ]);
for (const di in data) { for (const di in tests) {
let d = data[di]; let d = tests[di];
const attrs = attrs_func ? attrs_func(d) : null; const attrs = attrs_func ? attrs_func(d) : null;
table.appendChild(Tag('tr', attrs, null, (() => { table.appendChild(Tag('tr', attrs, null, (() => {
let ret = []; let ret = [];
@@ -159,7 +159,7 @@ function makeTable(fields, data, attrs_func) {
return table; return table;
} }
function buildTestResultsTable(data) { function buildTestResultsTable(tests) {
const fields = [ const fields = [
{label: 'Test', field: 'test', tags_func: sectionLink}, {label: 'Test', field: 'test', tags_func: sectionLink},
{label: 'Channel', field: 'channel', tags_func: channelWithEmoji}, {label: 'Channel', field: 'channel', tags_func: channelWithEmoji},
@@ -167,27 +167,37 @@ function buildTestResultsTable(data) {
{label: 'Passed', field: 'fail', tags_func: isPassed}, {label: 'Passed', field: 'fail', tags_func: isPassed},
]; ];
return [makeTable(fields, data, rowAttribs)]; return [makeTable(fields, tests, rowAttribs)];
} }
// Filter out all success // Filter out all success
function filterData(data) { function filterTests(tests) {
return data return tests
.filter((d) => { return d.diff_pct !== 0; }) .filter((t) => { return t.diff_pct !== 0; })
.sort((l, r) => { .sort((l, r) => {
return l.diff_pct < r.diff_pct ? 1 : -1; // RTFM return l.diff_pct < r.diff_pct ? 1 : -1; // RTFM
}); });
} }
function buildSummary(data) { function buildSummary(test_results) {
const total = data.length; const tests = test_results.tests
const fails = data.filter((d) => { return !!d.fail; }).length; const total = tests.length;
const fails = tests.filter((d) => { return !!d.fail; }).length;
const fails_pct = fails * 100.0 / total; const fails_pct = fails * 100.0 / total;
return emojiToTag(`🧪 Tests 💥: ${fails}/${total}🏁 (⚠${fails_pct.toFixed(3)}%)`); const meta = test_results.metadata;
return [
Tag('ul', null, null, [
Tag('li', null, `Test date: ${meta.time}`),
// TODO add links to repos at specified revisions
Tag('li', null, `Xash3D-FWGS revision: ${meta.revisions.xash3d}`),
Tag('li', null, `Half-Life-PBR revision: ${meta.revisions.pbr}`),
Tag('li', null, `HLRTest revision: ${meta.revisions.tests}`),
Tag('li', null, `Rendering took: ${meta.phase_render_time_sec.toFixed(1)} seconds`)
])].concat(emojiToTag(`🧪 Tests 💥: ${fails}/${total}🏁 (⚠${fails_pct.toFixed(3)}%)`));
} }
function buildTestResultImages(data) { function buildTestResultImages(tests) {
return data.flatMap((d) => { return tests.flatMap((d) => {
//return Tag('details', {id: makeId(d)}, null, [ //return Tag('details', {id: makeId(d)}, null, [
return Tag('div', {id: makeId(d), "class": "meta-block"}, null, [ return Tag('div', {id: makeId(d), "class": "meta-block"}, null, [
//Tag('summary', null, `${d.test}/${d.channel} δ=${d.diff_pct}`), //Tag('summary', null, `${d.test}/${d.channel} δ=${d.diff_pct}`),
@@ -207,10 +217,8 @@ function buildTestResultImages(data) {
function buildData(table, images, data, sort, filter, exact_match) { function buildData(table, images, test_results, sort, filter, exact_match) {
if (sort) { let tests = sort ? filterTests(test_results.tests) : test_results.tests
data = filterData(data);
}
if (filter) { if (filter) {
let test = "", channel = ""; let test = "", channel = "";
const args = filter.split(" "); const args = filter.split(" ");
@@ -221,7 +229,7 @@ function buildData(table, images, data, sort, filter, exact_match) {
test = filter; test = filter;
channel = filter; channel = filter;
} }
data = data.filter((d) => { tests = tests.filter((d) => {
const test_result = exact_match ? d.test === test : d.test.includes(test); const test_result = exact_match ? d.test === test : d.test.includes(test);
const channel_result = exact_match ? d.channel === channel : d.channel.includes(channel); const channel_result = exact_match ? d.channel === channel : d.channel.includes(channel);
if (args.length > 1) { if (args.length > 1) {
@@ -232,8 +240,8 @@ function buildData(table, images, data, sort, filter, exact_match) {
}); });
} }
table.replaceChildren(...buildTestResultsTable(data)); table.replaceChildren(...buildTestResultsTable(tests));
images.replaceChildren(...buildTestResultImages(data)); images.replaceChildren(...buildTestResultImages(tests));
for (let block of images.querySelectorAll(".image-container")) { for (let block of images.querySelectorAll(".image-container")) {
block.addEventListener("click", function() { block.addEventListener("click", function() {
@@ -255,8 +263,8 @@ function buildData(table, images, data, sort, filter, exact_match) {
saveToLocalStorage("rendertest_tablesort", sort); saveToLocalStorage("rendertest_tablesort", sort);
} }
const buildDataSlowMode = debounce((table, images, data, sort, filter, exact_match) => { const buildDataSlowMode = debounce((table, images, test_results, sort, filter, exact_match) => {
buildData(table, images, data, sort, filter, exact_match); buildData(table, images, test_results, sort, filter, exact_match);
}, ()=>{}, 250); }, ()=>{}, 250);
@@ -550,7 +558,7 @@ window.onload = () => {
]), ]),
Tag("label", null, null, [ Tag("label", null, null, [
Tag("input", {"type": "radio", "name": "sort_table", "value": "yes", ...uglyChecked(tableSort)}, null, null, "input", (e) => { Tag("input", {"type": "radio", "name": "sort_table", "value": "yes", ...uglyChecked(tableSort)}, null, null, "input", (e) => {
buildData(table, images, data, true, filter.value, exactmatch_input.checked); buildData(table, images, TEST_RESULTS, true, filter.value, exactmatch_input.checked);
// TODO: rewrite use reactive programming // TODO: rewrite use reactive programming
tableSort = true; tableSort = true;
}), }),
@@ -558,7 +566,7 @@ window.onload = () => {
]), ]),
Tag("label", null, null, [ Tag("label", null, null, [
Tag("input", {"type": "radio", "name": "sort_table", "value": "no", ...uglyChecked(!tableSort)}, null, null, "input", (e) => { Tag("input", {"type": "radio", "name": "sort_table", "value": "no", ...uglyChecked(!tableSort)}, null, null, "input", (e) => {
buildData(table, images, data, false, filter.value, exactmatch_input.checked); buildData(table, images, TEST_RESULTS, false, filter.value, exactmatch_input.checked);
tableSort = false; tableSort = false;
}), }),
Text(" no") Text(" no")
@@ -570,12 +578,12 @@ window.onload = () => {
Tag("label", {"class": "filter sticky"}, "Filter", [ Tag("label", {"class": "filter sticky"}, "Filter", [
filter = Tag("input", {"type": "input", "title": "Hotkey: ALT+F", "name": "filter", "value": filter_value}, null, null, "input", (e) => { filter = Tag("input", {"type": "input", "title": "Hotkey: ALT+F", "name": "filter", "value": filter_value}, null, null, "input", (e) => {
saveToLocalStorage("rendertest_filter", e.target.value); saveToLocalStorage("rendertest_filter", e.target.value);
buildDataSlowMode(table, images, data, tableSort, e.target.value, exactmatch_input.checked); buildDataSlowMode(table, images, TEST_RESULTS, tableSort, e.target.value, exactmatch_input.checked);
}), }),
Tag("label", null, null, [ Tag("label", null, null, [
exactmatch_input = Tag("input", {"type": "checkbox", "name": "exactmatch", ...uglyChecked(exactMatch)}, null, null, "change", (e) => { exactmatch_input = Tag("input", {"type": "checkbox", "name": "exactmatch", ...uglyChecked(exactMatch)}, null, null, "change", (e) => {
saveToLocalStorage("rendertest_exactmatch", e.target.checked); saveToLocalStorage("rendertest_exactmatch", e.target.checked);
buildDataSlowMode(table, images, data, tableSort, filter.value, exactmatch_input.checked); buildDataSlowMode(table, images, TEST_RESULTS, tableSort, filter.value, exactmatch_input.checked);
}), }),
Text("exact match") Text("exact match")
]) ])
@@ -586,7 +594,7 @@ window.onload = () => {
Tag("div", {"class": "content"}, null, [ Tag("div", {"class": "content"}, null, [
Tag("h1", null, "Rendertest report"), Tag("h1", null, "Rendertest report"),
Tag("h2", null, "Summary"), Tag("h2", null, "Summary"),
Tag("div", {"id": "summary"}, null, [...buildSummary(data)]), Tag("div", {"id": "summary"}, null, buildSummary(TEST_RESULTS)),
Tag("h2", null, "Images of things that are not perfect ", [ Tag("h2", null, "Images of things that are not perfect ", [
Tag("span", {"class": "emoji"}, "⤵") Tag("span", {"class": "emoji"}, "⤵")
]), ]),
@@ -595,7 +603,7 @@ window.onload = () => {
]) ])
document.body.appendChild(gridContainer); document.body.appendChild(gridContainer);
buildData(table, images, data, tableSort, filter_value, exactmatch_input.checked); buildData(table, images, TEST_RESULTS, tableSort, filter_value, exactmatch_input.checked);
// TODO: remove this // TODO: remove this
if (sidebarPos === "right") { if (sidebarPos === "right") {
+42 -8
View File
@@ -2,12 +2,14 @@
import argparse import argparse
import concurrent.futures import concurrent.futures
from datetime import datetime
import json import json
import os import os
import pathlib import pathlib
import re import re
import shutil import shutil
import subprocess import subprocess
import time
ROOT = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(os.path.abspath(__file__))
imagecompare = f'{ROOT}/imagecompare' imagecompare = f'{ROOT}/imagecompare'
@@ -15,11 +17,11 @@ WORKDIR = f'{ROOT}/work'
REPORT_ROOT = f'{ROOT}' # FIXME should be workdir? REPORT_ROOT = f'{ROOT}' # FIXME should be workdir?
def get_imagemagick_convert_cmd() -> str: def get_imagemagick_convert_cmd() -> str:
if shutil.which("magick"): if shutil.which("magick"):
return "magick" return "magick"
if shutil.which("convert"): if shutil.which("convert"):
return "convert" return "convert"
raise RuntimeError("No ImageMagick binary found (tried: magick, convert)") raise RuntimeError("No ImageMagick binary found (tried: magick, convert)")
convert = get_imagemagick_convert_cmd() convert = get_imagemagick_convert_cmd()
# TODO rename to <map>_<description>_<issue(opt)> # TODO rename to <map>_<description>_<issue(opt)>
@@ -66,10 +68,28 @@ parser.add_argument('--tests', '-t', type=test_list, default=saves, help='Run on
# TODO how to check that the dir is valid? presence of xash3d executable and valve dir? # TODO how to check that the dir is valid? presence of xash3d executable and valve dir?
parser.add_argument('--xash-dir', '-x', type=str, default=os.getcwd(), help='Path to xash3d-fwgs installation directory') parser.add_argument('--xash-dir', '-x', type=str, default=os.getcwd(), help='Path to xash3d-fwgs installation directory')
parser.add_argument('--xash-revision', type=str, default='unknown', help='xash engine repo revision, commit_id<-dirty>')
parser.add_argument('--pbr-revision', type=str, default='unknown', help='PBR textures repo revision, commit_id<-dirty>')
parser.add_argument('--tests-revision', type=str, default='unknown', help='HLRTest (this) repo revision, commit_id<-dirty>')
# TODO parse commands in type=.. function # TODO parse commands in type=.. function
parser.add_argument('command', type=str, default=None, help='Action to perform') parser.add_argument('command', type=str, default=None, help='Action to perform')
args = parser.parse_args() args = parser.parse_args()
metadata = {}
metadata['time'] = datetime.now().astimezone().isoformat()
metadata['revisions'] = {
'xash3d': args.xash_revision,
'pbr': args.pbr_revision,
'tests': args.tests_revision,
}
# TODO:
# - OS/kernel version
# - GPU model
# - Vulkan version
# - GPU driver version
def make_script(file, tests: [str]): def make_script(file, tests: [str]):
header = '''sv_cheats 1 header = '''sv_cheats 1
developer 0 developer 0
@@ -132,6 +152,7 @@ def render():
env = os.environ.copy() env = os.environ.copy()
env['LD_LIBRARY_PATH'] = f'{args.xash_dir}' env['LD_LIBRARY_PATH'] = f'{args.xash_dir}'
start_time_sec = time.perf_counter()
with open(f'{WORKDIR}/xash-stdout.log', 'wb') as stdout, open(f'{WORKDIR}/xash-stderr.log', 'wb') as stderr: with open(f'{WORKDIR}/xash-stdout.log', 'wb') as stdout, open(f'{WORKDIR}/xash-stderr.log', 'wb') as stderr:
result = subprocess.run([f'{args.xash_dir}/xash3d', '-ref', 'vk', result = subprocess.run([f'{args.xash_dir}/xash3d', '-ref', 'vk',
'-nowriteconfig', '-nosound', '-log', '-nowriteconfig', '-nosound', '-log',
@@ -139,6 +160,8 @@ def render():
'-width', '1280', '-height', '800', '-width', '1280', '-height', '800',
'+exec', 'rendertest.script'], '+exec', 'rendertest.script'],
env=env, check=True, stdout=stdout, stderr=stderr) env=env, check=True, stdout=stdout, stderr=stderr)
end_time_sec = time.perf_counter()
metadata['phase_render_time_sec'] = end_time_sec - start_time_sec
def compare_one(test: str, channel: str, image_base: str, image_gold: str, image_test: str, image_diff: str): def compare_one(test: str, channel: str, image_base: str, image_gold: str, image_test: str, image_diff: str):
result = subprocess.run([imagecompare, image_gold, image_test, image_diff], text=True, capture_output=True) result = subprocess.run([imagecompare, image_gold, image_test, image_diff], text=True, capture_output=True)
@@ -165,6 +188,7 @@ def compare():
diffs = [] diffs = []
command_png() command_png()
print(f'Comparing...') print(f'Comparing...')
start_time_sec = time.perf_counter()
with concurrent.futures.ThreadPoolExecutor() as executor: with concurrent.futures.ThreadPoolExecutor() as executor:
for test in args.tests: for test in args.tests:
for channel, _ in channels.items(): for channel, _ in channels.items():
@@ -175,11 +199,19 @@ def compare():
diffs.append(executor.submit(compare_one, test, channel, image_base, image_gold, image_test, image_diff)) diffs.append(executor.submit(compare_one, test, channel, image_base, image_gold, image_test, image_diff))
results = [diff.result() for diff in diffs] tests = [diff.result() for diff in diffs]
end_time_sec = time.perf_counter()
metadata['phase_compare_time_sec'] = end_time_sec - start_time_sec
results = {
'metadata': metadata,
'tests': tests,
}
json.dump(results, open(f'{WORKDIR}/test_results.json', 'w')) json.dump(results, open(f'{WORKDIR}/test_results.json', 'w'))
jsons = json.dumps(results) jsons = json.dumps(results)
with open(f'{WORKDIR}/data.js', 'w') as js: with open(f'{WORKDIR}/results.js', 'w') as js:
js.write(f'"use strict";\nconst data = {jsons};\n') js.write(f'"use strict";\nconst TEST_RESULTS = {jsons};\n')
def command_compare(): def command_compare():
compile() compile()
@@ -215,6 +247,8 @@ def command_render():
copy_assets() copy_assets()
render() render()
# TODO make one long sequence of functions, and then call enabled ones, passing state via files
# TODO dict # TODO dict
match args.command: match args.command:
case 'run': case 'run':
+1 -1
View File
@@ -8,7 +8,7 @@
<link rel="stylesheet alternate" type="text/css" href="theme-light.css" disabled="true" data-theme="theme-light" title="☀️ Light theme"> <link rel="stylesheet alternate" type="text/css" href="theme-light.css" disabled="true" data-theme="theme-light" title="☀️ Light theme">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<script src="utils.js"></script> <script src="utils.js"></script>
<script src="work/data.js"></script> <script src="work/results.js"></script>
<script src="index.js"></script> <script src="index.js"></script>
</head> </head>
<body> <body>