blob: 485b5d68109143ce769439866be2273be143c5df [file] [edit]
#!/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())