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())