| #!/usr/bin/env python3 | |
| # SPDX-FileCopyrightText: The Eigen Authors | |
| # SPDX-License-Identifier: MPL-2.0 | |
| """Unit tests for ci/scripts/test_cache.py. | |
| Runs against synthetic config roots and build directories, so the expectations | |
| do not drift as the real CI configuration changes. | |
| The load-bearing group is test_keyed_env: the fingerprint hashes only | |
| CONFIG_PATHS rather than the whole ci/ tree, which is sound only because every | |
| job variable that can change a test's outcome is keyed by value. Those checks | |
| pin that claim, so widening the YAML's influence again has to be deliberate. | |
| Usage: python3 ci/scripts/test_test_cache.py | |
| """ | |
| import json | |
| import os | |
| import shutil | |
| import stat | |
| import sys | |
| import tempfile | |
| import xml.etree.ElementTree as ElementTree | |
| sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| import test_cache | |
| from test_cache import fingerprint, read_manifest, test_key, write_manifest | |
| FAILURES = [] | |
| def check(condition, message): | |
| if condition: | |
| return | |
| FAILURES.append(message) | |
| print("FAIL: %s" % message) | |
| # Enough of a config root to exercise both CONFIG_PATHS entries plus the | |
| # orchestration files that must no longer count. | |
| CONFIG_FIXTURE = { | |
| "ci/scripts/test.linux.script.sh": "#!/bin/bash\nctest\n", | |
| "ci/scripts/common.linux.before_script.sh": "#!/bin/bash\nexport NPROC=`nproc`\n", | |
| "ci/docker/ubuntu-24.04-amd64-smoketest-run/Dockerfile": "FROM ubuntu:24.04\n", | |
| "ci/test.linux.gitlab-ci.yml": "test:linux:x86-64:\n tags: [saas-linux-2xlarge-amd64]\n", | |
| "ci/common.gitlab-ci.yml": ".rules:libeigen:all-tests:\n rules: []\n", | |
| "ci/CTest2JUnit.xsl": "<xsl:stylesheet/>\n", | |
| "ci/README.md": "CI documentation.\n", | |
| ".gitlab-ci.yml": "stages: [checkformat, build, test]\n", | |
| } | |
| def write_tree(root, files): | |
| for rel, content in files.items(): | |
| path = os.path.join(root, rel) | |
| os.makedirs(os.path.dirname(path), exist_ok=True) | |
| with open(path, "w") as handle: | |
| handle.write(content) | |
| def fingerprint_of(root, env=None): | |
| """The fingerprint with a controlled environment. file_digest is cached on | |
| the path, so an in-place edit is invisible without a cache_clear.""" | |
| test_cache.file_digest.cache_clear() | |
| saved = os.environ.copy() | |
| os.environ.clear() | |
| os.environ.update({"CI_JOB_IMAGE": "ubuntu:24.04"}) | |
| if env: | |
| os.environ.update(env) | |
| try: | |
| return fingerprint(root) | |
| finally: | |
| os.environ.clear() | |
| os.environ.update(saved) | |
| def edit(root, rel, content): | |
| with open(os.path.join(root, rel), "w") as handle: | |
| handle.write(content) | |
| def test_config_paths(root): | |
| """ci/scripts and ci/docker invalidate; the CI YAML no longer does.""" | |
| base = fingerprint_of(root) | |
| edit(root, "ci/scripts/test.linux.script.sh", "#!/bin/bash\nctest --verbose\n") | |
| check(fingerprint_of(root) != base, "editing ci/scripts/ must change the fingerprint") | |
| edit(root, "ci/scripts/test.linux.script.sh", CONFIG_FIXTURE["ci/scripts/test.linux.script.sh"]) | |
| check(fingerprint_of(root) == base, "restoring ci/scripts/ must restore the fingerprint") | |
| edit(root, "ci/docker/ubuntu-24.04-amd64-smoketest-run/Dockerfile", "FROM ubuntu:26.04\n") | |
| check(fingerprint_of(root) != base, "editing ci/docker/ must change the fingerprint") | |
| edit(root, "ci/docker/ubuntu-24.04-amd64-smoketest-run/Dockerfile", | |
| CONFIG_FIXTURE["ci/docker/ubuntu-24.04-amd64-smoketest-run/Dockerfile"]) | |
| # A new script must not be invisible: this is why CONFIG_PATHS is walked | |
| # rather than enumerated. | |
| added = os.path.join(root, "ci/scripts/brand_new.sh") | |
| with open(added, "w") as handle: | |
| handle.write("#!/bin/bash\n") | |
| check(fingerprint_of(root) != base, "adding a file under ci/scripts/ must change the fingerprint") | |
| os.remove(added) | |
| # Orchestration: must not invalidate. | |
| for rel, changed in ( | |
| ("ci/test.linux.gitlab-ci.yml", "test:linux:x86-64:\n tags: [saas-linux-large-amd64]\n"), | |
| ("ci/common.gitlab-ci.yml", ".rules:libeigen:all-tests:\n rules: [{if: $CI_COMMIT_TAG}]\n"), | |
| ("ci/CTest2JUnit.xsl", "<xsl:stylesheet version='1.0'/>\n"), | |
| ("ci/README.md", "Rewritten CI documentation.\n"), | |
| (".gitlab-ci.yml", "stages: [checkformat, build, test, deploy]\n"), | |
| ): | |
| edit(root, rel, changed) | |
| check(fingerprint_of(root) == base, "editing %s must not change the fingerprint" % rel) | |
| edit(root, rel, CONFIG_FIXTURE[rel]) | |
| def test_keyed_env(root): | |
| """Every knob the old whole-ci/ hash was justified by is keyed by value. | |
| This is what makes dropping the YAML from the hash sound; if one of these | |
| regresses, the narrowing is no longer safe. | |
| """ | |
| base = fingerprint_of(root) | |
| for name, value in ( | |
| ("EIGEN_REPEAT", "2"), | |
| ("EIGEN_SEED", "12345"), | |
| ("EIGEN_CI_CTEST_ARGS", "--timeout 3000"), | |
| ("QEMU_CPU", "max,sme=on,sme512=on"), | |
| ("ASAN_OPTIONS", "detect_leaks=1"), | |
| ("UBSAN_OPTIONS", "print_stacktrace=1"), | |
| ("LSAN_OPTIONS", "suppressions=lsan.supp"), | |
| ("TSAN_OPTIONS", "halt_on_error=1"), | |
| ("MSAN_OPTIONS", "poison_in_dtor=1"), | |
| ("LD_LIBRARY_PATH", "/usr/aarch64-linux-gnu/lib"), | |
| ("LD_PRELOAD", "libasan.so"), | |
| ): | |
| check(fingerprint_of(root, {name: value}) != base, | |
| "%s must be part of the fingerprint" % name) | |
| # The image is keyed; host-derived values that vary across runners of one | |
| # job are deliberately not. | |
| check(fingerprint_of(root, {"CI_JOB_IMAGE": "ubuntu:26.04"}) != base, | |
| "CI_JOB_IMAGE must be part of the fingerprint") | |
| for name, value in (("EIGEN_CI_CTEST_PARALLEL", "4"), ("NPROC", "32"), ("CI_JOB_ID", "12345")): | |
| check(fingerprint_of(root, {name: value}) == base, | |
| "%s must not be part of the fingerprint" % name) | |
| def test_fingerprint_stable(root): | |
| check(fingerprint_of(root) == fingerprint_of(root), | |
| "the fingerprint must not depend on walk or dict order") | |
| def make_test(builddir, name="unit", command=None, properties=None): | |
| binary = os.path.join(builddir, name) | |
| if not os.path.exists(binary): | |
| with open(binary, "w") as handle: | |
| handle.write("#!/bin/sh\nexit 0\n") | |
| os.chmod(binary, os.stat(binary).st_mode | stat.S_IXUSR) | |
| return {"name": name, "command": command or [binary], "properties": properties or []} | |
| def test_keying(builddir): | |
| fp = b"fingerprint\0" | |
| unit = make_test(builddir) | |
| base = test_key(unit, fp, builddir) | |
| check(base is not None, "a command ending in a build-directory executable must be keyable") | |
| # The binary's content is what the cache is addressed by. | |
| test_cache.file_digest.cache_clear() | |
| with open(os.path.join(builddir, "unit"), "w") as handle: | |
| handle.write("#!/bin/sh\nexit 1\n") | |
| check(test_key(unit, fp, builddir) != base, "changing the binary must change the key") | |
| test_cache.file_digest.cache_clear() | |
| with open(os.path.join(builddir, "unit"), "w") as handle: | |
| handle.write("#!/bin/sh\nexit 0\n") | |
| test_cache.file_digest.cache_clear() | |
| check(test_key(unit, fp, builddir) == base, "restoring the binary must restore the key") | |
| # A non-final command element that is a file -- the emulator on a | |
| # cross-compiled job -- is keyed by content, not by name. | |
| emulator = os.path.join(builddir, "qemu-aarch64-static") | |
| with open(emulator, "w") as handle: | |
| handle.write("emulator v1\n") | |
| emulated = make_test(builddir, command=[emulator, os.path.join(builddir, "unit")]) | |
| test_cache.file_digest.cache_clear() | |
| with_v1 = test_key(emulated, fp, builddir) | |
| with open(emulator, "w") as handle: | |
| handle.write("emulator v2\n") | |
| test_cache.file_digest.cache_clear() | |
| check(test_key(emulated, fp, builddir) != with_v1, "the emulator's content must be part of the key") | |
| # CTest properties reach the key, which is how --timeout is covered twice. | |
| timed = make_test(builddir, properties=[{"name": "TIMEOUT", "value": "1500"}]) | |
| other = make_test(builddir, properties=[{"name": "TIMEOUT", "value": "3000"}]) | |
| test_cache.file_digest.cache_clear() | |
| check(test_key(timed, fp, builddir) != test_key(other, fp, builddir), | |
| "a CTest property must be part of the key") | |
| # The fingerprint reaches the key. | |
| test_cache.file_digest.cache_clear() | |
| check(test_key(unit, b"other\0", builddir) != base, "the fingerprint must reach the test key") | |
| # Not keyable: the outcome depends on more than the artifact's content. | |
| check(test_key({"name": "n", "command": []}, fp, builddir) is None, "an empty command is not keyable") | |
| check(test_key({"name": "n"}, fp, builddir) is None, "a missing command is not keyable") | |
| check(test_key({"name": "n", "command": ["/bin/sh", "-c", "true"]}, fp, builddir) is None, | |
| "a command outside the build directory is not keyable") | |
| def test_manifest_roundtrip(builddir): | |
| path = os.path.join(builddir, "manifest") | |
| write_manifest(path, {"k1": "a", "k2": "b", "k3": "c"}, 10) | |
| check(list(read_manifest(path).items()) == [("k1", "a"), ("k2", "b"), ("k3", "c")], | |
| "the manifest must round-trip in insertion order") | |
| # Trimming keeps the most-recently-useful entries, which are at the back. | |
| write_manifest(path, {"k1": "a", "k2": "b", "k3": "c"}, 2) | |
| check(list(read_manifest(path)) == ["k2", "k3"], "trimming must drop the oldest entries") | |
| check(read_manifest(os.path.join(builddir, "absent")) == {}, | |
| "a missing manifest must read as empty") | |
| def test_record_status(builddir): | |
| """Only first-attempt passes enter the manifest, and an unreadable | |
| Test.xml records nothing rather than trusting a partial run.""" | |
| class Args(object): | |
| pass | |
| testing = os.path.join(builddir, "Testing") | |
| os.makedirs(os.path.join(testing, "20260825-0000"), exist_ok=True) | |
| with open(os.path.join(testing, "TAG"), "w") as handle: | |
| handle.write("20260825-0000\n") | |
| root = ElementTree.Element("Site") | |
| testing_el = ElementTree.SubElement(root, "Testing") | |
| for name, status in (("ok", "passed"), ("bad", "failed"), ("skipped", "notrun")): | |
| el = ElementTree.SubElement(testing_el, "Test", Status=status) | |
| ElementTree.SubElement(el, "Name").text = name | |
| xml_path = os.path.join(testing, "20260825-0000", "Test.xml") | |
| ElementTree.ElementTree(root).write(xml_path) | |
| keys_path = os.path.join(builddir, "keys") | |
| with open(keys_path, "w") as handle: | |
| handle.write("key-ok ok\nkey-bad bad\nkey-skipped skipped\n") | |
| args = Args() | |
| args.manifest = os.path.join(builddir, "record-manifest") | |
| args.keys = keys_path | |
| args.testing_dir = testing | |
| args.max_entries = 20000 | |
| if os.path.exists(args.manifest): | |
| os.remove(args.manifest) | |
| test_cache.record(args) | |
| recorded = read_manifest(args.manifest) | |
| check(list(recorded) == ["key-ok"], | |
| "only Status=passed may be recorded, got %s" % list(recorded)) | |
| # A truncated Test.xml must leave an existing manifest untouched. | |
| write_manifest(args.manifest, {"key-existing": "existing"}, 10) | |
| with open(xml_path, "w") as handle: | |
| handle.write("<Site><Testing><Test Status=") | |
| test_cache.record(args) | |
| check(list(read_manifest(args.manifest)) == ["key-existing"], | |
| "an unparsable Test.xml must record nothing") | |
| os.remove(os.path.join(testing, "TAG")) | |
| test_cache.record(args) | |
| check(list(read_manifest(args.manifest)) == ["key-existing"], | |
| "a missing TAG must record nothing") | |
| def main(): | |
| root = tempfile.mkdtemp(prefix="eigen-test-cache-") | |
| builddir = tempfile.mkdtemp(prefix="eigen-test-cache-build-") | |
| try: | |
| write_tree(root, CONFIG_FIXTURE) | |
| test_config_paths(root) | |
| test_keyed_env(root) | |
| test_fingerprint_stable(root) | |
| test_keying(os.path.realpath(builddir)) | |
| test_manifest_roundtrip(builddir) | |
| test_record_status(builddir) | |
| finally: | |
| shutil.rmtree(root, ignore_errors=True) | |
| shutil.rmtree(builddir, ignore_errors=True) | |
| if FAILURES: | |
| print("\n%d check(s) failed" % len(FAILURES)) | |
| return 1 | |
| print("all checks passed") | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) |