import contextlib
import io
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import time
import unittest
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import vulkan_validation_gate as gate
class ReportTests(unittest.TestCase):
def report(self, text):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "junit.xml"
path.write_text(text, encoding="utf-8")
return gate.parse_report(path, {"probe"})
def test_test_body_messages_are_distinct_from_configuration_echoes(self):
text = ''
text += 'environment: VK_INSTANCE_LAYERS=VUID-Fake-1\n'
text += 'Validation Error: [ VUID-Real-1 ] message\n'
text += 'Validation Warning: [ SYNC-HAZARD-WRITE_AFTER_WRITE ] message\n'
text += 'VUID-Old-2 (ERROR / SPEC): msgNum: 1\n'
text += 'WARNING: [Loader Message] layer was not found but was requested by env var VK_INSTANCE_LAYERS'
tests, problems = self.report(text)
self.assertEqual(tests["probe"]["diagnostics"], {"VUID-Real-1": 1, "SYNC-HAZARD-WRITE_AFTER_WRITE": 1, "VUID-Old-2": 1})
self.assertEqual(len(tests["probe"]["missing_layers"]), 1)
self.assertEqual(problems, ["probe"])
def test_skip_disabled_failure_and_missing_tests_are_not_successes(self):
for body in ('',
'',
'', ''):
with self.subTest(body=body):
self.assertTrue(self.report('' + body + '')[1])
def test_invalid_or_duplicate_test_names_are_setup_errors(self):
for body in ('', ''):
with self.subTest(body=body), self.assertRaises(gate.GateError):
self.report('' + body + '')
def test_new_ids_are_compared_per_test_and_counts_are_retained(self):
def iteration(side, test, messages):
return {"side": side, "iteration": 1, "returncode": 0, "problems": [],
"tests": {test: {"diagnostics": messages}}}
report = gate.compare([iteration("baseline", "probe", {"VUID-A-1": 1}),
iteration("candidate", "probe", {"VUID-A-1": 3})])
self.assertEqual(report["exit_code"], 0)
self.assertEqual(report["counts"]["candidate"]["probe"]["VUID-A-1"], 3)
report = gate.compare([iteration("baseline", "other", {"VUID-A-1": 1}),
iteration("candidate", "probe", {"VUID-A-1": 1})])
self.assertEqual(report["new_diagnostics"], {"probe": ["VUID-A-1"]})
self.assertEqual(report["exit_code"], 1)
def test_windows_is_refused_before_output_is_created(self):
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "not-created"
arguments = ["--baseline", directory, "a" * 40, "--candidate", directory, "b" * 40,
"--output", str(output), "--target", "fixture", "--tests", "probe", "--gpu", "fixture"]
with mock.patch.object(gate.os, "name", "nt"), contextlib.redirect_stderr(io.StringIO()):
with self.assertRaises(SystemExit) as error:
gate.main(arguments)
self.assertEqual(error.exception.code, 2)
self.assertFalse(output.exists())
def test_relative_command_files_follow_the_test_working_directory(self):
for mode in ("default", "absolute", "relative"):
for definition in (False, True):
with self.subTest(mode=mode, definition=definition), tempfile.TemporaryDirectory() as directory:
build = Path(directory).resolve()
working = build if mode == "default" else build / "working directory"
working.mkdir(exist_ok=True)
script = working / "probe.py"
script.write_text("original", encoding="utf-8")
argument = "-DINPUT=probe.py" if definition else "probe.py"
properties = [] if mode == "default" else [{
"name": "WORKING_DIRECTORY",
"value": str(working if mode == "absolute" else working.relative_to(build)),
}]
tests = [{"name": "probe", "command": [sys.executable, argument], "properties": properties}]
before = gate.artifact_hashes(build, tests, [])
self.assertIn(str(script.resolve()), before)
script.write_text("changed", encoding="utf-8")
self.assertNotEqual(before, gate.artifact_hashes(build, tests, []))
@unittest.skipUnless(os.name == "posix" and shutil.which("cmake") and shutil.which("ninja") and shutil.which("ctest"), "requires POSIX, CMake, Ninja and CTest")
class FixtureTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.root = Path(self.directory.name).resolve()
def source(self, name, message="pass", test="probe", property_text=""):
source = self.root / name
source.mkdir()
program = 'import os, sys\nfrom pathlib import Path\n'
program += 'assert os.environ["ANYPS5_REQUIRE_VULKAN"] == "1"\n'
program += 'assert os.environ["ANYPS5_NO_SHADER_CACHE"] == "1"\n'
program += 'assert os.environ["VK_INSTANCE_LAYERS"] == "VK_LAYER_KHRONOS_validation"\n'
program += 'assert os.environ["ANYPS5_GPU"] == "fixture"\n'
if message == "skip":
program += 'print("skipped, synthetic unavailable device")\nsys.exit(77)\n'
elif message == "fail":
program += 'sys.exit(7)\n'
elif message == "mutate":
program += 'with Path(__file__).open("a") as output: output.write("\\n")\n'
elif message == "large":
program += 'print("Validation Error: [ VUID-Early-00001 ] synthetic")\n'
program += 'print("x" * 131072)\n'
program += 'print("Validation Error: [ VUID-Late-00002 ] synthetic")\n'
else:
program += 'print(' + repr(message) + ')\n'
(source / "probe.py").write_text(program, encoding="utf-8")
(source / "CMakeLists.txt").write_text(
'cmake_minimum_required(VERSION 3.20)\nproject(gate_fixture NONE)\nenable_testing()\n'
'add_custom_command(OUTPUT "${CMAKE_BINARY_DIR}/probe.py" COMMAND "${CMAKE_COMMAND}" -E copy "${CMAKE_SOURCE_DIR}/probe.py" "${CMAKE_BINARY_DIR}/probe.py" DEPENDS "${CMAKE_SOURCE_DIR}/probe.py")\n'
'add_custom_target(fixture DEPENDS "${CMAKE_BINARY_DIR}/probe.py")\n'
'add_test(NAME ' + test + ' COMMAND "' + sys.executable + '" "${CMAKE_BINARY_DIR}/probe.py")\n'
'set_tests_properties(' + test + ' PROPERTIES SKIP_RETURN_CODE 77 ' + property_text + ')\n', encoding="utf-8")
subprocess.run(["git", "init", "-q", str(source)], check=True)
subprocess.run(["git", "-C", str(source), "add", "."], check=True)
subprocess.run(["git", "-C", str(source), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid",
"-c", "commit.gpgSign=false", "commit", "-qm", "fixture"], check=True)
return source, gate.git(source, "rev-parse", "HEAD")
def execute(self, baseline, candidate, *extra):
output = self.root / "evidence"
arguments = ["--baseline", str(baseline[0]), baseline[1], "--candidate", str(candidate[0]), candidate[1],
"--output", str(output), "--target", "fixture", "--tests", ".*", "--gpu", "fixture", *extra]
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
code = gate.main(arguments)
report = json.loads((output / "report.json").read_text()) if (output / "report.json").is_file() else None
return code, report
class IntegrationTests(FixtureTests):
def test_pass_builds_both_targets_and_alternates_with_attribution(self):
code, report = self.execute(self.source("base"), self.source("head"))
self.assertEqual(code, 0, report)
self.assertEqual([entry["side"] for entry in report["iterations"]], ["baseline", "candidate", "candidate", "baseline"])
self.assertEqual(report["status"], "complete")
for side in ("baseline", "candidate"):
files = report["builds"][side]["artifact_sha256"]
self.assertTrue(any(path.endswith("probe.py") for path in files))
self.assertTrue((self.root / "evidence" / side / "iteration-1.xml").is_file())
def test_ctest_success_with_validation_errors_is_a_gate_failure(self):
code, report = self.execute(self.source("base"), self.source("head", "Validation Error: [ VUID-Fixture-00001 ] synthetic"), "--repeat", "1")
self.assertEqual(code, 1, report)
self.assertTrue(all(entry["returncode"] == 0 for entry in report["iterations"]))
self.assertEqual(report["new_diagnostics"], {"probe": ["VUID-Fixture-00001"]})
def test_ctest_success_with_missing_layer_is_a_gate_failure(self):
message = "WARNING: [Loader Message] layer was not found but was requested by env var VK_INSTANCE_LAYERS"
code, report = self.execute(self.source("base", message), self.source("head"), "--repeat", "1")
self.assertEqual(code, 1, report)
self.assertTrue(all(entry["returncode"] == 0 for entry in report["iterations"]))
self.assertTrue(report["problems"])
def test_missing_layer_without_severity_is_a_gate_failure(self):
message = "Layer VK_LAYER_KHRONOS_validation was not found but was requested by env var VK_INSTANCE_LAYERS"
code, report = self.execute(self.source("base"), self.source("head", message), "--repeat", "1")
self.assertEqual(code, 1, report)
def test_large_passed_output_retains_early_and_late_validation_messages(self):
code, report = self.execute(self.source("base"), self.source("head", "large"), "--repeat", "1")
self.assertEqual(code, 1, report)
self.assertEqual(report["new_diagnostics"], {"probe": ["VUID-Early-00001", "VUID-Late-00002"]})
def test_configuration_like_text_does_not_create_a_validation_error(self):
text = "VUID-Fixture-00001 was not found but was requested by env var VK_INSTANCE_LAYERS"
code, report = self.execute(self.source("base"), self.source("head", property_text='ENVIRONMENT "GATE_CONFIGURATION_ECHO=' + text + '"'), "--repeat", "1")
self.assertEqual(code, 0, report)
self.assertIn(text, (self.root / "evidence/candidate/iteration-1.log").read_text())
def test_a_skipped_selected_test_is_not_accepted(self):
code, report = self.execute(self.source("base"), self.source("head", "skip"), "--repeat", "1")
self.assertEqual(code, 1, report)
self.assertTrue(report["problems"])
class AttributionTests(FixtureTests):
def test_relative_script_mutation_invalidates_successful_ctest_results(self):
baseline, candidate = self.source("base"), self.source("head", "mutate")
cmake = candidate[0] / "CMakeLists.txt"
text = cmake.read_text(encoding="utf-8")
absolute = 'add_test(NAME probe COMMAND "' + sys.executable + '" "${CMAKE_BINARY_DIR}/probe.py")'
self.assertIn(absolute, text)
cmake.write_text(text.replace(absolute, 'add_test(NAME probe COMMAND "' + sys.executable + '" probe.py)'), encoding="utf-8")
subprocess.run(["git", "-C", str(candidate[0]), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid",
"-c", "commit.gpgSign=false", "commit", "-qam", "relative fixture"], check=True)
candidate = candidate[0], gate.git(candidate[0], "rev-parse", "HEAD")
code, report = self.execute(baseline, candidate, "--repeat", "1")
self.assertTrue(all(iteration["returncode"] == 0 for iteration in report["iterations"]), report)
self.assertEqual(report["builds"]["candidate"]["tests"][0]["command"][1], "probe.py")
self.assertNotEqual((self.root / "evidence/candidate/build/probe.py").read_bytes(), (candidate[0] / "probe.py").read_bytes())
self.assertEqual(code, 2, report)
self.assertIn("artifacts changed", report["error"])
def test_different_selected_names_stop_before_iterations(self):
code, report = self.execute(self.source("base"), self.source("head", test="other"))
self.assertEqual(code, 2, report)
self.assertEqual(report["iterations"], [])
self.assertIn("test-name sets differ", report["error"])
def test_changed_artifact_invalidates_completed_test_results(self):
code, report = self.execute(self.source("base"), self.source("head", "mutate"), "--repeat", "1")
self.assertEqual(code, 2, report)
self.assertIn("artifacts changed", report["error"])
def test_test_properties_cannot_disable_required_validation(self):
code, report = self.execute(self.source("base"), self.source("head", property_text='ENVIRONMENT "ANYPS5_REQUIRE_VULKAN=0"'))
self.assertEqual(code, 2, report)
self.assertIn("overrides required environment", report["error"])
def test_test_properties_cannot_override_driver_selection(self):
code, report = self.execute(self.source("base"), self.source("head", property_text='ENVIRONMENT "VK_DRIVER_FILES=/different/driver.json"'))
self.assertEqual(code, 2, report)
self.assertIn("overrides required environment", report["error"])
def test_dirty_source_is_rejected_before_output_creation(self):
baseline, candidate = self.source("base"), self.source("head")
(candidate[0] / "untracked.txt").write_text("dirty")
code, report = self.execute(baseline, candidate)
self.assertEqual(code, 2)
self.assertIsNone(report)
def test_wrong_expected_commit_is_rejected_before_output_creation(self):
baseline, candidate = self.source("base"), self.source("head")
code, report = self.execute(baseline, (candidate[0], "0" * 40))
self.assertEqual(code, 2)
self.assertIsNone(report)
def test_existing_output_is_preserved(self):
output = self.root / "evidence"
output.mkdir()
marker = output / "keep"
marker.write_text("existing data")
code, report = self.execute(self.source("base"), self.source("head"))
self.assertEqual(code, 2)
self.assertIsNone(report)
self.assertEqual(marker.read_text(), "existing data")
def test_empty_selection_is_a_setup_error(self):
code, report = self.execute(self.source("base"), self.source("head"), "--tests", "^missing$")
self.assertEqual(code, 2, report)
self.assertIn("nonempty unique test names", report["error"])
def test_unexpected_command_failure_is_retained(self):
code, report = self.execute(self.source("base"), self.source("head", "fail"), "--repeat", "1")
self.assertEqual(code, 1, report)
self.assertTrue(any(entry["returncode"] != 0 for entry in report["iterations"]))
def test_command_cleanup_removes_descendant_after_parent_exit_and_timeout(self):
for timeout in (True, False):
with self.subTest(timeout=timeout):
marker = self.root / ("timeout-marker" if timeout else "exit-marker")
ready = marker.with_suffix(".ready")
child = f"import time; from pathlib import Path; Path({str(ready)!r}).touch(); time.sleep(1); Path({str(marker)!r}).touch()"
parent = f"import subprocess,sys,time; from pathlib import Path; subprocess.Popen([sys.executable,'-c',{child!r}])\nwhile not Path({str(ready)!r}).exists(): time.sleep(.01)\n"
if timeout:
parent += "time.sleep(30)\n"
with self.assertRaises(subprocess.TimeoutExpired):
gate.run([sys.executable, "-c", parent], self.root / "timeout.log", os.environ, .5)
else:
self.assertEqual(gate.run([sys.executable, "-c", parent], self.root / "exit.log", os.environ, 5), 0)
self.assertTrue(ready.exists())
time.sleep(1.1)
self.assertFalse(marker.exists())
if __name__ == "__main__":
unittest.main()