From 790ac1a507a8f291b91fdc555d9a539d6318a198 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=8EOl=C3=BAw=C3=A1=E1=B9=A3eun=20=E1=B9=A2=C3=B3b?= =?UTF-8?q?=C3=A1nd=C3=A9?= Date: Mon, 26 Oct 2026 17:22:14 +0000 Subject: [PATCH 1/2] Document result lifetimes and verify published consumers --- .github/workflows/ci.yml | 32 +++ .github/workflows/verification.yml | 59 +++++ README.md | 68 ++++++ .../com/quantipixels/gbeewa/ResultConsumer.kt | 5 + .../com/quantipixels/gbeewa/ResultEffect.kt | 2 + .../com/quantipixels/gbeewa/ResultStore.kt | 12 +- .../quantipixels/gbeewa/ResultEffectTest.kt | 47 ++++ .../quantipixels/gbeewa/ResultStoreTest.kt | 18 ++ gradle/libs.versions.toml | 4 + tooling/verify-publication.py | 203 ++++++++++++++++++ verification/README.md | 99 +++++++++ verification/apple-ci.py | 26 +++ verification/consumer/build.gradle.kts | 147 +++++++++++++ verification/consumer/gradle.properties | 2 + verification/consumer/settings.gradle.kts | 36 ++++ .../gbeewa/verification/OwnershipTest.kt | 33 +++ .../src/androidMain/AndroidManifest.xml | 5 + .../verification/VerificationActivity.kt | 66 ++++++ .../gbeewa/verification/PublishedApiTest.kt | 26 +++ 19 files changed, 885 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/verification.yml create mode 100755 tooling/verify-publication.py create mode 100644 verification/README.md create mode 100644 verification/apple-ci.py create mode 100644 verification/consumer/build.gradle.kts create mode 100644 verification/consumer/gradle.properties create mode 100644 verification/consumer/settings.gradle.kts create mode 100644 verification/consumer/src/androidInstrumentedTest/kotlin/com/quantipixels/gbeewa/verification/OwnershipTest.kt create mode 100644 verification/consumer/src/androidMain/AndroidManifest.xml create mode 100644 verification/consumer/src/androidMain/kotlin/com/quantipixels/gbeewa/verification/VerificationActivity.kt create mode 100644 verification/consumer/src/commonTest/kotlin/com/quantipixels/gbeewa/verification/PublishedApiTest.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b993502..9633d56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,22 @@ jobs: CODE_SIGNING_ALLOWED=NO \ build + - name: Verify staged artifact on an owned simulator + run: python3 verification/apple-ci.py iosSimulatorArm64 + + - name: Preserve publication consumer evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: gbeewa-iosSimulatorArm64-publication-evidence + path: | + build/verification/*/*.log + build/verification/*/*.json + build/verification/*/maven/staged-files.sha256 + build/verification/*/consumer-build/reports/ + build/verification/*/consumer-build/test-results/ + if-no-files-found: warn + check-x64: runs-on: macos-15-intel steps: @@ -71,3 +87,19 @@ jobs: :samples:composeApp:iosX64Test \ :samples:composeApp:linkDebugFrameworkIosX64 \ --stacktrace + + - name: Verify staged artifact on an owned simulator + run: python3 verification/apple-ci.py iosX64 + + - name: Preserve publication consumer evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: gbeewa-iosX64-publication-evidence + path: | + build/verification/*/*.log + build/verification/*/*.json + build/verification/*/maven/staged-files.sha256 + build/verification/*/consumer-build/reports/ + build/verification/*/consumer-build/test-results/ + if-no-files-found: warn diff --git a/.github/workflows/verification.yml b/.github/workflows/verification.yml new file mode 100644 index 0000000..615bdaf --- /dev/null +++ b/.github/workflows/verification.yml @@ -0,0 +1,59 @@ +name: Published artifact verification + +on: + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + android-consumer: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + - uses: gradle/actions/setup-gradle@v5 + - name: Install the pinned Android test platform + run: | + "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-36" "system-images;android-35;google_apis;x86_64" + echo no | "$ANDROID_HOME/cmdline-tools/latest/bin/avdmanager" create avd --name publication-verification --package "system-images;android-35;google_apis;x86_64" + - name: Run the packaged Android fixture + shell: bash + run: | + set -euo pipefail + mkdir -p build/verification + if [[ -e /dev/kvm ]]; then sudo chmod a+rw /dev/kvm; fi + "$ANDROID_HOME/emulator/emulator" -avd publication-verification -port 5580 -no-window -no-audio -no-snapshot -gpu swiftshader_indirect > build/verification/emulator.log 2>&1 & + emulator_pid=$! + trap '"$ANDROID_HOME/platform-tools/adb" -s emulator-5580 emu kill || true; wait "$emulator_pid" || true' EXIT + timeout 180 "$ANDROID_HOME/platform-tools/adb" -s emulator-5580 wait-for-device + booted=false + for attempt in {1..90}; do + if [[ "$("$ANDROID_HOME/platform-tools/adb" -s emulator-5580 shell getprop sys.boot_completed | tr -d '\r')" == 1 ]]; then booted=true; break; fi + sleep 2 + done + [[ "$booted" == true ]] + python3 tooling/verify-publication.py --platform android-device --android-serial emulator-5580 + - name: Preserve publication and test evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: gbeewa-android-publication-evidence + path: | + build/verification/emulator.log + build/verification/*/*.log + build/verification/*/*.json + build/verification/*/maven/staged-files.sha256 + build/verification/*/consumer-build/reports/ + build/verification/*/consumer-build/test-results/ + build/verification/*/consumer-build/outputs/androidTest-results/ + if-no-files-found: warn diff --git a/README.md b/README.md index 896c920..b68d2d3 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,16 @@ The showcase iOS application targets iOS 17.2. This value is the host applicatio | `rememberResultAsState` | Converts delivered results into read-only Compose state. | | `rememberSaveableResultAsState` | Converts delivered results into read-only saveable Compose state with automatic or custom saving. | +Read the contracts in order: [key identity](gbeewa/src/commonMain/kotlin/com/quantipixels/gbeewa/ResultKey.kt) +→ [store lifetime](gbeewa/src/commonMain/kotlin/com/quantipixels/gbeewa/ResultStore.kt) +and [presence](gbeewa/src/commonMain/kotlin/com/quantipixels/gbeewa/PendingResult.kt) +→ [consumer](gbeewa/src/commonMain/kotlin/com/quantipixels/gbeewa/ResultConsumer.kt) +→ [effect](gbeewa/src/commonMain/kotlin/com/quantipixels/gbeewa/ResultEffect.kt) +→ [state helpers](gbeewa/src/commonMain/kotlin/com/quantipixels/gbeewa/RememberResultAsState.kt). +The focused [store tests](gbeewa/src/commonTest/kotlin/com/quantipixels/gbeewa/ResultStoreTest.kt) +and [Compose runtime tests](gbeewa/src/commonTest/kotlin/com/quantipixels/gbeewa/ResultEffectTest.kt) +show these contracts in use. + ## Define a typed key Key equality defines the result lane. An object is suitable for one shared lane. A data class is suitable when an identifier is part of the lane. @@ -194,6 +204,20 @@ The key type controls the payload type at normal call sites. Create one `ResultStore` at the application or navigation root. Keep it for the communication lifetime that the app needs. Gbéewá does not require the store to be exposed to screen content. +### Transient handoffs and restored navigation + +`ResultStore` lives in memory and is not serialized or restored. Restoring a navigation destination +does not restore its pending result. If a destination requires a transient handoff, choose its +missing-value recovery before starting dependent work. For example, return to an earlier step or +close the destination when the handoff is missing. Check and consume the handoff in an event +handler, effect, or app-owned entry boundary, never during composition or in a `remember` initializer. +Save durable identifiers through the app's own navigation or storage contract. + +`rememberSaveableResultAsState` saves a receiver's UI value, not a pending handoff. Restoring that +value does not establish that a new result was delivered. + +### Store exposure + The app chooses how far each capability travels: - Keep `ResultStore` at the root and capture it in route-owned callbacks. @@ -318,6 +342,37 @@ Direct consumption removes the pending result before it returns. Do not call it because removal changes observable Compose state. Use `ResultEffect`, `rememberResultAsState`, or `rememberSaveableResultAsState` for composition-owned delivery. +### Cancel or abandon a request + +Use the existing `ResultConsumer.consume(key)` overload to remove a retired request's pending +value without invoking a result callback. Cleanup affects only that key, so other requests keep +their pending values. Run it on the UI or main context that drives Compose consumption, such as +the Cancel event handler: + +```kotlin +import com.quantipixels.gbeewa.ResultConsumer +import com.quantipixels.gbeewa.ResultKey + +data class EditNoteKey(val requestId: String) : ResultKey + +fun cancelNoteEdit( + consumer: ResultConsumer, + key: EditNoteKey, + dismiss: () -> Unit, +) { + consumer.consume(key) + dismiss() +} +``` + +The return value can be ignored for cleanup. If it matters, `PendingResult.Value(null)` means a +present nullable value was removed; `PendingResult.Missing` means no pending value existed. +Cleanup does not cancel the producer or reject later publications. The app must stop or ignore +late producer callbacks for a retired request and use distinct keys for overlapping requests. +No discard API, registry, session manager, or navigation policy is needed for this cleanup. + +### Receive as state + Use `rememberResultAsState` when the result directly updates local Compose state. The `AsState` suffix distinguishes the returned `State` from the root-owned `ResultStore`. Extra remember keys reset the initial value when their context changes: ```kotlin @@ -428,6 +483,19 @@ the key has no pending result. - The saveable helper can restore a receiver's delivered UI value. It does not restore pending results. - Any app layer can own the store or call `put`, including a `ViewModel` or navigation component. The call must execute on the UI or main context that drives Compose consumption. The library does not switch contexts or synchronize concurrent access. +### Same-key publication inside a callback + +Keep the restriction against publishing for the same key inside its result callback. The focused +Compose runtime experiment in `ResultEffectTest` republishes both a different payload and an equal +payload while the receiver stays mounted. In each case, the replacement stays pending through +recomposition and is delivered after the receiver leaves and re-enters composition. Publishing +twice in the callback retains only the latest pending value. Removal still happens before each +callback, and competing consumers still share one pending result. + +This characterizes the documented limitation; it adds no queue, automatic redelivery, or retry +guarantee. Support for callback republishing would change observable behavior and needs a separate +decision. + ## Contributing Open an issue before a behavior change or new platform target. Keep navigation policy outside the library, add tests for contract changes, and update `CHANGELOG.md` for public behavior changes. Before a pull request, run: diff --git a/gbeewa/src/commonMain/kotlin/com/quantipixels/gbeewa/ResultConsumer.kt b/gbeewa/src/commonMain/kotlin/com/quantipixels/gbeewa/ResultConsumer.kt index 83b7aef..b25954d 100644 --- a/gbeewa/src/commonMain/kotlin/com/quantipixels/gbeewa/ResultConsumer.kt +++ b/gbeewa/src/commonMain/kotlin/com/quantipixels/gbeewa/ResultConsumer.kt @@ -27,6 +27,11 @@ public class ResultConsumer public constructor( * Direct consumption is intended for event handlers, effects, and non-Compose code. Do not * call it during composition because removal changes observable Compose state. * + * To clean up an abandoned request, call this overload and ignore its return value. It removes + * only this key's pending result and invokes no callback. It does not prevent a later producer + * from publishing another result. Consume on the UI or main context that drives Compose + * consumption; the store does not switch contexts or synchronize concurrent access. + * * @param key the typed result lane to consume. * @return the removed value or [PendingResult.Missing]. */ diff --git a/gbeewa/src/commonMain/kotlin/com/quantipixels/gbeewa/ResultEffect.kt b/gbeewa/src/commonMain/kotlin/com/quantipixels/gbeewa/ResultEffect.kt index b93f6a0..0052089 100644 --- a/gbeewa/src/commonMain/kotlin/com/quantipixels/gbeewa/ResultEffect.kt +++ b/gbeewa/src/commonMain/kotlin/com/quantipixels/gbeewa/ResultEffect.kt @@ -38,6 +38,8 @@ public val LocalResultConsumer: ProvidableCompositionLocal = * * Do not publish another value for [key] from inside [onResult]. The current keyed effect has * completed, so that value can remain pending until this effect leaves and re-enters composition. + * This includes a payload equal to the consumed value; callback republishing has no automatic + * redelivery or retry guarantee. * * @param key the typed result lane to observe. * @param onResult the latest callback that receives a pending result. diff --git a/gbeewa/src/commonMain/kotlin/com/quantipixels/gbeewa/ResultStore.kt b/gbeewa/src/commonMain/kotlin/com/quantipixels/gbeewa/ResultStore.kt index 5ff53c4..00534f9 100644 --- a/gbeewa/src/commonMain/kotlin/com/quantipixels/gbeewa/ResultStore.kt +++ b/gbeewa/src/commonMain/kotlin/com/quantipixels/gbeewa/ResultStore.kt @@ -8,11 +8,13 @@ import androidx.compose.runtime.mutableStateMapOf * * The owner publishes results through [put]. The store retains each value until a * [ResultConsumer] consumes it, a later value replaces it, or the owner discards this store. - * The store does not address a screen or inspect navigation direction. A consumer backed by this - * store can consume an equal key on the current screen or on a screen reached after a pop, push, - * replacement, or other navigation change. Any application layer can call [put], but the call - * must execute on the UI or main context that drives Compose consumption. This store does not - * switch contexts or synchronize concurrent access. + * This store lives in memory and is not serialized or restored. Restoring navigation does not + * restore pending results. If a destination requires a transient handoff, the caller must choose + * how to recover from a missing result before starting work that depends on it. + * + * Shared store ownership, equal keys, and consumption timing select the receiver; navigation + * direction does not. Publish and consume on the UI or main context that drives Compose + * consumption. This store does not switch contexts or synchronize concurrent access. */ @Stable public class ResultStore public constructor() { diff --git a/gbeewa/src/commonTest/kotlin/com/quantipixels/gbeewa/ResultEffectTest.kt b/gbeewa/src/commonTest/kotlin/com/quantipixels/gbeewa/ResultEffectTest.kt index d14bdb9..981357c 100644 --- a/gbeewa/src/commonTest/kotlin/com/quantipixels/gbeewa/ResultEffectTest.kt +++ b/gbeewa/src/commonTest/kotlin/com/quantipixels/gbeewa/ResultEffectTest.kt @@ -84,6 +84,53 @@ class ResultEffectTest { assertEquals(listOf("mounted"), delivered) } + @Test + fun `same-key callback publications stay pending until remount including equal payloads`() = + runCompositionTest { + for (replacement in listOf("different", "initial")) { + val store = ResultStore() + val delivered = mutableListOf() + val mounted = mutableStateOf(true) + val recomposition = mutableStateOf(0) + var observedRecomposition = -1 + setContent(store) { + observedRecomposition = recomposition.value + if (mounted.value) { + ResultEffect(StringKey) { value -> + assertFalse(StringKey in store) + delivered += value + if (delivered.size == 1) { + store.put(StringKey, "superseded") + store.put(StringKey, replacement) + } + } + } + } + settle() + + store.put(StringKey, "initial") + settle() + recomposition.value += 1 + settle() + + assertEquals(1, observedRecomposition) + assertEquals(listOf("initial"), delivered) + assertTrue(StringKey in store) + + mounted.value = false + settle() + assertTrue(StringKey in store) + mounted.value = true + settle() + settle() + + assertEquals(listOf("initial", replacement), delivered) + assertFalse(StringKey in store) + setContent {} + settle() + } + } + @Test fun `pending effect uses callback replaced before its coroutine runs`() = runCompositionTest { val state = ResultStore() diff --git a/gbeewa/src/commonTest/kotlin/com/quantipixels/gbeewa/ResultStoreTest.kt b/gbeewa/src/commonTest/kotlin/com/quantipixels/gbeewa/ResultStoreTest.kt index e057973..2ed8e1e 100644 --- a/gbeewa/src/commonTest/kotlin/com/quantipixels/gbeewa/ResultStoreTest.kt +++ b/gbeewa/src/commonTest/kotlin/com/quantipixels/gbeewa/ResultStoreTest.kt @@ -41,6 +41,24 @@ class ResultStoreTest { assertEquals(PendingResult.Missing, consumer.consume(NullableKey)) } + @Test + fun `consume-only cleanup removes a nullable request without callbacks or affecting another lane`() { + val store = ResultStore() + val consumer = ResultConsumer(store) + val delivered = mutableListOf() + store.put(NullableKey, null) + store.put(CountryKey, "NG") + + assertEquals(PendingResult.Value(null), consumer.consume(NullableKey)) + consumer.consume(NullableKey, delivered::add) + + assertTrue(delivered.isEmpty()) + assertEquals(PendingResult.Missing, consumer.consume(NullableKey)) + assertTrue(CountryKey in consumer) + consumer.consume(CountryKey, delivered::add) + assertEquals(listOf("NG"), delivered) + } + @Test fun `direct consumption returns the latest result for the same key`() { val store = ResultStore() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 243b4f7..8373649 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,10 @@ activity-compose = "1.10.1" vanniktech-maven-publish = "0.35.0" [libraries] +verification-compose-test = { module = "androidx.compose.ui:ui-test-junit4", version = "1.8.2" } +verification-compose-manifest = { module = "androidx.compose.ui:ui-test-manifest", version = "1.8.2" } +verification-test-runner = { module = "androidx.test:runner", version = "1.6.2" } +verification-test-core = { module = "androidx.test:core", version = "1.6.1" } decompose = { module = "com.arkivanov.decompose:decompose", version.ref = "decompose" } decompose-compose = { module = "com.arkivanov.decompose:extensions-compose", version.ref = "decompose" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } diff --git a/tooling/verify-publication.py b/tooling/verify-publication.py new file mode 100755 index 0000000..fe432c2 --- /dev/null +++ b/tooling/verify-publication.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Stage a unique local publication and run its independent binary consumer.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import platform +import shutil +import subprocess +import sys +import uuid +import xml.etree.ElementTree as ET + +ROOT = Path(__file__).resolve().parents[1] +LIBRARY = "gbeewa" + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def source_copy(destination): + # Copy current tracked and untracked source, never local machine configuration. + listing = subprocess.check_output( + ["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z"], cwd=ROOT + ).decode().split("\0") + for name in listing: + relative = Path(name) + if not name or relative.parts[0] in {"verification", "tooling", ".alarina"}: + continue + if relative.name == "local.properties" or relative.name.startswith(".env"): + continue + source = ROOT / relative + if source.is_file() and not source.is_symlink(): + target = destination / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + + +def publication_manifest(stage, version): + modules = list(stage.glob("com/quantipixels/*/" + version + "/*.module")) + if not modules: + raise RuntimeError("No Gradle module metadata was published") + for module in modules: + metadata = json.loads(module.read_text()) + component = metadata["component"] + if component.get("group") != "com.quantipixels" or component.get("version") != version: + raise RuntimeError("Unexpected publication coordinates: " + str(module)) + if not component["module"].startswith(LIBRARY) or not metadata.get("variants"): + raise RuntimeError("Unexpected publication or empty variants: " + str(module)) + files = sorted(p for p in stage.rglob("*") if p.is_file()) + (stage / "staged-files.sha256").write_text("".join( + digest(p) + " " + p.relative_to(stage).as_posix() + "\n" for p in files + )) + + +def nonzero_reports(directory): + reports = list(directory.rglob("TEST-*.xml")) + tests = failures = skipped = 0 + for report in reports: + node = ET.parse(report).getroot() + suites = [node] if node.tag == "testsuite" else node.findall("testsuite") + for suite in suites: + tests += int(suite.get("tests", 0)) + failures += int(suite.get("failures", 0)) + int(suite.get("errors", 0)) + skipped += int(suite.get("skipped", 0)) + if tests == 0 or failures or skipped: + raise RuntimeError(f"Invalid consumer result: tests={tests}, failures={failures}, skipped={skipped}") + return tests + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--phase", choices=["configure", "plan", "stage", "verify"], default="verify") + parser.add_argument("--platform", choices=["android-unit", "android-device", "iosSimulatorArm64", "iosX64"], required=True) + parser.add_argument("--android-serial") + parser.add_argument("--ios-device", help="UUID of an owner-created disposable simulator") + args = parser.parse_args() + apple = args.platform.startswith("ios") + if apple and platform.system() != "Darwin": + parser.error("Apple publication and tests require macOS") + if args.phase == "verify" and apple and not args.ios_device: + parser.error("--ios-device must identify an owned simulator clone") + if args.phase == "verify" and args.platform == "android-device" and not args.android_serial: + parser.error("--android-serial is required; no default device is selected") + + run = ROOT / "build/verification" / uuid.uuid4().hex + run.mkdir(parents=True) + stage = run / "maven" + stage.mkdir() + version = "0.0.0-verification." + run.name + "-SNAPSHOT" + source = run / "source" + source_copy(source) + consumer = run / "consumer" + shutil.copytree(ROOT / "verification/consumer", consumer) + # The catalog stays the single versions source in both the checked-in and generated fixture. + (run / "gradle").mkdir() + shutil.copy2(ROOT / "gradle/libs.versions.toml", run / "gradle/libs.versions.toml") + settings = consumer / "settings.gradle.kts" + settings.write_text(settings.read_text().replace("../../gradle/libs.versions.toml", "../gradle/libs.versions.toml")) + + # No inherited Gradle properties, signing credentials, init scripts, or Java option injection. + environment = {key: os.environ[key] for key in ( + "PATH", "JAVA_HOME", "ANDROID_HOME", "ANDROID_SDK_ROOT", "SYSTEMROOT", "TMPDIR" + ) if key in os.environ} + if not environment.get("ANDROID_HOME") and not environment.get("ANDROID_SDK_ROOT"): + candidates = [Path.home() / "Library/Android/sdk", Path.home() / "Android/Sdk"] + sdk = next((path for path in candidates if (path / "platforms/android-36/android.jar").is_file()), None) + if sdk: + environment["ANDROID_HOME"] = str(sdk) + environment["KONAN_DATA_DIR"] = str(ROOT / "build/verification/konan") + environment["HOME"] = str(run / "home") + environment["GRADLE_USER_HOME"] = str(run / "gradle-user-home") + Path(environment["HOME"]).mkdir() + Path(environment["GRADLE_USER_HOME"]).mkdir(exist_ok=True) + for name in ("caches", "wrapper"): + cache = ROOT / "build/verification/gradle-user-home" / name + cache.mkdir(parents=True, exist_ok=True) + (Path(environment["GRADLE_USER_HOME"]) / name).symlink_to(cache, target_is_directory=True) + if args.android_serial: + environment["ANDROID_SERIAL"] = args.android_serial + metadata = {"library": LIBRARY, "version": version, "platform": args.platform, + "phase": args.phase, "stage": str(stage), "state": "running"} + record = run / "run.json" + record.write_text(json.dumps(metadata, indent=2) + "\n") + print("Evidence: " + str(run), flush=True) + + common = ["--no-daemon", "--no-configuration-cache", "--no-parallel", "--max-workers=1", "--console=plain", "--stacktrace"] + wrapper = str(source / "gradlew") + def execute(label, directory, tasks, properties=(), expected_failure=False, expected_marker=None): + command = [wrapper, "-p", str(directory), *common, *tasks, *properties] + (run / (label + ".command.json")).write_text(json.dumps(command, indent=2) + "\n") + with (run / (label + ".log")).open("w") as output: + try: + result = subprocess.run(command, cwd=directory, env=environment, + stdout=output, stderr=subprocess.STDOUT, + timeout=300 if args.phase in {"configure", "plan"} else 1200) + except (subprocess.TimeoutExpired, KeyboardInterrupt): + # This run has its own daemon registry; do not stop another user's Gradle work. + subprocess.run([wrapper, "--stop"], cwd=directory, env=environment, + stdout=output, stderr=subprocess.STDOUT, timeout=60) + raise + if (result.returncode != 0) != expected_failure: + raise RuntimeError(f"{label}: unexpected exit {result.returncode}; see {label}.log") + if expected_marker and expected_marker not in (run / (label + ".log")).read_text(): + raise RuntimeError(f"{label}: failed for a different reason; inspect its log") + print(f"{label}: exit {result.returncode}", flush=True) + + properties = ["-PstagedVersion=" + version, "-PstagedRepository=" + str(stage), + "-PverificationBuildDirectory=" + str(run / "consumer-build"), + "-PverificationAppleTarget=" + (args.platform if apple else "none")] + if args.ios_device: + properties += ["-PverificationIosDevice=" + args.ios_device] + try: + if args.phase == "configure": + execute("configure", consumer, ["help"], properties) + else: + # Exact local tasks only. Never accept arbitrary task names or remote publishing tasks. + target = {"iosSimulatorArm64": "IosSimulatorArm64", "iosX64": "IosX64"}.get(args.platform, "AndroidRelease") + tasks = [f":{LIBRARY}:publishKotlinMultiplatformPublicationToMavenLocal", + f":{LIBRARY}:publish{target}PublicationToMavenLocal"] + if args.phase == "plan": + tasks += ["--dry-run"] + execute("publication-plan" if args.phase == "plan" else "stage", source, tasks, + ["-PVERSION_NAME=" + version, "-Dmaven.repo.local=" + str(stage)]) + if args.phase != "plan": + publication_manifest(stage, version) + if args.phase == "verify": + execute("artifact-resolution", consumer, ["verifyStagedArtifacts"], properties) + empty = run / "empty-maven" + empty.mkdir() + negative = [p for p in properties if not p.startswith("-PstagedRepository=")] + execute("empty-stage-must-fail", consumer, ["verifyStagedArtifacts"], + [*negative, "-PstagedRepository=" + str(empty)], expected_failure=True, + expected_marker=f"Could not find com.quantipixels:{LIBRARY}") + wrong = run / "wrong-maven" + shutil.copytree(stage, wrong) + artifact = next(p for p in wrong.rglob("*") if p.suffix in {".aar", ".klib"}) + with artifact.open("ab") as output: + output.write(b"verification-negative-control") + execute("wrong-artifact-must-fail", consumer, ["verifyStagedArtifacts"], + [*negative, "-PstagedRepository=" + str(wrong)], expected_failure=True, + expected_marker="Changed staged artifact") + tests = [args.platform + "Test"] if apple else ["testDebugUnitTest"] + if args.platform == "android-device": + tests += ["connectedDebugAndroidTest"] + execute("consumer-tests", consumer, tests, properties) + metadata["executedTests"] = nonzero_reports(run / "consumer-build") + if args.platform == "android-device": + metadata["instrumentedTests"] = nonzero_reports(run / "consumer-build/outputs/androidTest-results") + metadata["state"] = "passed" + except (Exception, KeyboardInterrupt) as error: + metadata["state"] = "failed" + metadata["error"] = str(error) + raise + finally: + record.write_text(json.dumps(metadata, indent=2) + "\n") + print(json.dumps(metadata, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/verification/README.md b/verification/README.md new file mode 100644 index 0000000..c82ca6f --- /dev/null +++ b/verification/README.md @@ -0,0 +1,99 @@ +# Published-artifact verification + +This standalone consumer proves the packaged `gbeewa` API. It is deliberately absent from the root build: it has its own settings, repositories, Android activity, and platform tests. The same consumer supplies Android restoration/ownership tests and Maven publication proof. + +## Commands + +Run from the repository root with JDK 17 and an installed Android SDK. Set `JAVA_HOME` to your detected JDK 17 directory and `ANDROID_HOME` or `ANDROID_SDK_ROOT` to your SDK. When no SDK variable is provided, the runner detects an installed SDK 36 at the standard macOS or Linux user SDK location. It reads no configuration file to do this. No `local.properties` file is copied or read by the fixture runner. + +```sh +# Configuration only: no publication, compilation, or tests. +python3 tooling/verify-publication.py --phase configure --platform android-unit + +# Inspect the exact local publication task graph without building. +python3 tooling/verify-publication.py --phase plan --platform android-unit + +# Stage the Android publication without running consumer tests. +python3 tooling/verify-publication.py --phase stage --platform android-unit + +# Stage, check provenance and negative controls, then run binary JVM tests. +python3 tooling/verify-publication.py --platform android-unit + +# Include real instrumentation on ONE explicitly selected, owned emulator. +python3 tooling/verify-publication.py --platform android-device --android-serial emulator-5580 + +# macOS: use the UUID of an owned disposable simulator clone. +python3 tooling/verify-publication.py --platform iosSimulatorArm64 --ios-device "$owned_simulator_uuid" +python3 tooling/verify-publication.py --platform iosX64 --ios-device "$owned_simulator_uuid" + +# CI entry point: creates, boots, and deletes its own simulator clone. +python3 verification/apple-ci.py iosSimulatorArm64 +python3 verification/apple-ci.py iosX64 +``` + +Use the architecture matching the host runner. Do not pass the UUID of a user's original simulator. The helper clones an available, shut-down iPhone simulator and deletes only its own clone in `finally`. Local runs never choose a default Android serial or Apple device. The Android script leaves the selected emulator running; its owner controls its lifetime. The CI emulator job owns its process and stops it in its exit trap. + +Every invocation prints its evidence directory under `build/verification//`. `run.json` records the selected lane and outcome. Exact Gradle commands are stored as `*.command.json`; output is in `*.log`. Commands use `--no-parallel --max-workers=1 --no-daemon --no-configuration-cache`. Configuration and publication dry-runs are bounded to five minutes per Gradle invocation; publication and test invocations are bounded to twenty minutes each. A timeout attempts to stop only that run's isolated Gradle daemon registry. + +## Publication and resolution + +Each run copies the current working source into its generated directory, excluding local machine configuration, Git metadata, and build outputs. Finish concurrent library edits before staging: a source copy is not an atomic snapshot of a writer's changes. Root release configuration and tracked `VERSION_NAME` stay unchanged. Generated verification invocations disable configuration-cache reuse because each run has fresh source and its own daemon home. + +The version is `0.0.0-verification.-SNAPSHOT`. The only selected publication tasks are: + +```text +:gbeewa:publishKotlinMultiplatformPublicationToMavenLocal +:gbeewa:publishAndroidReleasePublicationToMavenLocal # Android lanes +:gbeewa:publishIosSimulatorArm64PublicationToMavenLocal # Apple Silicon lane +:gbeewa:publishIosX64PublicationToMavenLocal # Intel lane +``` + +Each invocation passes `-PVERSION_NAME=` and `-Dmaven.repo.local=/maven`. The script accepts no arbitrary task or Gradle arguments. It never calls `publish`, `publishToMavenCentral`, or a remote repository publication task. Signing tasks remain in Gradle's normal task graph. + +The standalone settings use `exclusiveContent` for all `com.quantipixels` modules, with Gradle module metadata as the only metadata source. There is no Maven Local, project dependency, composite build, or repository fallback for this group. Other dependencies use Google and Maven Central. The fixture reads `gradle/libs.versions.toml`; it adds no version matrix. Baseline: Kotlin 2.1.21, Compose Multiplatform 1.8.2 (Android Compose 1.8.2), AGP 8.9.1, Gradle 8.13, JVM 17. Android compile SDK is 36 and minimum SDK is 24; CI uses the API 35 Google APIs x86_64 emulator image. + +After staging, the runner validates actual `.module` coordinates and nonempty variants and records a SHA-256 manifest. + +`verifyStagedArtifacts` resolves both `debugRuntimeClasspath` and `debugAndroidTestRuntimeClasspath` for Android, or the selected native compile klibraries for Apple. The published library must be present in the app runtime or selected native configuration. The instrumentation APK has separate dependencies and drives the tested app, so it need not contain the library. Every `com.quantipixels` artifact found in either Android configuration still receives the same provenance checks. + +The task checks the exact version, published platform coordinates, corresponding staged module metadata, manifest hashes, and resolved artifact bytes against staged files. The report includes selected variant attributes, hashes, and staged paths. Gradle may cache downloaded files elsewhere; matching bytes establish correspondence without assuming the cache path is the Maven path. + +The Android debug consumer selects the published release library using `matchingFallbacks`. Apple lanes select `iosSimulatorArm64` or `iosX64` through actual Gradle metadata. Only those requested platform publications are built. + +## Signing policy + +[Vanniktech 0.35.0 source, `signAllPublications`](https://github.com/vanniktech/gradle-maven-publish-plugin/blob/0.35.0/plugin/src/main/kotlin/com/vanniktech/maven/publish/MavenPublishBaseExtension.kt#L138-L152) sets signing required to `!version.endsWith("-SNAPSHOT")`. This applies to publications signed for Maven Local too. A non-SNAPSHOT fixture therefore needs a signing key. The unique SNAPSHOT version uses the existing policy: no hook, task, signature requirement, or tracked `signAllPublications()` is disabled or changed. + +The runner builds a small allowlist of environment variables for Java/SDK discovery, uses generated Gradle user homes and project directories, and does not inherit signing, Maven Central, Gradle project-property, or Java option variables. It does not inspect a user's Gradle properties, init scripts, keyring, or environment secrets. Repository-local dependency caches and wrapper downloads are reused, while each run has a separate daemon registry. Failure evidence is retained; delete only the owned `build/verification` outputs when no proof is needed. + +## Discriminating failures and test counts + +A successful `verify` run must first resolve the real publication, then reject both negative controls: + +1. An empty staged repository must fail with `Could not find com.quantipixels:gbeewa`. Resolution happens before manifest checking, so a manifest-only error cannot satisfy this control. +2. A copied stage with one altered AAR or KLIB must fail with `Changed staged artifact`. Its original manifest is kept so the altered artifact cannot establish its own expected hash. + +The runner then executes `testDebugUnitTest`, adds `connectedDebugAndroidTest` for the device lane, or executes the selected `iosSimulatorArm64Test`/`iosX64Test`. Gradle test listeners and XML report checks reject zero tests, skipped tests, and failures. The device lane also requires nonzero instrumentation XML results independently of the JVM test count. Fresh generated build directories prevent old test reports from satisfying the checks. + +Expected tests: two common binary tests on each platform; two additional Android instrumentation tests. Negative controls are separate failed Gradle invocations, not skipped tests. + +## Native boundary and limits + +The Android tests use a real `ComponentActivity`, Compose ownership, and `ActivityScenario.recreate()`: + +- Capture a producer callback, replace its workspace owner, and invoke the stale callback. The new store must receive nothing. Publish through the current owner and attempt a second consumption; delivery must occur exactly once. +- Enter a detail route with its transient handoff and assert dependent work began. Recreate the activity so the saved route returns with a fresh in-memory store. It must show the fallback with zero dependent work. + +Navigation and recovery are small application-owned branches. The library gains no registry, discard API, or navigation dependency. Common binary tests distinguish present-null from missing, prove single consumption, and prove separate store ownership on Android's JVM and each selected Apple architecture. + +Recreation proves the real Android save registry boundary, not process death or a particular navigation framework. The common tests do not prove native UI ownership by themselves. + +## CI evidence + +`.github/workflows/ci.yml` runs the Apple artifact consumers on its existing runners after the existing checks. `.github/workflows/verification.yml` runs the Android instrumentation lane on pull requests or manual dispatch; new runs on the same workflow and ref cancel earlier in-progress runs. Existing Apple checks and runner versions are preserved. + +CI uploads `gbeewa--publication-evidence` from the Apple jobs and `gbeewa-android-publication-evidence` from the Android workflow, including on failure. Find these artifacts in the workflow run's artifact list. They contain command logs, command JSON, `run.json`, the staged SHA-256 manifest, provenance reports, and test reports. Android uploads also include emulator output and instrumentation XML results. + +Interpret `run.json` together with its `phase`: a passed `configure`, `plan`, or `stage` run does not prove consumer tests. A complete `verify` result requires successful artifact resolution, both negative controls failing with their expected markers, and nonzero test reports with no failures or skips. Check the instrumentation reports separately for an Android device run. A dry-run task marked `SKIPPED` proves only task selection, not publication, signing, or test execution. + +Use CI artifacts to assess a specific run. Generated local evidence directories are retained for diagnosis, but are not committed verification records or a standing claim that the tests passed. diff --git a/verification/apple-ci.py b/verification/apple-ci.py new file mode 100644 index 0000000..a0c9d07 --- /dev/null +++ b/verification/apple-ci.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""Run PR verification on a disposable Apple simulator owned by this process.""" +import json +from pathlib import Path +import subprocess +import sys +import uuid + +root = Path(__file__).resolve().parents[1] +target = sys.argv[1] if len(sys.argv) == 2 else "" +if target not in {"iosSimulatorArm64", "iosX64"}: + raise SystemExit("Usage: python3 verification/apple-ci.py iosSimulatorArm64|iosX64") +devices = json.loads(subprocess.check_output(["xcrun", "simctl", "list", "devices", "available", "--json"])) +source = next(device["udid"] for runtime, entries in devices["devices"].items() + if "iOS" in runtime for device in entries + if device.get("isAvailable") and "iPhone" in device["name"] and device["state"] == "Shutdown") +clone = subprocess.check_output(["xcrun", "simctl", "clone", source, "Publication-" + uuid.uuid4().hex], text=True).strip() +try: + subprocess.run(["xcrun", "simctl", "boot", clone], check=True) + subprocess.run(["xcrun", "simctl", "bootstatus", clone, "-b"], check=True) + + subprocess.run([sys.executable, str(root / "tooling/verify-publication.py"), + "--platform", target, "--ios-device", clone], cwd=root, check=True) +finally: + subprocess.run(["xcrun", "simctl", "shutdown", clone], check=False) + subprocess.run(["xcrun", "simctl", "delete", clone], check=True) diff --git a/verification/consumer/build.gradle.kts b/verification/consumer/build.gradle.kts new file mode 100644 index 0000000..2747921 --- /dev/null +++ b/verification/consumer/build.gradle.kts @@ -0,0 +1,147 @@ +import java.security.MessageDigest +import org.gradle.api.artifacts.component.ModuleComponentIdentifier +import org.gradle.api.tasks.testing.AbstractTestTask +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeSimulatorTest + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.application) + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.compose.compiler) +} + +layout.buildDirectory.set(file(providers.gradleProperty("verificationBuildDirectory").get())) +val stagedVersion = providers.gradleProperty("stagedVersion").get() +val stagedRepository = file(providers.gradleProperty("stagedRepository").get()) +val appleTarget = providers.gradleProperty("verificationAppleTarget").orElse("none").get() +require(appleTarget in setOf("none", "iosSimulatorArm64", "iosX64")) + +kotlin { + androidTarget { + compilerOptions.jvmTarget.set(JvmTarget.JVM_17) + } + when (appleTarget) { + "iosSimulatorArm64" -> iosSimulatorArm64() + "iosX64" -> iosX64() + } + sourceSets { + commonMain.dependencies { + implementation("com.quantipixels:gbeewa:$stagedVersion") + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + } + androidMain.dependencies { + implementation(libs.activity.compose) + } + androidInstrumentedTest.dependencies { + implementation(libs.verification.test.runner) + implementation(libs.verification.test.core) + implementation(libs.verification.compose.test) + } + } +} + +android { + namespace = "com.quantipixels.gbeewa.verification" + compileSdk = libs.versions.android.compile.sdk.get().toInt() + defaultConfig { + applicationId = "com.quantipixels.gbeewa.verification" + minSdk = libs.versions.android.min.sdk.get().toInt() + targetSdk = libs.versions.android.compile.sdk.get().toInt() + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + buildTypes.getByName("debug") { matchingFallbacks += "release" } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + testOptions.unitTests.isReturnDefaultValues = true +} + +dependencies { debugImplementation(libs.verification.compose.manifest) } + +// Native tests must use a simulator chosen by the owner, never a user's default device. +tasks.withType().configureEach { + providers.gradleProperty("verificationIosDevice").orNull?.let { device.set(it) } + doFirst { + require(providers.gradleProperty("verificationIosDevice").isPresent) { + "Pass -PverificationIosDevice=" + } + } +} + +tasks.withType().configureEach { + afterSuite(KotlinClosure2({ descriptor, result -> + if (descriptor.parent == null) { + check(result.testCount > 0 && result.skippedTestCount == 0L) { + "Consumer tests must execute nonzero tests with no skips" + } + } + })) +} + +fun sha256(file: File): String = MessageDigest.getInstance("SHA-256") + .digest(file.readBytes()).joinToString("") { "%02x".format(it) } + +// Gradle caches artifacts outside the repository. Compare bytes, not cache path names. +tasks.register("verifyStagedArtifacts") { + group = "verification" + doLast { + val requiredConfigurations = if (appleTarget == "none") { + setOf("debugRuntimeClasspath", "debugAndroidTestRuntimeClasspath") + } else { + setOf("${appleTarget}CompileKlibraries") + } + val selected = configurations.filter { + it.isCanBeResolved && it.name in requiredConfigurations + } + check(selected.map { it.name }.toSet() == requiredConfigurations) { + "Missing resolvable verification configurations: $requiredConfigurations" + } + // Resolve before checking the manifest so an empty repository proves resolution fails. + val resolved = selected.associateWith { it.incoming.artifacts.artifacts } + val manifest = stagedRepository.resolve("staged-files.sha256") + check(manifest.isFile) { "Missing staged publication manifest" } + val expected = manifest.readLines().associate { line -> + val parts = line.split(" ", limit = 2) + check(parts.size == 2) + parts[1] to parts[0] + } + expected.forEach { (path, hash) -> + val stagedFile = stagedRepository.resolve(path) + check(stagedFile.isFile && sha256(stagedFile) == hash) { "Changed staged artifact: $path" } + } + val report = mutableListOf() + resolved.forEach { (configuration, resolvedArtifacts) -> + val artifacts = resolvedArtifacts.filter { + (it.id.componentIdentifier as? ModuleComponentIdentifier)?.group == "com.quantipixels" + } + // The instrumentation APK drives the tested app and has separate dependencies. + // Require the library in the app or native configuration; check provenance in both APKs. + if (configuration.name != "debugAndroidTestRuntimeClasspath") { + check(artifacts.isNotEmpty()) { "No published artifact in ${configuration.name}" } + } + artifacts.forEach { artifact -> + val id = artifact.id.componentIdentifier as ModuleComponentIdentifier + check(id.version == stagedVersion && id.module.startsWith("gbeewa")) { "Wrong artifact: $id" } + val directory = stagedRepository.resolve("com/quantipixels/${id.module}/${id.version}") + check(directory.resolve("${id.module}-${id.version}.module").isFile) { "Missing Gradle metadata: $id" } + val matching = directory.listFiles().orEmpty().filter { + it.isFile && it.extension in setOf("aar", "jar", "klib") && sha256(it) == sha256(artifact.file) + } + check(matching.isNotEmpty()) { "Resolved bytes differ from staged publication: ${artifact.file}" } + report += "${configuration.name}: $id ${artifact.variant.attributes} ${sha256(artifact.file)} ${matching.first()}" + } + } + layout.buildDirectory.file("reports/staged-artifacts.txt").get().asFile.apply { + parentFile.mkdirs() + writeText(report.joinToString("\n", postfix = "\n")) + } + report.forEach(::println) + } +} diff --git a/verification/consumer/gradle.properties b/verification/consumer/gradle.properties new file mode 100644 index 0000000..344ead5 --- /dev/null +++ b/verification/consumer/gradle.properties @@ -0,0 +1,2 @@ +android.useAndroidX=true +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 diff --git a/verification/consumer/settings.gradle.kts b/verification/consumer/settings.gradle.kts new file mode 100644 index 0000000..dafba9f --- /dev/null +++ b/verification/consumer/settings.gradle.kts @@ -0,0 +1,36 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +val stagedRepository = providers.gradleProperty("stagedRepository").orNull + ?: error("Pass -PstagedRepository=") +val stage = file(stagedRepository) +require(stage.isDirectory) { "Staged Maven directory does not exist: $stage" } +require(providers.gradleProperty("stagedVersion").isPresent) { "Pass -PstagedVersion=" } + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + versionCatalogs { + create("libs") { from(files("../../gradle/libs.versions.toml")) } + } + repositories { + exclusiveContent { + forRepository { + maven { + name = "verificationStage" + url = stage.toURI() + metadataSources { gradleMetadata() } + } + } + filter { includeGroup("com.quantipixels") } + } + google() + mavenCentral() + } +} + +rootProject.name = "gbeewa-publication-consumer" diff --git a/verification/consumer/src/androidInstrumentedTest/kotlin/com/quantipixels/gbeewa/verification/OwnershipTest.kt b/verification/consumer/src/androidInstrumentedTest/kotlin/com/quantipixels/gbeewa/verification/OwnershipTest.kt new file mode 100644 index 0000000..0630bba --- /dev/null +++ b/verification/consumer/src/androidInstrumentedTest/kotlin/com/quantipixels/gbeewa/verification/OwnershipTest.kt @@ -0,0 +1,33 @@ +package com.quantipixels.gbeewa.verification + +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import org.junit.Rule +import org.junit.Test + +class OwnershipTest { + @get:Rule val compose = createAndroidComposeRule() + + @Test fun capturedOldOwnerCannotDeliverToReplacementAndCurrentResultIsConsumedOnce() { + compose.waitForIdle() + lateinit var stale: (String) -> Unit + compose.activityRule.scenario.onActivity { stale = it.capturePickerCallback() } + compose.onNodeWithTag("replace-owner").performClick() + compose.waitForIdle() + compose.activityRule.scenario.onActivity { stale("late") } + compose.onNodeWithTag("deliveries").assertTextEquals("deliveries:0") + compose.onNodeWithTag("publish").performClick() + compose.onNodeWithTag("deliveries").assertTextEquals("deliveries:1") + compose.onNodeWithTag("consume-again").performClick() + compose.onNodeWithTag("deliveries").assertTextEquals("deliveries:1") + } + + @Test fun restoredRouteFallsBackBeforeStartingWorkWithoutTransientHandoff() { + compose.onNodeWithTag("open-detail").performClick() + compose.onNodeWithTag("status").assertTextEquals("detail") + compose.onNodeWithTag("work").assertTextEquals("work:1") + compose.activityRule.scenario.recreate() + compose.onNodeWithTag("status").assertTextEquals("fallback") + compose.onNodeWithTag("work").assertTextEquals("work:0") + } +} diff --git a/verification/consumer/src/androidMain/AndroidManifest.xml b/verification/consumer/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..314a286 --- /dev/null +++ b/verification/consumer/src/androidMain/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/verification/consumer/src/androidMain/kotlin/com/quantipixels/gbeewa/verification/VerificationActivity.kt b/verification/consumer/src/androidMain/kotlin/com/quantipixels/gbeewa/verification/VerificationActivity.kt new file mode 100644 index 0000000..8ca1d34 --- /dev/null +++ b/verification/consumer/src/androidMain/kotlin/com/quantipixels/gbeewa/verification/VerificationActivity.kt @@ -0,0 +1,66 @@ +package com.quantipixels.gbeewa.verification + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import com.quantipixels.gbeewa.* + +private object Pick : ResultKey +private object Handoff : ResultKey + +class VerificationActivity : ComponentActivity() { + private lateinit var currentStore: ResultStore + fun capturePickerCallback(): (String) -> Unit { + val owner = currentStore + return { owner.put(Pick, it) } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + var workspace by remember { mutableIntStateOf(0) } + var route by rememberSaveable { mutableStateOf("home") } + var deliveries by remember { mutableIntStateOf(0) } + var dependentWork by remember { mutableIntStateOf(0) } + var status by remember { mutableStateOf("home") } + val store = remember(workspace) { ResultStore() } + val consumer = remember(store) { ResultConsumer(store) } + SideEffect { currentStore = store } + CompositionLocalProvider(LocalResultConsumer provides consumer) { + ResultEffect(Pick) { deliveries++ } + } + if (route == "detail") { + LaunchedEffect(consumer, route) { + when (consumer.consume(Handoff)) { + PendingResult.Missing -> status = "fallback" + is PendingResult.Value -> { + dependentWork++ + status = "detail" + } + } + } + } + Column { + Button(onClick = { workspace++ }, Modifier.testTag("replace-owner")) { Text("Replace workspace") } + Button(onClick = { store.put(Pick, "current") }, Modifier.testTag("publish")) { Text("Publish") } + Button(onClick = { + store.put(Handoff, "required-input") + route = "detail" + }, Modifier.testTag("open-detail")) { Text("Open detail") } + Button(onClick = { consumer.consume(Pick) { deliveries++ } }, Modifier.testTag("consume-again")) { + Text("Consume again") + } + Text("deliveries:$deliveries", Modifier.testTag("deliveries")) + Text("work:$dependentWork", Modifier.testTag("work")) + Text(status, Modifier.testTag("status")) + } + } + } +} diff --git a/verification/consumer/src/commonTest/kotlin/com/quantipixels/gbeewa/verification/PublishedApiTest.kt b/verification/consumer/src/commonTest/kotlin/com/quantipixels/gbeewa/verification/PublishedApiTest.kt new file mode 100644 index 0000000..0e158d3 --- /dev/null +++ b/verification/consumer/src/commonTest/kotlin/com/quantipixels/gbeewa/verification/PublishedApiTest.kt @@ -0,0 +1,26 @@ +package com.quantipixels.gbeewa.verification + +import com.quantipixels.gbeewa.* +import kotlin.test.* + +class PublishedApiTest { + private object Key : ResultKey + + @Test fun publishedStoreConsumesPresentNullExactlyOnce() { + val store = ResultStore() + val consumer = ResultConsumer(store) + store.put(Key, null) + assertTrue(Key in consumer) + assertEquals(PendingResult.Value(null), consumer.consume(Key)) + assertEquals(PendingResult.Missing, consumer.consume(Key)) + } + + @Test fun oldOwnerRemainsSeparateFromNewOwner() { + val oldStore = ResultStore() + val newStore = ResultStore() + val lateCallback: () -> Unit = { oldStore.put(Key, "late") } + lateCallback() + assertEquals(PendingResult.Missing, ResultConsumer(newStore).consume(Key)) + assertEquals(PendingResult.Value("late"), ResultConsumer(oldStore).consume(Key)) + } +} From 82ae7696713635143c80d5da905e7c717eaa9aea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=8EOl=C3=BAw=C3=A1=E1=B9=A3eun=20=E1=B9=A2=C3=B3b?= =?UTF-8?q?=C3=A1nd=C3=A9?= Date: Mon, 26 Oct 2026 18:49:21 +0000 Subject: [PATCH 2/2] Limit result lifetime changes to documentation --- .github/workflows/ci.yml | 32 --- .github/workflows/verification.yml | 59 ----- README.md | 14 +- .../quantipixels/gbeewa/ResultEffectTest.kt | 47 ---- .../quantipixels/gbeewa/ResultStoreTest.kt | 18 -- gradle/libs.versions.toml | 4 - tooling/verify-publication.py | 203 ------------------ verification/README.md | 99 --------- verification/apple-ci.py | 26 --- verification/consumer/build.gradle.kts | 147 ------------- verification/consumer/gradle.properties | 2 - verification/consumer/settings.gradle.kts | 36 ---- .../gbeewa/verification/OwnershipTest.kt | 33 --- .../src/androidMain/AndroidManifest.xml | 5 - .../verification/VerificationActivity.kt | 66 ------ .../gbeewa/verification/PublishedApiTest.kt | 26 --- 16 files changed, 4 insertions(+), 813 deletions(-) delete mode 100644 .github/workflows/verification.yml delete mode 100755 tooling/verify-publication.py delete mode 100644 verification/README.md delete mode 100644 verification/apple-ci.py delete mode 100644 verification/consumer/build.gradle.kts delete mode 100644 verification/consumer/gradle.properties delete mode 100644 verification/consumer/settings.gradle.kts delete mode 100644 verification/consumer/src/androidInstrumentedTest/kotlin/com/quantipixels/gbeewa/verification/OwnershipTest.kt delete mode 100644 verification/consumer/src/androidMain/AndroidManifest.xml delete mode 100644 verification/consumer/src/androidMain/kotlin/com/quantipixels/gbeewa/verification/VerificationActivity.kt delete mode 100644 verification/consumer/src/commonTest/kotlin/com/quantipixels/gbeewa/verification/PublishedApiTest.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9633d56..b993502 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,22 +49,6 @@ jobs: CODE_SIGNING_ALLOWED=NO \ build - - name: Verify staged artifact on an owned simulator - run: python3 verification/apple-ci.py iosSimulatorArm64 - - - name: Preserve publication consumer evidence - if: always() - uses: actions/upload-artifact@v4 - with: - name: gbeewa-iosSimulatorArm64-publication-evidence - path: | - build/verification/*/*.log - build/verification/*/*.json - build/verification/*/maven/staged-files.sha256 - build/verification/*/consumer-build/reports/ - build/verification/*/consumer-build/test-results/ - if-no-files-found: warn - check-x64: runs-on: macos-15-intel steps: @@ -87,19 +71,3 @@ jobs: :samples:composeApp:iosX64Test \ :samples:composeApp:linkDebugFrameworkIosX64 \ --stacktrace - - - name: Verify staged artifact on an owned simulator - run: python3 verification/apple-ci.py iosX64 - - - name: Preserve publication consumer evidence - if: always() - uses: actions/upload-artifact@v4 - with: - name: gbeewa-iosX64-publication-evidence - path: | - build/verification/*/*.log - build/verification/*/*.json - build/verification/*/maven/staged-files.sha256 - build/verification/*/consumer-build/reports/ - build/verification/*/consumer-build/test-results/ - if-no-files-found: warn diff --git a/.github/workflows/verification.yml b/.github/workflows/verification.yml deleted file mode 100644 index 615bdaf..0000000 --- a/.github/workflows/verification.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Published artifact verification - -on: - pull_request: - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - android-consumer: - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@v5 - - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: 17 - - uses: gradle/actions/setup-gradle@v5 - - name: Install the pinned Android test platform - run: | - "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-36" "system-images;android-35;google_apis;x86_64" - echo no | "$ANDROID_HOME/cmdline-tools/latest/bin/avdmanager" create avd --name publication-verification --package "system-images;android-35;google_apis;x86_64" - - name: Run the packaged Android fixture - shell: bash - run: | - set -euo pipefail - mkdir -p build/verification - if [[ -e /dev/kvm ]]; then sudo chmod a+rw /dev/kvm; fi - "$ANDROID_HOME/emulator/emulator" -avd publication-verification -port 5580 -no-window -no-audio -no-snapshot -gpu swiftshader_indirect > build/verification/emulator.log 2>&1 & - emulator_pid=$! - trap '"$ANDROID_HOME/platform-tools/adb" -s emulator-5580 emu kill || true; wait "$emulator_pid" || true' EXIT - timeout 180 "$ANDROID_HOME/platform-tools/adb" -s emulator-5580 wait-for-device - booted=false - for attempt in {1..90}; do - if [[ "$("$ANDROID_HOME/platform-tools/adb" -s emulator-5580 shell getprop sys.boot_completed | tr -d '\r')" == 1 ]]; then booted=true; break; fi - sleep 2 - done - [[ "$booted" == true ]] - python3 tooling/verify-publication.py --platform android-device --android-serial emulator-5580 - - name: Preserve publication and test evidence - if: always() - uses: actions/upload-artifact@v4 - with: - name: gbeewa-android-publication-evidence - path: | - build/verification/emulator.log - build/verification/*/*.log - build/verification/*/*.json - build/verification/*/maven/staged-files.sha256 - build/verification/*/consumer-build/reports/ - build/verification/*/consumer-build/test-results/ - build/verification/*/consumer-build/outputs/androidTest-results/ - if-no-files-found: warn diff --git a/README.md b/README.md index b68d2d3..d3cb6db 100644 --- a/README.md +++ b/README.md @@ -485,16 +485,10 @@ the key has no pending result. ### Same-key publication inside a callback -Keep the restriction against publishing for the same key inside its result callback. The focused -Compose runtime experiment in `ResultEffectTest` republishes both a different payload and an equal -payload while the receiver stays mounted. In each case, the replacement stays pending through -recomposition and is delivered after the receiver leaves and re-enters composition. Publishing -twice in the callback retains only the latest pending value. Removal still happens before each -callback, and competing consumers still share one pending result. - -This characterizes the documented limitation; it adds no queue, automatic redelivery, or retry -guarantee. Support for callback republishing would change observable behavior and needs a separate -decision. +Do not publish another value for the same key from inside its result callback. The replacement, +including an equal payload, can remain pending until the receiver leaves and re-enters composition. +Publishing twice retains only the latest pending value. There is no queue, automatic redelivery, +or retry guarantee. ## Contributing diff --git a/gbeewa/src/commonTest/kotlin/com/quantipixels/gbeewa/ResultEffectTest.kt b/gbeewa/src/commonTest/kotlin/com/quantipixels/gbeewa/ResultEffectTest.kt index 981357c..d14bdb9 100644 --- a/gbeewa/src/commonTest/kotlin/com/quantipixels/gbeewa/ResultEffectTest.kt +++ b/gbeewa/src/commonTest/kotlin/com/quantipixels/gbeewa/ResultEffectTest.kt @@ -84,53 +84,6 @@ class ResultEffectTest { assertEquals(listOf("mounted"), delivered) } - @Test - fun `same-key callback publications stay pending until remount including equal payloads`() = - runCompositionTest { - for (replacement in listOf("different", "initial")) { - val store = ResultStore() - val delivered = mutableListOf() - val mounted = mutableStateOf(true) - val recomposition = mutableStateOf(0) - var observedRecomposition = -1 - setContent(store) { - observedRecomposition = recomposition.value - if (mounted.value) { - ResultEffect(StringKey) { value -> - assertFalse(StringKey in store) - delivered += value - if (delivered.size == 1) { - store.put(StringKey, "superseded") - store.put(StringKey, replacement) - } - } - } - } - settle() - - store.put(StringKey, "initial") - settle() - recomposition.value += 1 - settle() - - assertEquals(1, observedRecomposition) - assertEquals(listOf("initial"), delivered) - assertTrue(StringKey in store) - - mounted.value = false - settle() - assertTrue(StringKey in store) - mounted.value = true - settle() - settle() - - assertEquals(listOf("initial", replacement), delivered) - assertFalse(StringKey in store) - setContent {} - settle() - } - } - @Test fun `pending effect uses callback replaced before its coroutine runs`() = runCompositionTest { val state = ResultStore() diff --git a/gbeewa/src/commonTest/kotlin/com/quantipixels/gbeewa/ResultStoreTest.kt b/gbeewa/src/commonTest/kotlin/com/quantipixels/gbeewa/ResultStoreTest.kt index 2ed8e1e..e057973 100644 --- a/gbeewa/src/commonTest/kotlin/com/quantipixels/gbeewa/ResultStoreTest.kt +++ b/gbeewa/src/commonTest/kotlin/com/quantipixels/gbeewa/ResultStoreTest.kt @@ -41,24 +41,6 @@ class ResultStoreTest { assertEquals(PendingResult.Missing, consumer.consume(NullableKey)) } - @Test - fun `consume-only cleanup removes a nullable request without callbacks or affecting another lane`() { - val store = ResultStore() - val consumer = ResultConsumer(store) - val delivered = mutableListOf() - store.put(NullableKey, null) - store.put(CountryKey, "NG") - - assertEquals(PendingResult.Value(null), consumer.consume(NullableKey)) - consumer.consume(NullableKey, delivered::add) - - assertTrue(delivered.isEmpty()) - assertEquals(PendingResult.Missing, consumer.consume(NullableKey)) - assertTrue(CountryKey in consumer) - consumer.consume(CountryKey, delivered::add) - assertEquals(listOf("NG"), delivered) - } - @Test fun `direct consumption returns the latest result for the same key`() { val store = ResultStore() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8373649..243b4f7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,10 +15,6 @@ activity-compose = "1.10.1" vanniktech-maven-publish = "0.35.0" [libraries] -verification-compose-test = { module = "androidx.compose.ui:ui-test-junit4", version = "1.8.2" } -verification-compose-manifest = { module = "androidx.compose.ui:ui-test-manifest", version = "1.8.2" } -verification-test-runner = { module = "androidx.test:runner", version = "1.6.2" } -verification-test-core = { module = "androidx.test:core", version = "1.6.1" } decompose = { module = "com.arkivanov.decompose:decompose", version.ref = "decompose" } decompose-compose = { module = "com.arkivanov.decompose:extensions-compose", version.ref = "decompose" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } diff --git a/tooling/verify-publication.py b/tooling/verify-publication.py deleted file mode 100755 index fe432c2..0000000 --- a/tooling/verify-publication.py +++ /dev/null @@ -1,203 +0,0 @@ -#!/usr/bin/env python3 -"""Stage a unique local publication and run its independent binary consumer.""" -import argparse -import hashlib -import json -import os -from pathlib import Path -import platform -import shutil -import subprocess -import sys -import uuid -import xml.etree.ElementTree as ET - -ROOT = Path(__file__).resolve().parents[1] -LIBRARY = "gbeewa" - - -def digest(path): - return hashlib.sha256(path.read_bytes()).hexdigest() - - -def source_copy(destination): - # Copy current tracked and untracked source, never local machine configuration. - listing = subprocess.check_output( - ["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z"], cwd=ROOT - ).decode().split("\0") - for name in listing: - relative = Path(name) - if not name or relative.parts[0] in {"verification", "tooling", ".alarina"}: - continue - if relative.name == "local.properties" or relative.name.startswith(".env"): - continue - source = ROOT / relative - if source.is_file() and not source.is_symlink(): - target = destination / relative - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(source, target) - - -def publication_manifest(stage, version): - modules = list(stage.glob("com/quantipixels/*/" + version + "/*.module")) - if not modules: - raise RuntimeError("No Gradle module metadata was published") - for module in modules: - metadata = json.loads(module.read_text()) - component = metadata["component"] - if component.get("group") != "com.quantipixels" or component.get("version") != version: - raise RuntimeError("Unexpected publication coordinates: " + str(module)) - if not component["module"].startswith(LIBRARY) or not metadata.get("variants"): - raise RuntimeError("Unexpected publication or empty variants: " + str(module)) - files = sorted(p for p in stage.rglob("*") if p.is_file()) - (stage / "staged-files.sha256").write_text("".join( - digest(p) + " " + p.relative_to(stage).as_posix() + "\n" for p in files - )) - - -def nonzero_reports(directory): - reports = list(directory.rglob("TEST-*.xml")) - tests = failures = skipped = 0 - for report in reports: - node = ET.parse(report).getroot() - suites = [node] if node.tag == "testsuite" else node.findall("testsuite") - for suite in suites: - tests += int(suite.get("tests", 0)) - failures += int(suite.get("failures", 0)) + int(suite.get("errors", 0)) - skipped += int(suite.get("skipped", 0)) - if tests == 0 or failures or skipped: - raise RuntimeError(f"Invalid consumer result: tests={tests}, failures={failures}, skipped={skipped}") - return tests - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--phase", choices=["configure", "plan", "stage", "verify"], default="verify") - parser.add_argument("--platform", choices=["android-unit", "android-device", "iosSimulatorArm64", "iosX64"], required=True) - parser.add_argument("--android-serial") - parser.add_argument("--ios-device", help="UUID of an owner-created disposable simulator") - args = parser.parse_args() - apple = args.platform.startswith("ios") - if apple and platform.system() != "Darwin": - parser.error("Apple publication and tests require macOS") - if args.phase == "verify" and apple and not args.ios_device: - parser.error("--ios-device must identify an owned simulator clone") - if args.phase == "verify" and args.platform == "android-device" and not args.android_serial: - parser.error("--android-serial is required; no default device is selected") - - run = ROOT / "build/verification" / uuid.uuid4().hex - run.mkdir(parents=True) - stage = run / "maven" - stage.mkdir() - version = "0.0.0-verification." + run.name + "-SNAPSHOT" - source = run / "source" - source_copy(source) - consumer = run / "consumer" - shutil.copytree(ROOT / "verification/consumer", consumer) - # The catalog stays the single versions source in both the checked-in and generated fixture. - (run / "gradle").mkdir() - shutil.copy2(ROOT / "gradle/libs.versions.toml", run / "gradle/libs.versions.toml") - settings = consumer / "settings.gradle.kts" - settings.write_text(settings.read_text().replace("../../gradle/libs.versions.toml", "../gradle/libs.versions.toml")) - - # No inherited Gradle properties, signing credentials, init scripts, or Java option injection. - environment = {key: os.environ[key] for key in ( - "PATH", "JAVA_HOME", "ANDROID_HOME", "ANDROID_SDK_ROOT", "SYSTEMROOT", "TMPDIR" - ) if key in os.environ} - if not environment.get("ANDROID_HOME") and not environment.get("ANDROID_SDK_ROOT"): - candidates = [Path.home() / "Library/Android/sdk", Path.home() / "Android/Sdk"] - sdk = next((path for path in candidates if (path / "platforms/android-36/android.jar").is_file()), None) - if sdk: - environment["ANDROID_HOME"] = str(sdk) - environment["KONAN_DATA_DIR"] = str(ROOT / "build/verification/konan") - environment["HOME"] = str(run / "home") - environment["GRADLE_USER_HOME"] = str(run / "gradle-user-home") - Path(environment["HOME"]).mkdir() - Path(environment["GRADLE_USER_HOME"]).mkdir(exist_ok=True) - for name in ("caches", "wrapper"): - cache = ROOT / "build/verification/gradle-user-home" / name - cache.mkdir(parents=True, exist_ok=True) - (Path(environment["GRADLE_USER_HOME"]) / name).symlink_to(cache, target_is_directory=True) - if args.android_serial: - environment["ANDROID_SERIAL"] = args.android_serial - metadata = {"library": LIBRARY, "version": version, "platform": args.platform, - "phase": args.phase, "stage": str(stage), "state": "running"} - record = run / "run.json" - record.write_text(json.dumps(metadata, indent=2) + "\n") - print("Evidence: " + str(run), flush=True) - - common = ["--no-daemon", "--no-configuration-cache", "--no-parallel", "--max-workers=1", "--console=plain", "--stacktrace"] - wrapper = str(source / "gradlew") - def execute(label, directory, tasks, properties=(), expected_failure=False, expected_marker=None): - command = [wrapper, "-p", str(directory), *common, *tasks, *properties] - (run / (label + ".command.json")).write_text(json.dumps(command, indent=2) + "\n") - with (run / (label + ".log")).open("w") as output: - try: - result = subprocess.run(command, cwd=directory, env=environment, - stdout=output, stderr=subprocess.STDOUT, - timeout=300 if args.phase in {"configure", "plan"} else 1200) - except (subprocess.TimeoutExpired, KeyboardInterrupt): - # This run has its own daemon registry; do not stop another user's Gradle work. - subprocess.run([wrapper, "--stop"], cwd=directory, env=environment, - stdout=output, stderr=subprocess.STDOUT, timeout=60) - raise - if (result.returncode != 0) != expected_failure: - raise RuntimeError(f"{label}: unexpected exit {result.returncode}; see {label}.log") - if expected_marker and expected_marker not in (run / (label + ".log")).read_text(): - raise RuntimeError(f"{label}: failed for a different reason; inspect its log") - print(f"{label}: exit {result.returncode}", flush=True) - - properties = ["-PstagedVersion=" + version, "-PstagedRepository=" + str(stage), - "-PverificationBuildDirectory=" + str(run / "consumer-build"), - "-PverificationAppleTarget=" + (args.platform if apple else "none")] - if args.ios_device: - properties += ["-PverificationIosDevice=" + args.ios_device] - try: - if args.phase == "configure": - execute("configure", consumer, ["help"], properties) - else: - # Exact local tasks only. Never accept arbitrary task names or remote publishing tasks. - target = {"iosSimulatorArm64": "IosSimulatorArm64", "iosX64": "IosX64"}.get(args.platform, "AndroidRelease") - tasks = [f":{LIBRARY}:publishKotlinMultiplatformPublicationToMavenLocal", - f":{LIBRARY}:publish{target}PublicationToMavenLocal"] - if args.phase == "plan": - tasks += ["--dry-run"] - execute("publication-plan" if args.phase == "plan" else "stage", source, tasks, - ["-PVERSION_NAME=" + version, "-Dmaven.repo.local=" + str(stage)]) - if args.phase != "plan": - publication_manifest(stage, version) - if args.phase == "verify": - execute("artifact-resolution", consumer, ["verifyStagedArtifacts"], properties) - empty = run / "empty-maven" - empty.mkdir() - negative = [p for p in properties if not p.startswith("-PstagedRepository=")] - execute("empty-stage-must-fail", consumer, ["verifyStagedArtifacts"], - [*negative, "-PstagedRepository=" + str(empty)], expected_failure=True, - expected_marker=f"Could not find com.quantipixels:{LIBRARY}") - wrong = run / "wrong-maven" - shutil.copytree(stage, wrong) - artifact = next(p for p in wrong.rglob("*") if p.suffix in {".aar", ".klib"}) - with artifact.open("ab") as output: - output.write(b"verification-negative-control") - execute("wrong-artifact-must-fail", consumer, ["verifyStagedArtifacts"], - [*negative, "-PstagedRepository=" + str(wrong)], expected_failure=True, - expected_marker="Changed staged artifact") - tests = [args.platform + "Test"] if apple else ["testDebugUnitTest"] - if args.platform == "android-device": - tests += ["connectedDebugAndroidTest"] - execute("consumer-tests", consumer, tests, properties) - metadata["executedTests"] = nonzero_reports(run / "consumer-build") - if args.platform == "android-device": - metadata["instrumentedTests"] = nonzero_reports(run / "consumer-build/outputs/androidTest-results") - metadata["state"] = "passed" - except (Exception, KeyboardInterrupt) as error: - metadata["state"] = "failed" - metadata["error"] = str(error) - raise - finally: - record.write_text(json.dumps(metadata, indent=2) + "\n") - print(json.dumps(metadata, indent=2)) - - -if __name__ == "__main__": - main() diff --git a/verification/README.md b/verification/README.md deleted file mode 100644 index c82ca6f..0000000 --- a/verification/README.md +++ /dev/null @@ -1,99 +0,0 @@ -# Published-artifact verification - -This standalone consumer proves the packaged `gbeewa` API. It is deliberately absent from the root build: it has its own settings, repositories, Android activity, and platform tests. The same consumer supplies Android restoration/ownership tests and Maven publication proof. - -## Commands - -Run from the repository root with JDK 17 and an installed Android SDK. Set `JAVA_HOME` to your detected JDK 17 directory and `ANDROID_HOME` or `ANDROID_SDK_ROOT` to your SDK. When no SDK variable is provided, the runner detects an installed SDK 36 at the standard macOS or Linux user SDK location. It reads no configuration file to do this. No `local.properties` file is copied or read by the fixture runner. - -```sh -# Configuration only: no publication, compilation, or tests. -python3 tooling/verify-publication.py --phase configure --platform android-unit - -# Inspect the exact local publication task graph without building. -python3 tooling/verify-publication.py --phase plan --platform android-unit - -# Stage the Android publication without running consumer tests. -python3 tooling/verify-publication.py --phase stage --platform android-unit - -# Stage, check provenance and negative controls, then run binary JVM tests. -python3 tooling/verify-publication.py --platform android-unit - -# Include real instrumentation on ONE explicitly selected, owned emulator. -python3 tooling/verify-publication.py --platform android-device --android-serial emulator-5580 - -# macOS: use the UUID of an owned disposable simulator clone. -python3 tooling/verify-publication.py --platform iosSimulatorArm64 --ios-device "$owned_simulator_uuid" -python3 tooling/verify-publication.py --platform iosX64 --ios-device "$owned_simulator_uuid" - -# CI entry point: creates, boots, and deletes its own simulator clone. -python3 verification/apple-ci.py iosSimulatorArm64 -python3 verification/apple-ci.py iosX64 -``` - -Use the architecture matching the host runner. Do not pass the UUID of a user's original simulator. The helper clones an available, shut-down iPhone simulator and deletes only its own clone in `finally`. Local runs never choose a default Android serial or Apple device. The Android script leaves the selected emulator running; its owner controls its lifetime. The CI emulator job owns its process and stops it in its exit trap. - -Every invocation prints its evidence directory under `build/verification//`. `run.json` records the selected lane and outcome. Exact Gradle commands are stored as `*.command.json`; output is in `*.log`. Commands use `--no-parallel --max-workers=1 --no-daemon --no-configuration-cache`. Configuration and publication dry-runs are bounded to five minutes per Gradle invocation; publication and test invocations are bounded to twenty minutes each. A timeout attempts to stop only that run's isolated Gradle daemon registry. - -## Publication and resolution - -Each run copies the current working source into its generated directory, excluding local machine configuration, Git metadata, and build outputs. Finish concurrent library edits before staging: a source copy is not an atomic snapshot of a writer's changes. Root release configuration and tracked `VERSION_NAME` stay unchanged. Generated verification invocations disable configuration-cache reuse because each run has fresh source and its own daemon home. - -The version is `0.0.0-verification.-SNAPSHOT`. The only selected publication tasks are: - -```text -:gbeewa:publishKotlinMultiplatformPublicationToMavenLocal -:gbeewa:publishAndroidReleasePublicationToMavenLocal # Android lanes -:gbeewa:publishIosSimulatorArm64PublicationToMavenLocal # Apple Silicon lane -:gbeewa:publishIosX64PublicationToMavenLocal # Intel lane -``` - -Each invocation passes `-PVERSION_NAME=` and `-Dmaven.repo.local=/maven`. The script accepts no arbitrary task or Gradle arguments. It never calls `publish`, `publishToMavenCentral`, or a remote repository publication task. Signing tasks remain in Gradle's normal task graph. - -The standalone settings use `exclusiveContent` for all `com.quantipixels` modules, with Gradle module metadata as the only metadata source. There is no Maven Local, project dependency, composite build, or repository fallback for this group. Other dependencies use Google and Maven Central. The fixture reads `gradle/libs.versions.toml`; it adds no version matrix. Baseline: Kotlin 2.1.21, Compose Multiplatform 1.8.2 (Android Compose 1.8.2), AGP 8.9.1, Gradle 8.13, JVM 17. Android compile SDK is 36 and minimum SDK is 24; CI uses the API 35 Google APIs x86_64 emulator image. - -After staging, the runner validates actual `.module` coordinates and nonempty variants and records a SHA-256 manifest. - -`verifyStagedArtifacts` resolves both `debugRuntimeClasspath` and `debugAndroidTestRuntimeClasspath` for Android, or the selected native compile klibraries for Apple. The published library must be present in the app runtime or selected native configuration. The instrumentation APK has separate dependencies and drives the tested app, so it need not contain the library. Every `com.quantipixels` artifact found in either Android configuration still receives the same provenance checks. - -The task checks the exact version, published platform coordinates, corresponding staged module metadata, manifest hashes, and resolved artifact bytes against staged files. The report includes selected variant attributes, hashes, and staged paths. Gradle may cache downloaded files elsewhere; matching bytes establish correspondence without assuming the cache path is the Maven path. - -The Android debug consumer selects the published release library using `matchingFallbacks`. Apple lanes select `iosSimulatorArm64` or `iosX64` through actual Gradle metadata. Only those requested platform publications are built. - -## Signing policy - -[Vanniktech 0.35.0 source, `signAllPublications`](https://github.com/vanniktech/gradle-maven-publish-plugin/blob/0.35.0/plugin/src/main/kotlin/com/vanniktech/maven/publish/MavenPublishBaseExtension.kt#L138-L152) sets signing required to `!version.endsWith("-SNAPSHOT")`. This applies to publications signed for Maven Local too. A non-SNAPSHOT fixture therefore needs a signing key. The unique SNAPSHOT version uses the existing policy: no hook, task, signature requirement, or tracked `signAllPublications()` is disabled or changed. - -The runner builds a small allowlist of environment variables for Java/SDK discovery, uses generated Gradle user homes and project directories, and does not inherit signing, Maven Central, Gradle project-property, or Java option variables. It does not inspect a user's Gradle properties, init scripts, keyring, or environment secrets. Repository-local dependency caches and wrapper downloads are reused, while each run has a separate daemon registry. Failure evidence is retained; delete only the owned `build/verification` outputs when no proof is needed. - -## Discriminating failures and test counts - -A successful `verify` run must first resolve the real publication, then reject both negative controls: - -1. An empty staged repository must fail with `Could not find com.quantipixels:gbeewa`. Resolution happens before manifest checking, so a manifest-only error cannot satisfy this control. -2. A copied stage with one altered AAR or KLIB must fail with `Changed staged artifact`. Its original manifest is kept so the altered artifact cannot establish its own expected hash. - -The runner then executes `testDebugUnitTest`, adds `connectedDebugAndroidTest` for the device lane, or executes the selected `iosSimulatorArm64Test`/`iosX64Test`. Gradle test listeners and XML report checks reject zero tests, skipped tests, and failures. The device lane also requires nonzero instrumentation XML results independently of the JVM test count. Fresh generated build directories prevent old test reports from satisfying the checks. - -Expected tests: two common binary tests on each platform; two additional Android instrumentation tests. Negative controls are separate failed Gradle invocations, not skipped tests. - -## Native boundary and limits - -The Android tests use a real `ComponentActivity`, Compose ownership, and `ActivityScenario.recreate()`: - -- Capture a producer callback, replace its workspace owner, and invoke the stale callback. The new store must receive nothing. Publish through the current owner and attempt a second consumption; delivery must occur exactly once. -- Enter a detail route with its transient handoff and assert dependent work began. Recreate the activity so the saved route returns with a fresh in-memory store. It must show the fallback with zero dependent work. - -Navigation and recovery are small application-owned branches. The library gains no registry, discard API, or navigation dependency. Common binary tests distinguish present-null from missing, prove single consumption, and prove separate store ownership on Android's JVM and each selected Apple architecture. - -Recreation proves the real Android save registry boundary, not process death or a particular navigation framework. The common tests do not prove native UI ownership by themselves. - -## CI evidence - -`.github/workflows/ci.yml` runs the Apple artifact consumers on its existing runners after the existing checks. `.github/workflows/verification.yml` runs the Android instrumentation lane on pull requests or manual dispatch; new runs on the same workflow and ref cancel earlier in-progress runs. Existing Apple checks and runner versions are preserved. - -CI uploads `gbeewa--publication-evidence` from the Apple jobs and `gbeewa-android-publication-evidence` from the Android workflow, including on failure. Find these artifacts in the workflow run's artifact list. They contain command logs, command JSON, `run.json`, the staged SHA-256 manifest, provenance reports, and test reports. Android uploads also include emulator output and instrumentation XML results. - -Interpret `run.json` together with its `phase`: a passed `configure`, `plan`, or `stage` run does not prove consumer tests. A complete `verify` result requires successful artifact resolution, both negative controls failing with their expected markers, and nonzero test reports with no failures or skips. Check the instrumentation reports separately for an Android device run. A dry-run task marked `SKIPPED` proves only task selection, not publication, signing, or test execution. - -Use CI artifacts to assess a specific run. Generated local evidence directories are retained for diagnosis, but are not committed verification records or a standing claim that the tests passed. diff --git a/verification/apple-ci.py b/verification/apple-ci.py deleted file mode 100644 index a0c9d07..0000000 --- a/verification/apple-ci.py +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env python3 -"""Run PR verification on a disposable Apple simulator owned by this process.""" -import json -from pathlib import Path -import subprocess -import sys -import uuid - -root = Path(__file__).resolve().parents[1] -target = sys.argv[1] if len(sys.argv) == 2 else "" -if target not in {"iosSimulatorArm64", "iosX64"}: - raise SystemExit("Usage: python3 verification/apple-ci.py iosSimulatorArm64|iosX64") -devices = json.loads(subprocess.check_output(["xcrun", "simctl", "list", "devices", "available", "--json"])) -source = next(device["udid"] for runtime, entries in devices["devices"].items() - if "iOS" in runtime for device in entries - if device.get("isAvailable") and "iPhone" in device["name"] and device["state"] == "Shutdown") -clone = subprocess.check_output(["xcrun", "simctl", "clone", source, "Publication-" + uuid.uuid4().hex], text=True).strip() -try: - subprocess.run(["xcrun", "simctl", "boot", clone], check=True) - subprocess.run(["xcrun", "simctl", "bootstatus", clone, "-b"], check=True) - - subprocess.run([sys.executable, str(root / "tooling/verify-publication.py"), - "--platform", target, "--ios-device", clone], cwd=root, check=True) -finally: - subprocess.run(["xcrun", "simctl", "shutdown", clone], check=False) - subprocess.run(["xcrun", "simctl", "delete", clone], check=True) diff --git a/verification/consumer/build.gradle.kts b/verification/consumer/build.gradle.kts deleted file mode 100644 index 2747921..0000000 --- a/verification/consumer/build.gradle.kts +++ /dev/null @@ -1,147 +0,0 @@ -import java.security.MessageDigest -import org.gradle.api.artifacts.component.ModuleComponentIdentifier -import org.gradle.api.tasks.testing.AbstractTestTask -import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeSimulatorTest - -plugins { - alias(libs.plugins.kotlin.multiplatform) - alias(libs.plugins.android.application) - alias(libs.plugins.compose.multiplatform) - alias(libs.plugins.compose.compiler) -} - -layout.buildDirectory.set(file(providers.gradleProperty("verificationBuildDirectory").get())) -val stagedVersion = providers.gradleProperty("stagedVersion").get() -val stagedRepository = file(providers.gradleProperty("stagedRepository").get()) -val appleTarget = providers.gradleProperty("verificationAppleTarget").orElse("none").get() -require(appleTarget in setOf("none", "iosSimulatorArm64", "iosX64")) - -kotlin { - androidTarget { - compilerOptions.jvmTarget.set(JvmTarget.JVM_17) - } - when (appleTarget) { - "iosSimulatorArm64" -> iosSimulatorArm64() - "iosX64" -> iosX64() - } - sourceSets { - commonMain.dependencies { - implementation("com.quantipixels:gbeewa:$stagedVersion") - implementation(compose.runtime) - implementation(compose.foundation) - implementation(compose.material3) - } - commonTest.dependencies { - implementation(libs.kotlin.test) - } - androidMain.dependencies { - implementation(libs.activity.compose) - } - androidInstrumentedTest.dependencies { - implementation(libs.verification.test.runner) - implementation(libs.verification.test.core) - implementation(libs.verification.compose.test) - } - } -} - -android { - namespace = "com.quantipixels.gbeewa.verification" - compileSdk = libs.versions.android.compile.sdk.get().toInt() - defaultConfig { - applicationId = "com.quantipixels.gbeewa.verification" - minSdk = libs.versions.android.min.sdk.get().toInt() - targetSdk = libs.versions.android.compile.sdk.get().toInt() - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - buildTypes.getByName("debug") { matchingFallbacks += "release" } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - testOptions.unitTests.isReturnDefaultValues = true -} - -dependencies { debugImplementation(libs.verification.compose.manifest) } - -// Native tests must use a simulator chosen by the owner, never a user's default device. -tasks.withType().configureEach { - providers.gradleProperty("verificationIosDevice").orNull?.let { device.set(it) } - doFirst { - require(providers.gradleProperty("verificationIosDevice").isPresent) { - "Pass -PverificationIosDevice=" - } - } -} - -tasks.withType().configureEach { - afterSuite(KotlinClosure2({ descriptor, result -> - if (descriptor.parent == null) { - check(result.testCount > 0 && result.skippedTestCount == 0L) { - "Consumer tests must execute nonzero tests with no skips" - } - } - })) -} - -fun sha256(file: File): String = MessageDigest.getInstance("SHA-256") - .digest(file.readBytes()).joinToString("") { "%02x".format(it) } - -// Gradle caches artifacts outside the repository. Compare bytes, not cache path names. -tasks.register("verifyStagedArtifacts") { - group = "verification" - doLast { - val requiredConfigurations = if (appleTarget == "none") { - setOf("debugRuntimeClasspath", "debugAndroidTestRuntimeClasspath") - } else { - setOf("${appleTarget}CompileKlibraries") - } - val selected = configurations.filter { - it.isCanBeResolved && it.name in requiredConfigurations - } - check(selected.map { it.name }.toSet() == requiredConfigurations) { - "Missing resolvable verification configurations: $requiredConfigurations" - } - // Resolve before checking the manifest so an empty repository proves resolution fails. - val resolved = selected.associateWith { it.incoming.artifacts.artifacts } - val manifest = stagedRepository.resolve("staged-files.sha256") - check(manifest.isFile) { "Missing staged publication manifest" } - val expected = manifest.readLines().associate { line -> - val parts = line.split(" ", limit = 2) - check(parts.size == 2) - parts[1] to parts[0] - } - expected.forEach { (path, hash) -> - val stagedFile = stagedRepository.resolve(path) - check(stagedFile.isFile && sha256(stagedFile) == hash) { "Changed staged artifact: $path" } - } - val report = mutableListOf() - resolved.forEach { (configuration, resolvedArtifacts) -> - val artifacts = resolvedArtifacts.filter { - (it.id.componentIdentifier as? ModuleComponentIdentifier)?.group == "com.quantipixels" - } - // The instrumentation APK drives the tested app and has separate dependencies. - // Require the library in the app or native configuration; check provenance in both APKs. - if (configuration.name != "debugAndroidTestRuntimeClasspath") { - check(artifacts.isNotEmpty()) { "No published artifact in ${configuration.name}" } - } - artifacts.forEach { artifact -> - val id = artifact.id.componentIdentifier as ModuleComponentIdentifier - check(id.version == stagedVersion && id.module.startsWith("gbeewa")) { "Wrong artifact: $id" } - val directory = stagedRepository.resolve("com/quantipixels/${id.module}/${id.version}") - check(directory.resolve("${id.module}-${id.version}.module").isFile) { "Missing Gradle metadata: $id" } - val matching = directory.listFiles().orEmpty().filter { - it.isFile && it.extension in setOf("aar", "jar", "klib") && sha256(it) == sha256(artifact.file) - } - check(matching.isNotEmpty()) { "Resolved bytes differ from staged publication: ${artifact.file}" } - report += "${configuration.name}: $id ${artifact.variant.attributes} ${sha256(artifact.file)} ${matching.first()}" - } - } - layout.buildDirectory.file("reports/staged-artifacts.txt").get().asFile.apply { - parentFile.mkdirs() - writeText(report.joinToString("\n", postfix = "\n")) - } - report.forEach(::println) - } -} diff --git a/verification/consumer/gradle.properties b/verification/consumer/gradle.properties deleted file mode 100644 index 344ead5..0000000 --- a/verification/consumer/gradle.properties +++ /dev/null @@ -1,2 +0,0 @@ -android.useAndroidX=true -org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 diff --git a/verification/consumer/settings.gradle.kts b/verification/consumer/settings.gradle.kts deleted file mode 100644 index dafba9f..0000000 --- a/verification/consumer/settings.gradle.kts +++ /dev/null @@ -1,36 +0,0 @@ -pluginManagement { - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -val stagedRepository = providers.gradleProperty("stagedRepository").orNull - ?: error("Pass -PstagedRepository=") -val stage = file(stagedRepository) -require(stage.isDirectory) { "Staged Maven directory does not exist: $stage" } -require(providers.gradleProperty("stagedVersion").isPresent) { "Pass -PstagedVersion=" } - -dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) - versionCatalogs { - create("libs") { from(files("../../gradle/libs.versions.toml")) } - } - repositories { - exclusiveContent { - forRepository { - maven { - name = "verificationStage" - url = stage.toURI() - metadataSources { gradleMetadata() } - } - } - filter { includeGroup("com.quantipixels") } - } - google() - mavenCentral() - } -} - -rootProject.name = "gbeewa-publication-consumer" diff --git a/verification/consumer/src/androidInstrumentedTest/kotlin/com/quantipixels/gbeewa/verification/OwnershipTest.kt b/verification/consumer/src/androidInstrumentedTest/kotlin/com/quantipixels/gbeewa/verification/OwnershipTest.kt deleted file mode 100644 index 0630bba..0000000 --- a/verification/consumer/src/androidInstrumentedTest/kotlin/com/quantipixels/gbeewa/verification/OwnershipTest.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.quantipixels.gbeewa.verification - -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import org.junit.Rule -import org.junit.Test - -class OwnershipTest { - @get:Rule val compose = createAndroidComposeRule() - - @Test fun capturedOldOwnerCannotDeliverToReplacementAndCurrentResultIsConsumedOnce() { - compose.waitForIdle() - lateinit var stale: (String) -> Unit - compose.activityRule.scenario.onActivity { stale = it.capturePickerCallback() } - compose.onNodeWithTag("replace-owner").performClick() - compose.waitForIdle() - compose.activityRule.scenario.onActivity { stale("late") } - compose.onNodeWithTag("deliveries").assertTextEquals("deliveries:0") - compose.onNodeWithTag("publish").performClick() - compose.onNodeWithTag("deliveries").assertTextEquals("deliveries:1") - compose.onNodeWithTag("consume-again").performClick() - compose.onNodeWithTag("deliveries").assertTextEquals("deliveries:1") - } - - @Test fun restoredRouteFallsBackBeforeStartingWorkWithoutTransientHandoff() { - compose.onNodeWithTag("open-detail").performClick() - compose.onNodeWithTag("status").assertTextEquals("detail") - compose.onNodeWithTag("work").assertTextEquals("work:1") - compose.activityRule.scenario.recreate() - compose.onNodeWithTag("status").assertTextEquals("fallback") - compose.onNodeWithTag("work").assertTextEquals("work:0") - } -} diff --git a/verification/consumer/src/androidMain/AndroidManifest.xml b/verification/consumer/src/androidMain/AndroidManifest.xml deleted file mode 100644 index 314a286..0000000 --- a/verification/consumer/src/androidMain/AndroidManifest.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/verification/consumer/src/androidMain/kotlin/com/quantipixels/gbeewa/verification/VerificationActivity.kt b/verification/consumer/src/androidMain/kotlin/com/quantipixels/gbeewa/verification/VerificationActivity.kt deleted file mode 100644 index 8ca1d34..0000000 --- a/verification/consumer/src/androidMain/kotlin/com/quantipixels/gbeewa/verification/VerificationActivity.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.quantipixels.gbeewa.verification - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.Column -import androidx.compose.material3.Button -import androidx.compose.material3.Text -import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import com.quantipixels.gbeewa.* - -private object Pick : ResultKey -private object Handoff : ResultKey - -class VerificationActivity : ComponentActivity() { - private lateinit var currentStore: ResultStore - fun capturePickerCallback(): (String) -> Unit { - val owner = currentStore - return { owner.put(Pick, it) } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent { - var workspace by remember { mutableIntStateOf(0) } - var route by rememberSaveable { mutableStateOf("home") } - var deliveries by remember { mutableIntStateOf(0) } - var dependentWork by remember { mutableIntStateOf(0) } - var status by remember { mutableStateOf("home") } - val store = remember(workspace) { ResultStore() } - val consumer = remember(store) { ResultConsumer(store) } - SideEffect { currentStore = store } - CompositionLocalProvider(LocalResultConsumer provides consumer) { - ResultEffect(Pick) { deliveries++ } - } - if (route == "detail") { - LaunchedEffect(consumer, route) { - when (consumer.consume(Handoff)) { - PendingResult.Missing -> status = "fallback" - is PendingResult.Value -> { - dependentWork++ - status = "detail" - } - } - } - } - Column { - Button(onClick = { workspace++ }, Modifier.testTag("replace-owner")) { Text("Replace workspace") } - Button(onClick = { store.put(Pick, "current") }, Modifier.testTag("publish")) { Text("Publish") } - Button(onClick = { - store.put(Handoff, "required-input") - route = "detail" - }, Modifier.testTag("open-detail")) { Text("Open detail") } - Button(onClick = { consumer.consume(Pick) { deliveries++ } }, Modifier.testTag("consume-again")) { - Text("Consume again") - } - Text("deliveries:$deliveries", Modifier.testTag("deliveries")) - Text("work:$dependentWork", Modifier.testTag("work")) - Text(status, Modifier.testTag("status")) - } - } - } -} diff --git a/verification/consumer/src/commonTest/kotlin/com/quantipixels/gbeewa/verification/PublishedApiTest.kt b/verification/consumer/src/commonTest/kotlin/com/quantipixels/gbeewa/verification/PublishedApiTest.kt deleted file mode 100644 index 0e158d3..0000000 --- a/verification/consumer/src/commonTest/kotlin/com/quantipixels/gbeewa/verification/PublishedApiTest.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.quantipixels.gbeewa.verification - -import com.quantipixels.gbeewa.* -import kotlin.test.* - -class PublishedApiTest { - private object Key : ResultKey - - @Test fun publishedStoreConsumesPresentNullExactlyOnce() { - val store = ResultStore() - val consumer = ResultConsumer(store) - store.put(Key, null) - assertTrue(Key in consumer) - assertEquals(PendingResult.Value(null), consumer.consume(Key)) - assertEquals(PendingResult.Missing, consumer.consume(Key)) - } - - @Test fun oldOwnerRemainsSeparateFromNewOwner() { - val oldStore = ResultStore() - val newStore = ResultStore() - val lateCallback: () -> Unit = { oldStore.put(Key, "late") } - lateCallback() - assertEquals(PendingResult.Missing, ResultConsumer(newStore).consume(Key)) - assertEquals(PendingResult.Value("late"), ResultConsumer(oldStore).consume(Key)) - } -}