field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

kotlin_smoke.py (10695B)


      1 """Run the test-only generated Kotlin/JNA boundary on the governed native host."""
      2 
      3 from __future__ import annotations
      4 
      5 import argparse
      6 import hashlib
      7 import json
      8 import os
      9 import platform
     10 import re
     11 import subprocess
     12 import sys
     13 import xml.etree.ElementTree as ET
     14 from pathlib import Path
     15 from typing import Any
     16 
     17 import ffi_artifacts as artifacts
     18 import ffi_installed as installed
     19 import ffi_provenance as provenance
     20 import ffi_source as source
     21 
     22 HARNESS = "scripts/kotlin_smoke"
     23 HOST = "aarch64-apple-darwin"
     24 LIBRARY = f"native/{HOST}/libtera_ffi.dylib"
     25 WRAPPER_HASHES = {
     26     "gradlew": "ab5c0cad16305af2e619c159c1f58dd68d07fab9c11e36701e109c0277407f7a",
     27     "gradle/wrapper/gradle-wrapper.jar": "497c8c2a7e5031f6aa847f88104aa80a93532ec32ee17bdb8d1d2f67a194a9c7",
     28 }
     29 EXPECTED_CASES = {
     30     "tera.smoke.LegacyInventoryTests/nativeLegacyPagesPreserveOperationsAcrossRestartAndRejectMalformedCursors",
     31     "tera.smoke.ScopeLifecycleTests/scopeAndUnsignedContextsCrossRustWithoutSignedNarrowing",
     32     "tera.smoke.ScopeLifecycleTests/cancelledCloseWaitRetainsNativeCallbackDrainAndClosedAdmission",
     33     "tera.smoke.ScopeLifecycleTests/independentSubscriptionDisposalStopsOnlyItsObserver",
     34     "tera.smoke.ScopeLifecycleTests/errorRecoveryAndProtectedDataFailureRemainTyped",
     35     "tera.smoke.ScopeLifecycleTests/cancellationHandleKeepsIdentityAndDisposesIdempotently",
     36     "tera.smoke.MediaOwnershipTests/callerCloseBeforeGeneratedConversionPreservesTheAdmittedBytes",
     37     "tera.smoke.MediaOwnershipTests/recycledCallerSlotAndDisposedSourceWrapperCannotSubstituteBytes",
     38     "tera.smoke.MediaOwnershipTests/admissionRejectsInvalidDescriptorsAndWrongTypesSynchronously",
     39     "tera.smoke.TodayReceiptTests/generatedNativeBufferRoundTripPreservesIncompleteAndUnknownTargetEvidence",
     40     "tera.smoke.CalendarTimingTests/generatedNativeBuffersPreserveCivilDatesAndExclusiveEnds",
     41     "tera.smoke.CalendarTimingTests/generatedPageBufferRetainsFrozenViewerCalendarContext",
     42     "tera.smoke.CalendarTimingTests/generatedNativeBuffersPreserveEveryUnsignedInstantBitAndSourceZone",
     43     "tera.smoke.CalendarTimingTests/unknownVariantsAndTruncatedGeneratedValuesFailWithoutFabricatingDates",
     44     "tera.smoke.ComposerPersistenceTests/actualNativeComposerPreservesPartialFormsExactUnsignedReceiptsAndRestart",
     45     "tera.smoke.ComposerPersistenceTests/unsupportedNestedVersionsAndMalformedVariantsFailClosed",
     46     "tera.smoke.SubmissionReservationTests/reservationRecoversOneHistoricalSourceAcrossConcurrencyAndRestart",
     47     "tera.smoke.SubmissionReservationTests/invalidReservationVersionsAndUnsignedWidthsFailClosed",
     48 }
     49 
     50 
     51 def require(condition: bool, message: str) -> None:
     52     if not condition:
     53         raise source.ProvenanceError(message)
     54 
     55 
     56 def output_root(root: Path) -> Path:
     57     require(
     58         bool(os.environ.get("EXT_BUILD_RUN_ACTIVE")), "Kotlin smoke requires extbuild"
     59     )
     60     project = os.environ.get("EXT_BUILD_PROJECT_DIR")
     61     require(bool(project), "Kotlin smoke output routing is absent")
     62     return provenance.output_path(
     63         root, HOST, str(Path(project) / "target/kotlin_smoke")
     64     )
     65 
     66 
     67 def verify_host(root: Path, *, offline: bool) -> str:
     68     require(
     69         platform.system() == "Darwin" and platform.machine() == "arm64",
     70         "Kotlin smoke host is unsupported",
     71     )
     72     java = subprocess.check_output(["java", "--version"], text=True, timeout=30).strip()
     73     require(
     74         bool(re.match(r"(?:openjdk|java) 21[. ]", java)),
     75         "Kotlin smoke requires the discovered JDK 21",
     76     )
     77     cache = os.environ.get("GRADLE_USER_HOME")
     78     require(bool(cache) and Path(cache).is_absolute(), "Gradle cache routing is absent")
     79     require(
     80         not Path(cache).resolve().is_relative_to(root),
     81         "Gradle cache is inside the source repository",
     82     )
     83     for name, digest in WRAPPER_HASHES.items():
     84         require(
     85             artifacts.file_record(root / HARNESS, name)["sha256"] == digest,
     86             "Gradle wrapper bytes differ",
     87         )
     88     properties = (
     89         root / HARNESS / "gradle/wrapper/gradle-wrapper.properties"
     90     ).read_text()
     91     require(
     92         "distributionUrl=https\\://services.gradle.org/distributions/gradle-9.5.0-bin.zip\n"
     93         in properties
     94         and "distributionSha256Sum=553c78f50dafcd54d65b9a444649057857469edf836431389695608536d6b746\n"
     95         in properties,
     96         "Gradle distribution pin differs",
     97     )
     98     if offline:
     99         # --offline governs Gradle dependency resolution; the wrapper can still
    100         # download its distribution on first use. Require its completed install.
    101         distributions = Path(cache) / "wrapper/dists/gradle-9.5.0-bin"
    102         require(
    103             any(
    104                 p.is_file() and (p.parent / "gradle-9.5.0/bin/gradle").is_file()
    105                 for p in distributions.glob("*/gradle-9.5.0-bin.zip.ok")
    106             ),
    107             "Gradle 9.5.0 is not bootstrapped; run make kotlin-smoke-bootstrap",
    108         )
    109     return java
    110 
    111 
    112 def native_input(
    113     root: Path, output: Path
    114 ) -> tuple[dict[str, Any], Path, dict[str, Any]]:
    115     manifest = installed.check(root)
    116     candidate = manifest["candidate"]
    117     tree = candidate["source"]["tree"]
    118     base = output.parent / "tera_ffi/candidates" / tree
    119     require(base.resolve() == base, "Kotlin native candidate contains a symlink")
    120     record = artifacts.file_record(base, LIBRARY)
    121     require(
    122         record in candidate["files"],
    123         "Kotlin smoke native library does not match the installed producer",
    124     )
    125     return candidate, base / LIBRARY, record
    126 
    127 
    128 def generate(root: Path, output: Path, library: Path) -> Path:
    129     destination = provenance.output_path(root, HOST, str(output / "generated"))
    130     argv = [
    131         "cargo",
    132         "run",
    133         "-p",
    134         "tera_bindgen",
    135         "--locked",
    136         "--",
    137         "generate",
    138         str(library),
    139         "--library",
    140         "--language",
    141         "kotlin",
    142         "--metadata-no-deps",
    143         "--no-format",
    144         "--out-dir",
    145         str(destination),
    146         "--config",
    147         "core/crates/tera_ffi/uniffi.toml",
    148     ]
    149     subprocess.run(argv, cwd=root, check=True, timeout=600)
    150     generated = destination / "uniffi/tera_core/tera_core.kt"
    151     require(
    152         generated.is_file() and not generated.is_symlink(),
    153         "Generated Kotlin source is absent",
    154     )
    155     require(
    156         list(destination.rglob("*.kt")) == [generated],
    157         "Generated Kotlin source inventory differs",
    158     )
    159     return generated
    160 
    161 
    162 def test_results(directory: Path) -> dict[str, Any]:
    163     files = sorted(directory.glob("TEST-*.xml"))
    164     require(bool(files), "Kotlin smoke produced no test results")
    165     cases = []
    166     for path in files:
    167         require(
    168             path.resolve() == path and not path.is_symlink(),
    169             "Kotlin test result contains a symlink",
    170         )
    171         require(
    172             path.stat().st_size <= 2_000_000,
    173             "Kotlin test result exceeds its byte bound",
    174         )
    175         suite = ET.fromstring(path.read_bytes())
    176         require(suite.tag == "testsuite", "Kotlin test result has an invalid root")
    177         require(
    178             all(
    179                 suite.attrib.get(key) == "0"
    180                 for key in ("failures", "errors", "skipped")
    181             ),
    182             "Kotlin smoke has failed or skipped tests",
    183         )
    184         observed = suite.findall("testcase")
    185         require(
    186             len(observed) == int(suite.attrib["tests"]) and bool(observed),
    187             "Kotlin test result count differs",
    188         )
    189         require(
    190             all(not list(case) for case in observed),
    191             "Kotlin test case contains a failure or skip",
    192         )
    193         cases.extend(
    194             f"{case.attrib['classname']}/{case.attrib['name']}" for case in observed
    195         )
    196     require(
    197         len(cases) == len(set(cases)) and set(cases) == EXPECTED_CASES,
    198         "Kotlin smoke inventory is incomplete",
    199     )
    200     return {"passed": len(cases), "failed": 0, "skipped": 0, "cases": sorted(cases)}
    201 
    202 
    203 def run(root: Path, *, offline: bool) -> None:
    204     output = output_root(root)
    205     java = verify_host(root, offline=offline)
    206     candidate, library, native = native_input(root, output)
    207     generated = generate(root, output, library)
    208     result_directory = output / "build/test-results/test"
    209     require(
    210         result_directory.resolve() == result_directory,
    211         "Kotlin result directory contains a symlink",
    212     )
    213     # Remove only prior XML for this exact owned task so stale results cannot pass.
    214     for path in result_directory.glob("TEST-*.xml"):
    215         require(not path.is_symlink(), "Kotlin result is a symlink")
    216         path.unlink()
    217     argv = [
    218         str(root / HARNESS / "gradlew"),
    219         "--no-daemon",
    220         "--console=plain",
    221         "--dependency-verification=strict",
    222         "--project-cache-dir",
    223         str(output / "project_cache"),
    224         f"-Pkotlin.project.persistent.dir={output / 'kotlin'}",
    225         "-p",
    226         str(root / HARNESS),
    227         "test",
    228     ]
    229     if offline:
    230         argv.append("--offline")
    231     environment = dict(
    232         os.environ,
    233         TERA_KOTLIN_SMOKE_ROOT=str(output),
    234         TERA_KOTLIN_NATIVE_DIR=str(library.parent),
    235     )
    236     subprocess.run(argv, cwd=root, env=environment, check=True, timeout=600)
    237     results = test_results(result_directory)
    238     require(
    239         installed.check(root)["candidate"] == candidate,
    240         "Producer changed during Kotlin smoke",
    241     )
    242     require(
    243         artifacts.file_record(library.parent.parent.parent, LIBRARY) == native,
    244         "Native library changed during Kotlin smoke",
    245     )
    246     report = {
    247         "schema": "tera.kotlin-binding-smoke.v1",
    248         "source": candidate["source"],
    249         "native": native,
    250         "generated_sha256": hashlib.sha256(generated.read_bytes()).hexdigest(),
    251         "jdk": java,
    252         "host": HOST,
    253         "dependency_resolution": "offline" if offline else "bootstrap",
    254         "gradle_argv": argv,
    255         "results": results,
    256         "disposition": "local_native_binding_smoke_only",
    257     }
    258     provenance.write_atomic(output / "result.json", provenance.encoded(report))
    259     print(json.dumps(report, indent=2))
    260 
    261 
    262 def main() -> int:
    263     parser = argparse.ArgumentParser(description=__doc__)
    264     parser.add_argument("mode", choices=("verify", "bootstrap"))
    265     args = parser.parse_args()
    266     try:
    267         run(Path(__file__).resolve().parent.parent, offline=args.mode == "verify")
    268     except (
    269         OSError,
    270         ValueError,
    271         ET.ParseError,
    272         subprocess.SubprocessError,
    273         source.ProvenanceError,
    274     ) as error:
    275         print(f"Kotlin binding smoke failed: {error}", file=sys.stderr)
    276         return 1
    277     return 0
    278 
    279 
    280 if __name__ == "__main__":
    281     raise SystemExit(main())