Files
AnyPS5/tools/tests/test_build_changed_guest_tests.py
T
Dean f485df1f8c Merge pull request #13264 from rooklz/fix/build-changed-guest-tests-symlink-loop
fix(tools): report symlink-loop build dir in build_changed_guest_tests
2026-11-01 14:54:12 +13:00

306 lines
16 KiB
Python

import contextlib
import io
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import build_changed_guest_tests
def run_result(stdout=""):
return mock.Mock(returncode=0, stdout=stdout, stderr="")
class GuestTestTargets(unittest.TestCase):
def target(self, name, target_type="EXECUTABLE", source="core/libs/tests/Guest.cpp", fragment=""):
return {
"artifacts": [{"path": f"tests/{name}.exe"}],
"id": name,
"link": {"commandFragments": [{"fragment": fragment}] if fragment else []},
"name": name,
"paths": {"build": "core/libs", "source": "core/libs"},
"sources": [{"path": source}],
"type": target_type,
}
def fixture(self, targets):
directory = Path(self.temporary.name)
reply = directory / "build" / ".cmake" / "api" / "v1" / "reply"
reply.mkdir(parents=True)
entries = []
for target in targets:
filename = f"target-{target['name']}.json"
(reply / filename).write_text(json.dumps(target), encoding="utf-8")
entries.append({"id": target["id"], "jsonFile": filename, "name": target["name"]})
model = {"configurations": [{"targets": entries}], "paths": {"source": str(directory)}}
(reply / "codemodel.json").write_text(json.dumps(model), encoding="utf-8")
index = {"reply": {"codemodel-v2": {"jsonFile": "codemodel.json"}}}
(reply / "index-0001.json").write_text(json.dumps(index), encoding="utf-8")
return directory / "build"
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
def tearDown(self):
self.temporary.cleanup()
def selections(self, build, changed):
source_root, targets = build_changed_guest_tests.host_only.read_reply(build.resolve())
return build_changed_guest_tests.guest_test_targets(source_root, targets, changed)
def test_selects_core_libs_test_executables_with_guest_libc(self):
guest = self.target("guest_pad_tests", fragment="libc.prx")
host = self.target("host_tests")
product = self.target("libScePad", target_type="SHARED_LIBRARY", source="core/libs/prx/libScePad/Export.cpp")
self.assertEqual(self.selections(self.fixture([guest, host, product]), None), ["guest_pad_tests"])
def test_selects_changed_guest_test_with_mingw_libc_import_library(self):
for fragment in (
"core/libs/libs/implib/liblibc.dll.a",
r'"C:\build with spaces\core\libs\libs\implib\liblibc.dll.a"',
):
with self.subTest(fragment=fragment):
guest = self.target("guest_mktime_tests", source="core/libs/tests/GuestMktime.cpp", fragment=fragment)
targets = {guest["id"]: guest}
source_root = Path(self.temporary.name).resolve()
for changed in (["core/libs/tests/GuestMktime.cpp"], ["core/libs/prx/libc/include/SonyCrt.hpp"]):
self.assertEqual(
build_changed_guest_tests.guest_test_targets(source_root, targets, changed),
["guest_mktime_tests"],
)
def test_changed_source_selects_only_the_changed_target(self):
guest_a = self.target("guest_a_tests", source="core/libs/tests/GuestA.cpp", fragment="libc.prx")
guest_b = self.target("guest_b_tests", source="core/libs/tests/GuestB.cpp", fragment="libc.prx")
self.assertEqual(self.selections(self.fixture([guest_a, guest_b]), ["core/libs/tests/GuestB.cpp"]), ["guest_b_tests"])
def test_a_production_source_change_selects_no_test_but_reaches_the_run(self):
guest = self.target("guest_pad_tests", source="core/libs/tests/Guest.cpp", fragment="libc.prx")
self.assertEqual(self.selections(self.fixture([guest]), ["core/libs/prx/libc/src/FloatBuiltins.cpp"]), [])
def test_a_header_change_selects_every_guest_test(self):
guest_a = self.target("guest_a_tests", source="core/libs/tests/GuestA.cpp", fragment="libc.prx")
guest_b = self.target("guest_b_tests", source="core/libs/tests/GuestB.cpp", fragment="libc.prx")
self.assertEqual(self.selections(self.fixture([guest_a, guest_b]), ["core/libs/prx/libScePad/include/Pad.hpp"]), ["guest_a_tests", "guest_b_tests"])
def test_an_unrelated_change_selects_nothing(self):
guest = self.target("guest_pad_tests", source="core/libs/tests/Guest.cpp", fragment="libc.prx")
self.assertEqual(self.selections(self.fixture([guest]), ["core/relinker/codegen/src/Amd64OnlyConverter.cpp"]), [])
class OwnerResolution(unittest.TestCase):
def test_resolves_the_executable_not_a_test_argument(self):
owner = build_changed_guest_tests.test_owner(
["C:/b/tests/guest_np_common_tests.exe", "build"], {"guest_np_common_tests": "guest_np_common_tests"})
self.assertEqual(owner, "guest_np_common_tests")
def test_argumentless_registration_resolves(self):
owner = build_changed_guest_tests.test_owner(
["C:/b/tests/guest_pad_tests.exe"], {"guest_pad_tests": "guest_pad_tests"})
self.assertEqual(owner, "guest_pad_tests")
def test_an_argument_that_merely_looks_like_a_target_does_not_own_the_test(self):
owner = build_changed_guest_tests.test_owner(
["C:/b/tests/other_tests.exe", "guest_pad_tests"], {"guest_pad_tests": "guest_pad_tests"})
self.assertIsNone(owner)
class Main(unittest.TestCase):
def target(self, name, target_type="EXECUTABLE", source="core/libs/tests/Guest.cpp", fragment=""):
return {
"artifacts": [{"path": f"tests/{name}.exe"}],
"id": name,
"link": {"commandFragments": [{"fragment": fragment}] if fragment else []},
"name": name,
"paths": {"build": "core/libs", "source": "core/libs"},
"sources": [{"path": source}],
"type": target_type,
}
def listing(self):
return json.dumps({"tests": [
{"name": "guest_np_common_build", "command": ["C:/b/tests/guest_np_common_tests.exe", "build"]},
{"name": "guest_np_common_fetch", "command": ["C:/b/tests/guest_np_common_tests.exe", "fetch"]},
{"name": "guest_pad", "command": ["C:/b/tests/guest_pad_tests.exe"]},
]})
def fixture(self, targets):
directory = Path(self.temporary.name)
reply = directory / "build" / ".cmake" / "api" / "v1" / "reply"
reply.mkdir(parents=True)
entries = []
for target in targets:
filename = f"target-{target['name']}.json"
(reply / filename).write_text(json.dumps(target), encoding="utf-8")
entries.append({"id": target["id"], "jsonFile": filename, "name": target["name"]})
model = {"configurations": [{"targets": entries}], "paths": {"source": str(directory)}}
(reply / "codemodel.json").write_text(json.dumps(model), encoding="utf-8")
index = {"reply": {"codemodel-v2": {"jsonFile": "codemodel.json"}}}
(reply / "index-0001.json").write_text(json.dumps(index), encoding="utf-8")
return directory / "build"
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
def tearDown(self):
self.temporary.cleanup()
def main(self, build, changed_lines):
changed = Path(self.temporary.name) / "changed.txt"
changed.write_text("\n".join(changed_lines), encoding="utf-8")
return build_changed_guest_tests.main([str(build), str(changed)])
def test_mixed_argument_bearing_and_argumentless_tests_all_run(self):
build = self.fixture([
self.target("guest_np_common_tests", source="core/libs/tests/GuestNp.cpp", fragment="libc.prx"),
self.target("guest_pad_tests", source="core/libs/tests/Guest.cpp", fragment="libc.prx"),
])
calls = []
selections = []
def execute(cmd, **_):
calls.append(cmd)
if "-I" in cmd:
selections.append(Path(cmd[cmd.index("-I") + 1]).read_text())
return run_result(self.listing() if "--show-only=json-v1" in cmd else "")
with mock.patch.object(build_changed_guest_tests.subprocess, "run", side_effect=execute):
code = self.main(build, ["core/libs/tests/GuestNp.cpp", "core/libs/tests/Guest.cpp"])
self.assertEqual(code, 0)
run_cmd = [cmd for cmd in calls if cmd[0] == "ctest" and not any("--show-only" in part for part in cmd)][0]
self.assertEqual(selections, ["0,0,0,1,2,3"])
self.assertIn("--no-tests=error", run_cmd)
def test_large_selection_fits_windows_commands_and_preserves_every_test(self):
selected = ["guest_" + "long_name_" * 8 + str(index) for index in range(600)]
tests = [{"name": "unrelated", "command": ["other.exe"]}]
tests += [{"name": f"case [{index}].+ \U0001f600", "command": [f"C:/b/{name}.exe"]}
for index, name in enumerate(selected)]
listing = json.dumps({"tests": tests})
calls = []
seen_indices = []
def execute(cmd, **_):
length = len(subprocess.list2cmdline(cmd).encode("utf-16-le")) // 2
if length >= 32767:
raise OSError(206, "Windows command line is too long")
calls.append(cmd)
if "-I" in cmd:
content = Path(cmd[cmd.index("-I") + 1]).read_text()
seen_indices.extend(map(int, content.split(",")[3:]))
return run_result(listing if "--show-only=json-v1" in cmd else "")
with mock.patch.object(build_changed_guest_tests.subprocess, "run", side_effect=execute), \
mock.patch("select_tests.sys.platform", "win32"):
build_changed_guest_tests.run(Path(self.temporary.name), selected, {name: name for name in selected})
builds = [cmd[cmd.index("--target") + 1:] for cmd in calls if cmd[0] == "cmake"]
self.assertEqual(builds[0], ["libs"])
self.assertGreater(len(builds), 2)
self.assertEqual([name for batch in builds[1:] for name in batch], selected)
self.assertEqual(seen_indices, list(range(2, len(tests) + 1)))
self.assertEqual(len([cmd for cmd in calls if "-I" in cmd]), 1)
def test_ctest_index_file_runs_only_resolved_tests(self):
build = Path(self.temporary.name) / "ctest-build"
build.mkdir()
python = Path(sys.executable).as_posix()
body = f'add_test("unrelated" "cmake" "-E" "false")\n'
for name in ("selected [one].+", "selected two"):
body += f'add_test("{name}" "{python}" "-c" "pass")\n'
(build / "CTestTestfile.cmake").write_text(body)
real_run = subprocess.run
executed = []
def execute(cmd, **kwargs):
if cmd[0] == "cmake":
return run_result()
result = real_run(cmd, **kwargs)
if "-I" in cmd:
executed.append(Path(cmd[cmd.index("-I") + 1]).read_text())
return result
with mock.patch.object(build_changed_guest_tests.subprocess, "run", side_effect=execute):
build_changed_guest_tests.run(build, ["python_target"], {Path(sys.executable).stem: "python_target"})
self.assertEqual(executed, ["0,0,0,2,3"])
def test_a_production_only_change_still_builds_libs_and_runs_nothing(self):
build = self.fixture([self.target("guest_pad_tests", source="core/libs/tests/Guest.cpp", fragment="libc.prx")])
calls = []
with mock.patch.object(build_changed_guest_tests.subprocess, "run", side_effect=lambda cmd, **_: calls.append(cmd) or run_result()):
code = self.main(build, ["core/libs/prx/libc/src/FloatBuiltins.cpp"])
self.assertEqual(code, 0)
self.assertIn(["cmake", "--build", str(build), "--parallel", "3", "--target", "libs"], calls)
self.assertFalse([cmd for cmd in calls if cmd[0] == "ctest"])
def test_selected_target_with_a_renamed_executable_runs_its_ctest(self):
guest = self.target("guest_pad_tests", source="core/libs/tests/Guest.cpp", fragment="libc.prx")
guest["artifacts"] = [{"path": "tests/pad-renamed.exe"}]
build = self.fixture([guest])
listing = json.dumps({"tests": [{"name": "guest_pad", "command": ["C:/b/tests/pad-renamed.exe"]}]})
calls = []
selections = []
def execute(cmd, **_):
calls.append(cmd)
if "-I" in cmd:
selections.append(Path(cmd[cmd.index("-I") + 1]).read_text())
return run_result(listing if "--show-only=json-v1" in cmd else "")
with mock.patch.object(build_changed_guest_tests.subprocess, "run", side_effect=execute):
code = self.main(build, ["core/libs/tests/Guest.cpp"])
self.assertEqual(code, 0)
commands = [cmd for cmd in calls if cmd[0] == "ctest" and "--show-only=json-v1" not in cmd]
self.assertEqual(len(commands), 1)
self.assertEqual(selections, ["0,0,0,1"])
def test_an_unrelated_change_runs_nothing(self):
build = self.fixture([self.target("guest_pad_tests", source="core/libs/tests/Guest.cpp", fragment="libc.prx")])
calls = []
with mock.patch.object(build_changed_guest_tests.subprocess, "run", side_effect=lambda cmd, **_: calls.append(cmd) or run_result()):
code = self.main(build, ["core/relinker/codegen/src/Amd64OnlyConverter.cpp"])
self.assertEqual(code, 0)
self.assertEqual(calls, [])
def test_partial_target_to_test_coverage_is_rejected(self):
build = self.fixture([
self.target("guest_pad_tests", source="core/libs/tests/Guest.cpp", fragment="libc.prx"),
self.target("guest_unregistered_tests", source="core/libs/tests/GuestUnregistered.cpp", fragment="libc.prx"),
])
calls = []
with mock.patch.object(build_changed_guest_tests.subprocess, "run", side_effect=lambda cmd, **_: calls.append(cmd) or run_result(self.listing() if "--show-only=json-v1" in cmd else "")):
code = self.main(build, ["core/libs/tests/Guest.cpp", "core/libs/tests/GuestUnregistered.cpp"])
self.assertEqual(code, 1)
self.assertFalse([cmd for cmd in calls if cmd[0] == "ctest" and not any("--show-only" in part for part in cmd)])
def test_a_ctest_failure_propagates(self):
build = self.fixture([self.target("guest_pad_tests", source="core/libs/tests/Guest.cpp", fragment="libc.prx")])
def fail(cmd, **_):
if cmd[0] == "ctest" and "--show-only=json-v1" not in cmd:
raise subprocess.CalledProcessError(2, cmd)
return run_result(self.listing() if "--show-only=json-v1" in cmd else "")
with mock.patch.object(build_changed_guest_tests.subprocess, "run", side_effect=fail):
code = self.main(build, ["core/libs/tests/Guest.cpp"])
self.assertEqual(code, 1)
def test_a_libs_build_failure_propagates(self):
build = self.fixture([self.target("guest_pad_tests", source="core/libs/tests/Guest.cpp", fragment="libc.prx")])
with mock.patch.object(build_changed_guest_tests.subprocess, "run", side_effect=subprocess.CalledProcessError(2, "cmake")):
code = self.main(build, ["core/libs/prx/libc/src/FloatBuiltins.cpp"])
self.assertEqual(code, 1)
def test_symbolic_link_loop_build_directory_is_reported_cleanly(self):
root = Path(self.temporary.name)
loop = root / "build"
try:
loop.symlink_to(root / "other")
(root / "other").symlink_to(loop)
except (NotImplementedError, OSError) as error:
self.skipTest(f"symbolic links are unavailable: {error}")
errors = io.StringIO()
with contextlib.redirect_stderr(errors):
self.assertEqual(build_changed_guest_tests.main([str(loop)]), 1)
self.assertIn("no CMake File API index", errors.getvalue())
self.assertNotIn("Traceback", errors.getvalue())
if __name__ == "__main__":
with contextlib.redirect_stdout(io.StringIO()):
unittest.main()