From 14b863eb0f159d46891f4ad5cf076b3aa0a11bba Mon Sep 17 00:00:00 2001 From: fy59 Date: Thu, 17 Sep 2026 06:31:07 +0200 Subject: [PATCH] test(sync): add isolated validation harness --- CHANGELOG.md | 9 + .../data/IsolatedTestEnvironmentTest.kt | 35 ++ docs/current_state.md | 11 +- docs/design/sync_gap_contract_v1.md | 9 +- docs/roadmap.md | 14 +- docs/tests.md | 53 ++ tests/test_validate_sync_isolated.py | 188 ++++++ tools/validate_sync_isolated.py | 539 ++++++++++++++++++ tui/meson.build | 6 + 9 files changed, 851 insertions(+), 13 deletions(-) create mode 100644 android/app/src/test/java/com/labfytools/trainlog/data/IsolatedTestEnvironmentTest.kt create mode 100644 tests/test_validate_sync_isolated.py create mode 100755 tools/validate_sync_isolated.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a532cb6..af9c0da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased — 0.1.2 +- Completed `TRAINLOG_SYNC_TEST_ENV_V1` without changing production behavior, + schemas or formats. `tools/validate_sync_isolated.py` now validates JDK 17 and + the effective Gradle JVMs, creates unique private HOME/XDG/native/JVM tmp, + exchange, database and report paths, runs bounded preflight/smoke/full suites, + counts Android XML results, propagates failures and interruptions, and cleans + only its owned successful run. Unit and Robolectric coverage proves bounded + cleanup, distinct runs, process termination, no default real transport and + effective `java.io.tmpdir` propagation. + - Froze `TRAINLOG_SYNC_GAP_CONTRACT_V1` without changing production sync, schemas or published formats. The contract distinguishes current evidence from future behavior and defines domain ownership, active-draft lifecycle, diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/IsolatedTestEnvironmentTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/IsolatedTestEnvironmentTest.kt new file mode 100644 index 0000000..7168228 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/IsolatedTestEnvironmentTest.kt @@ -0,0 +1,35 @@ +package com.labfytools.trainlog.data + +import java.nio.file.Path +import kotlin.io.path.Path +import kotlin.io.path.isDirectory +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class IsolatedTestEnvironmentTest { + @Test + fun `private paths reach the Robolectric JVM`() { + val rootText = System.getenv("TRAINLOG_TEST_RUN_ROOT") ?: return + val root = Path(rootText).toRealPath() + fun assertedPath(environmentName: String): Path { + val value = System.getenv(environmentName) + assertTrue("$environmentName missing", !value.isNullOrBlank()) + val path = Path(value!!).toRealPath() + assertTrue("$environmentName escapes run root", path.startsWith(root)) + assertTrue("$environmentName is not a directory", path.isDirectory()) + return path + } + + /* CONTRACT: JAVA_TOOL_OPTIONS must reach the forked test JVM, not only + * the Gradle launcher; native Robolectric extraction uses this value. */ + assertEquals(assertedPath("TRAINLOG_TEST_TMPDIR"), Path(System.getProperty("java.io.tmpdir")).toRealPath()) + assertedPath("HOME") + assertedPath("XDG_DATA_HOME") + assertedPath("XDG_CONFIG_HOME") + assertedPath("XDG_CACHE_HOME") + assertedPath("XDG_RUNTIME_DIR") + assertedPath("TRAINLOG_TEST_EXCHANGE_DIR") + assertedPath("TRAINLOG_TEST_DATABASE_DIR") + } +} diff --git a/docs/current_state.md b/docs/current_state.md index a1880e2..c34c82a 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -1,6 +1,6 @@ # Current implementation state -Snapshot date: **2026-09-16**. +Snapshot date: **2026-09-17**. This document owns the current implemented state. Historical checkpoints and closed incidents belong in [reviews](reviews/) and the @@ -49,6 +49,7 @@ parallel implementation of its rules. | Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` | | Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` | | Complete synchronization gap contract | `TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED` | +| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` | Desktop and Android schema numbers are independent. Neither changes the frozen Trainlog JSON V1 contract. @@ -181,10 +182,10 @@ browser launch or layout persistence exists yet. ## Validation status -The durable commands are owned by [tests.md](tests.md). On 2026-09-16 final -validation passed desktop compilation, **74/74 normal Meson tests** and -**74/74 ASan/UBSan Meson tests**, Android debug assembly and **202 Android -tests (198 passed, 4 skipped, 0 failed)**, the JSON validator, and the +The durable commands are owned by [tests.md](tests.md). On 2026-09-17 isolated +validation passed desktop compilation, **75/75 normal Meson tests** and +**75/75 ASan/UBSan Meson tests**, Android debug assembly and **203 Android +tests (199 passed, 4 skipped, 0 failed)**, the JSON validator, and the import-contract validator. The source-derived TUI `TRANSLATABLE_UI=0` check and Android resource parity also passed. Link and diff safety checks pass in the final audit evidence. diff --git a/docs/design/sync_gap_contract_v1.md b/docs/design/sync_gap_contract_v1.md index 3523e97..16fc1f8 100644 --- a/docs/design/sync_gap_contract_v1.md +++ b/docs/design/sync_gap_contract_v1.md @@ -425,8 +425,13 @@ l'apparition de la séance finalisée dans l'export. **[ACTUEL DÉMONTRÉ]** La validation de ce lot a exécuté les 202 tests Android (198 réussis, 4 ignorés, 0 échec) et `assembleDebug` avec le JDK 17 documenté. Le quota du tmpfs `/tmp` a nécessité un `java.io.tmpdir` ticket-spécifique sous -le home ; Robolectric est resté actif. Ce contournement d'exécution ne constitue -pas encore le harness reproductible du futur lot `TRAINLOG_SYNC_TEST_ENV_V1`. +le home ; Robolectric est resté actif. + +**[ACTUEL DÉMONTRÉ]** `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` fournit désormais +le harness reproductible : JDK 17 contrôlé, JVM Gradle et Robolectric vérifiées, +HOME/XDG/tmp/exchange/bases privés par run et aucun transport réel par défaut. +Son test d'environnement porte la suite Android à 203 tests (199 réussis, +4 ignorés, 0 échec) sans changer les contrats de synchronisation. ## 16. Sources propriétaires diff --git a/docs/roadmap.md b/docs/roadmap.md index e6fb07f..d907e62 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -32,15 +32,17 @@ tile rendering exist. Advanced visualizations remain a future slice. ## Current cursor ```text -CURRENT_OPERATIONAL_CURSOR=TRAINLOG_SYNC_TEST_ENV_V1 +CURRENT_OPERATIONAL_CURSOR=TRAINLOG_SYNC_CHARACTERIZATION_V1 ``` `TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED` -defines the complete-sync target and its bounded dependency order. Its first -implementation cursor is only `TRAINLOG_SYNC_TEST_ENV_V1`: make the isolated -desktop/Android validation environment reproducible, including the documented -JDK and resource gates. It does not authorize a protocol, schema, endpoint or -button change. [Contract details](design/sync_gap_contract_v1.md). +defines the complete-sync target and its bounded dependency order. +`TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` now supplies the reproducible isolated +desktop/Android validation entry point, JDK 17 gate and private JVM/XDG paths. +The next cursor is only `TRAINLOG_SYNC_CHARACTERIZATION_V1`; it must reuse the +existing V3 gap, draft lifecycle and harness coverage rather than duplicate it, +and it does not authorize a protocol, schema, endpoint or button change. +[Contract details](design/sync_gap_contract_v1.md). `WEB_FRONTEND_SHELL_V1=PASS/FROZEN`. `trainlog -w` serves the embedded React, TypeScript and Vite shell with its five client routes, Catppuccin Mocha design diff --git a/docs/tests.md b/docs/tests.md index 45982b4..f08c48b 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -388,6 +388,59 @@ Notable regression coverage: ## 5. Build +### Isolated synchronization validation environment + +`TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` provides one repository-owned entry +point for the desktop and Android validation needed by synchronization work: + +```bash +python3 tools/validate_sync_isolated.py --suite preflight +python3 tools/validate_sync_isolated.py --suite smoke +python3 tools/validate_sync_isolated.py --suite full +``` + +Use `--jdk /absolute/jdk17/home` when auto-detection is unsuitable and +`--run-parent /absolute/private/parent` to select another parent outside the +problematic system `/tmp`. `--keep-run-dir` retains a successful run for +diagnosis; failures and interruptions are always retained. Without that flag, a +successful run removes only its own validated `run-*` directory. + +Each invocation creates a mode-0700 unique root under +`${XDG_CACHE_HOME:-$HOME/.cache}/trainlog/test-runs/`, then gives its children +private HOME, XDG data/config/cache/runtime, native/JVM tmp, exchange, database +and report directories. Test-sensitive files and logs are mode 0600. The +already-provisioned `${GRADLE_USER_HOME:-$original_HOME/.gradle}` dependency +cache is deliberately shared; no Trainlog application data, rclone credential, +keystore or production XDG path is shared. This is path/environment isolation, +not a system sandbox. + +The harness verifies Java 17 with `java -version`, then verifies both Gradle's +launcher and daemon JVM. `JAVA_TOOL_OPTIONS=-Djava.io.tmpdir=/tmp` reaches +the forked Robolectric JVM; `IsolatedTestEnvironmentTest` checks the effective +property and every private path from inside that JVM. Android test tasks use +`--rerun-tasks`, so an up-to-date or cached task cannot masquerade as a new +execution. No default mode invokes rclone, adb, libmtp synchronization, +`trainlog-sync-once`, `connectedDebugAndroidTest`, a user service or a +production lock/database. + +The modes are: + +- `preflight`: tools, writable parent, JDK 17, Gradle launcher/daemon and paths; +- `smoke`: preflight, production-path sync-gap characterization and the Android + JVM isolation test; +- `full`: normal and ASan/UBSan desktop suites, explicit characterization, + JSON/import validators, targeted draft lifecycle/export test, complete + Android unit suite and `assembleDebug`. + +Logs are private and capped at 8 MiB per command. Exit codes distinguish `0` +success, `2` blocked preflight, `3` missing dependency, `4` environment failure, +`5` business-test/command failure and `130` interruption. Commands, exit codes, +JDK, non-secret paths and Android test/pass/fail/skip counts are recorded in +`reports/`; the full environment and credentials are never printed. The +harness terminates only process groups that it started, waits before forced +termination, never stops a pre-existing Gradle daemon or Trainlog service, and +refuses cleanup outside its exact root or across a symlink. + Stable-release validation additionally builds the signed release variant and checks the packaged product versions and binary linkage: diff --git a/tests/test_validate_sync_isolated.py b/tests/test_validate_sync_isolated.py new file mode 100644 index 0000000..e2d713a --- /dev/null +++ b/tests/test_validate_sync_isolated.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Unit tests for the isolated validation harness; no production transport.""" + +import importlib.util +import os +import shutil +import stat +import subprocess +import sys +import tempfile +import unittest +from unittest import mock +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / "tools/validate_sync_isolated.py" +SPEC = importlib.util.spec_from_file_location("validate_sync_isolated", MODULE_PATH) +assert SPEC and SPEC.loader +HARNESS = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = HARNESS +SPEC.loader.exec_module(HARNESS) + + +class IsolatedValidationHarnessTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="trainlog-harness-test-") + self.root = Path(self.temporary.name) + + def tearDown(self): + self.temporary.cleanup() + + def fake_jdk(self, version="17.0.12"): + jdk = self.root / f"jdk-{version}" + binary = jdk / "bin/java" + binary.parent.mkdir(parents=True) + binary.write_text(f'#!/bin/sh\necho \'openjdk version "{version}"\' >&2\n') + binary.chmod(0o700) + return jdk + + def test_required_jdk_is_detected_and_incompatible_jdk_is_rejected(self): + good = self.fake_jdk() + self.assertEqual(good.resolve(), HARNESS.resolve_jdk(str(good), {})) + bad = self.fake_jdk("21.0.4") + with self.assertRaisesRegex(HARNESS.HarnessError, "JDK incompatible") as raised: + HARNESS.resolve_jdk(str(bad), {}) + self.assertEqual(HARNESS.PREFLIGHT_BLOCKED, raised.exception.exit_code) + with self.assertRaisesRegex(HARNESS.HarnessError, "introuvable"): + HARNESS.resolve_jdk(str(self.root / "missing"), {}) + + def test_unusable_parent_is_an_environment_failure(self): + regular_file = self.root / "not-a-directory" + regular_file.write_text("sentinel") + with self.assertRaises(HARNESS.HarnessError) as raised: + HARNESS.check_parent(regular_file) + self.assertEqual(HARNESS.ENVIRONMENT_FAILURE, raised.exception.exit_code) + + def test_two_runs_have_private_distinct_paths_and_effective_tmpdir(self): + parent = self.root / "runs" + first = HARNESS.create_run_paths(parent) + second = HARNESS.create_run_paths(parent) + self.assertNotEqual(first.root, second.root) + environment = HARNESS.build_environment( + first, self.fake_jdk(), {"HOME": str(self.root)}, self.root + ) + for name in ( + "HOME", + "XDG_DATA_HOME", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_RUNTIME_DIR", + "TMPDIR", + "TRAINLOG_TEST_EXCHANGE_DIR", + "TRAINLOG_TEST_DATABASE_DIR", + ): + self.assertTrue(Path(environment[name]).resolve().is_relative_to(first.root)) + self.assertEqual( + f"-Djava.io.tmpdir={first.native_tmp}", environment["JAVA_TOOL_OPTIONS"] + ) + self.assertEqual(0o700, stat.S_IMODE(first.root.stat().st_mode)) + + def test_child_failure_is_propagated_and_logged(self): + paths = HARNESS.create_run_paths(self.root / "runs") + with self.assertRaises(HARNESS.HarnessError) as raised: + HARNESS.run_command( + [sys.executable, "-c", "raise SystemExit(7)"], + cwd=self.root, + environment=os.environ.copy(), + log_path=paths.reports / "failure.log", + active=set(), + ) + self.assertEqual(HARNESS.TEST_FAILURE, raised.exception.exit_code) + self.assertIn("EXIT 7", (paths.reports / "failure.log").read_text()) + + def test_missing_child_and_robolectric_quota_are_classified(self): + paths = HARNESS.create_run_paths(self.root / "runs") + with self.assertRaises(HARNESS.HarnessError) as missing: + HARNESS.run_command( + [str(self.root / "missing-tool")], + cwd=self.root, + environment=os.environ.copy(), + log_path=paths.reports / "missing.log", + active=set(), + ) + self.assertEqual(HARNESS.DEPENDENCY_MISSING, missing.exception.exit_code) + environment_log = paths.reports / "environment.log" + environment_log.write_text("Unable to load Robolectric native runtime library\nDisk quota exceeded\n") + self.assertEqual( + HARNESS.ENVIRONMENT_FAILURE, + HARNESS.classify_command_failure(environment_log), + ) + + def test_android_counts_include_skips(self): + result_dir = self.root / "results" + result_dir.mkdir() + (result_dir / "TEST-one.xml").write_text( + '' + ) + self.assertEqual( + {"tests": 5, "failures": 1, "errors": 0, "skipped": 2, "passed": 2}, + HARNESS.count_android_results(result_dir), + ) + + def test_owned_children_are_stopped(self): + child = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(60)"], + start_new_session=True, + ) + active = {child} + HARNESS.stop_children(active) + self.assertIsNotNone(child.poll()) + self.assertEqual(set(), active) + + def test_interrupted_live_child_remains_registered_for_shutdown(self): + class InterruptedOutput: + def read(self, _size): + raise KeyboardInterrupt + + process = mock.Mock() + process.stdout = InterruptedOutput() + process.poll.return_value = None + active = set() + paths = HARNESS.create_run_paths(self.root / "runs") + with mock.patch.object(HARNESS.subprocess, "Popen", return_value=process): + with self.assertRaises(KeyboardInterrupt): + HARNESS.run_command( + ["owned-child"], + cwd=self.root, + environment=os.environ.copy(), + log_path=paths.reports / "interrupted.log", + active=active, + ) + self.assertEqual({process}, active) + + def test_cleanup_is_bounded_and_external_sentinel_survives(self): + parent = self.root / "runs" + paths = HARNESS.create_run_paths(parent) + sentinel = self.root / "sentinel" + sentinel.write_text("outside") + HARNESS.safe_cleanup(paths.root, parent) + self.assertFalse(paths.root.exists()) + self.assertEqual("outside", sentinel.read_text()) + with self.assertRaisesRegex(HARNESS.HarnessError, "cible extérieure"): + HARNESS.safe_cleanup(self.root, parent) + + def test_cleanup_refuses_symlink(self): + parent = self.root / "runs" + paths = HARNESS.create_run_paths(parent) + outside = self.root / "outside" + outside.write_text("intact") + link = paths.root / "external-link" + link.symlink_to(outside) + with self.assertRaisesRegex(HARNESS.HarnessError, "symlink"): + HARNESS.safe_cleanup(paths.root, parent) + self.assertEqual("intact", outside.read_text()) + link.unlink() + shutil.rmtree(paths.root) + + def test_default_modes_never_start_real_transports(self): + commands = HARNESS.commands_for_suite(ROOT, "full") + flattened = [argument for _, command, _ in commands for argument in command] + forbidden = ("trainlog-sync-once", "trainlog_syncd.py", "rclone", "adb") + for name in forbidden: + self.assertFalse(any(name in argument for argument in flattened), name) + self.assertNotIn("connectedDebugAndroidTest", flattened) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/validate_sync_isolated.py b/tools/validate_sync_isolated.py new file mode 100755 index 0000000..e2abd0f --- /dev/null +++ b/tools/validate_sync_isolated.py @@ -0,0 +1,539 @@ +#!/usr/bin/env python3 +"""Run Trainlog sync validation with private HOME/XDG/tmp data paths. + +This harness isolates application data and test transports by environment and +explicit fixture paths. It is not a system sandbox and intentionally reuses the +already-provisioned Gradle dependency cache. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import signal +import stat +import subprocess +import sys +import tempfile +import time +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from pathlib import Path + +SUCCESS = 0 +PREFLIGHT_BLOCKED = 2 +DEPENDENCY_MISSING = 3 +ENVIRONMENT_FAILURE = 4 +TEST_FAILURE = 5 +INTERRUPTED = 130 +MAX_LOG_BYTES = 8 * 1024 * 1024 +TARGET_ANDROID_TEST = ( + "com.labfytools.trainlog.data.TrainlogRepositoryDraftTest." + "finalizeIsAtomicAndDraftNeverExportsBeforeCompletion" +) +ENV_ANDROID_TEST = ( + "com.labfytools.trainlog.data.IsolatedTestEnvironmentTest." + "private paths reach the Robolectric JVM" +) + + +class HarnessError(RuntimeError): + def __init__(self, message: str, exit_code: int) -> None: + super().__init__(message) + self.exit_code = exit_code + + +@dataclass(frozen=True) +class RunPaths: + root: Path + home: Path + data: Path + config: Path + cache: Path + runtime: Path + native_tmp: Path + exchange: Path + databases: Path + reports: Path + + +def parse_java_major(output: str) -> int | None: + match = re.search(r'(?:java|openjdk) version "([0-9]+)(?:\.|\")', output) + return int(match.group(1)) if match else None + + +def resolve_jdk(explicit: str | None, inherited: dict[str, str]) -> Path: + candidates: list[Path] = [] + if explicit: + candidates.append(Path(explicit)) + else: + if inherited.get("JAVA_HOME"): + candidates.append(Path(inherited["JAVA_HOME"])) + candidates.extend( + [Path("/usr/lib/jvm/java-17-openjdk"), Path("/usr/lib/jvm/java-17-openjdk-amd64")] + ) + seen: set[Path] = set() + for candidate in candidates: + resolved = candidate.expanduser().resolve() + if resolved in seen: + continue + seen.add(resolved) + java = resolved / "bin/java" + if not java.is_file() or not os.access(java, os.X_OK): + continue + result = subprocess.run( + [str(java), "-version"], capture_output=True, text=True, check=False + ) + version_output = result.stdout + result.stderr + if result.returncode == 0 and parse_java_major(version_output) == 17: + return resolved + if explicit: + raise HarnessError( + f"JDK incompatible: {resolved} n'est pas un JDK 17 utilisable", + PREFLIGHT_BLOCKED, + ) + raise HarnessError( + "JDK 17 introuvable; utiliser --jdk /chemin/vers/jdk17", + DEPENDENCY_MISSING, + ) + + +def default_run_parent(inherited: dict[str, str]) -> Path: + cache = inherited.get("XDG_CACHE_HOME") + if cache: + base = Path(cache).expanduser() + else: + home = inherited.get("HOME") + if not home: + raise HarnessError("HOME est absent", PREFLIGHT_BLOCKED) + base = Path(home).expanduser() / ".cache" + return base.resolve() / "trainlog/test-runs" + + +def check_parent(parent: Path) -> Path: + try: + parent.mkdir(mode=0o700, parents=True, exist_ok=True) + parent = parent.resolve(strict=True) + if not parent.is_dir() or not os.access(parent, os.W_OK | os.X_OK): + raise OSError("répertoire non accessible en écriture") + probe = parent / f".write-probe-{os.getpid()}" + descriptor = os.open(probe, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + os.write(descriptor, b"trainlog-test\n") + os.close(descriptor) + probe.unlink() + except OSError as error: + raise HarnessError( + f"parent temporaire inutilisable: {parent}: {error}", + ENVIRONMENT_FAILURE, + ) from error + return parent + + +def create_run_paths(parent: Path) -> RunPaths: + os.umask(0o077) + root = Path(tempfile.mkdtemp(prefix="run-", dir=check_parent(parent))).resolve() + names = { + "home": "home", + "data": "xdg-data", + "config": "xdg-config", + "cache": "xdg-cache", + "runtime": "xdg-runtime", + "native_tmp": "tmp", + "exchange": "exchange", + "databases": "databases", + "reports": "reports", + } + created: dict[str, Path] = {} + for key, name in names.items(): + path = root / name + path.mkdir(mode=0o700) + path.chmod(0o700) + created[key] = path + return RunPaths(root=root, **created) + + +def build_environment( + paths: RunPaths, jdk: Path, inherited: dict[str, str], gradle_cache: Path +) -> dict[str, str]: + environment = inherited.copy() + environment.update( + { + "HOME": str(paths.home), + "XDG_DATA_HOME": str(paths.data), + "XDG_CONFIG_HOME": str(paths.config), + "XDG_CACHE_HOME": str(paths.cache), + "XDG_RUNTIME_DIR": str(paths.runtime), + "TMPDIR": str(paths.native_tmp), + "JAVA_HOME": str(jdk), + "GRADLE_USER_HOME": str(gradle_cache), + "JAVA_TOOL_OPTIONS": f"-Djava.io.tmpdir={paths.native_tmp}", + "TRAINLOG_TEST_RUN_ROOT": str(paths.root), + "TRAINLOG_TEST_TMPDIR": str(paths.native_tmp), + "TRAINLOG_TEST_EXCHANGE_DIR": str(paths.exchange), + "TRAINLOG_TEST_DATABASE_DIR": str(paths.databases), + } + ) + # CONTRACT: real rclone configuration and production daemon endpoints are + # not inherited by an isolated validation run. + environment.pop("RCLONE_CONFIG", None) + return environment + + +def command_label(arguments: list[str]) -> str: + return " ".join(json.dumps(argument) for argument in arguments) + + +def classify_command_failure(log_path: Path) -> int: + output = log_path.read_text(encoding="utf-8", errors="replace").lower() + environment_markers = ( + "disk quota exceeded", + "no space left on device", + "unable to load robolectric native runtime library", + "could not create the java virtual machine", + ) + return ( + ENVIRONMENT_FAILURE + if any(marker in output for marker in environment_markers) + else TEST_FAILURE + ) + + +def run_command( + arguments: list[str], + *, + cwd: Path, + environment: dict[str, str], + log_path: Path, + active: set[subprocess.Popen[bytes]], +) -> None: + log_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + with log_path.open("wb") as log: + os.chmod(log_path, 0o600) + header = f"COMMAND {command_label(arguments)}\nCWD {cwd}\n".encode() + log.write(header) + try: + process = subprocess.Popen( + arguments, + cwd=cwd, + env=environment, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + except FileNotFoundError as error: + log.write(f"DEPENDENCY_MISSING {error}\n".encode()) + raise HarnessError( + f"outil ou dépendance manquante: {arguments[0]}", DEPENDENCY_MISSING + ) from error + active.add(process) + captured = len(header) + try: + assert process.stdout is not None + while True: + chunk = process.stdout.read(65536) + if not chunk: + break + if captured < MAX_LOG_BYTES: + retained = chunk[: MAX_LOG_BYTES - captured] + log.write(retained) + captured += len(retained) + process.stdout.close() + return_code = process.wait() + finally: + # INVARIANT: an interrupted live child remains registered so the + # caller can terminate its owned process group before returning. + if process.poll() is not None: + active.discard(process) + log.write(f"\nEXIT {return_code}\n".encode()) + if return_code != 0: + exit_code = classify_command_failure(log_path) + raise HarnessError( + f"commande en échec ({return_code}): {command_label(arguments)}; log={log_path}", + exit_code, + ) + + +def stop_children(active: set[subprocess.Popen[bytes]]) -> None: + for process in tuple(active): + if process.poll() is None: + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + pass + deadline = time.monotonic() + 5.0 + for process in tuple(active): + remaining = max(0.0, deadline - time.monotonic()) + try: + process.wait(timeout=remaining) + except subprocess.TimeoutExpired: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait() + active.discard(process) + + +def safe_cleanup(root: Path, parent: Path) -> None: + resolved_parent = parent.resolve(strict=True) + resolved_root = root.resolve(strict=True) + if resolved_root.parent != resolved_parent or not resolved_root.name.startswith("run-"): + raise HarnessError( + f"refus de nettoyer une cible extérieure: {resolved_root}", + ENVIRONMENT_FAILURE, + ) + for entry in resolved_root.rglob("*"): + if entry.is_symlink(): + raise HarnessError( + f"refus de suivre/nettoyer le symlink: {entry}", ENVIRONMENT_FAILURE + ) + shutil.rmtree(resolved_root) + + +def count_android_results(result_dir: Path) -> dict[str, int]: + totals = {"tests": 0, "failures": 0, "errors": 0, "skipped": 0} + files = list(result_dir.glob("TEST-*.xml")) + if not files: + raise HarnessError( + f"aucun résultat Android dans {result_dir}", ENVIRONMENT_FAILURE + ) + for result_file in files: + suite = ET.parse(result_file).getroot() + for key in totals: + totals[key] += int(suite.attrib.get(key, "0")) + totals["passed"] = ( + totals["tests"] - totals["failures"] - totals["errors"] - totals["skipped"] + ) + return totals + + +def verify_gradle_jdk( + repository: Path, + environment: dict[str, str], + reports: Path, + active: set[subprocess.Popen[bytes]], +) -> None: + log = reports / "gradle-version.log" + run_command( + [str(repository / "android/gradlew"), "--no-daemon", "--version"], + cwd=repository / "android", + environment=environment, + log_path=log, + active=active, + ) + output = log.read_text(encoding="utf-8", errors="replace") + match = re.search(r"Launcher JVM:\s+17(?:\.|\s)", output) + java_home = Path(environment["JAVA_HOME"]).resolve() + daemon_line = re.search(r"^Daemon JVM:\s+(.+)$", output, re.MULTILINE) + daemon = daemon_line is not None and str(java_home) in daemon_line.group(1) + if not match or not daemon: + raise HarnessError( + f"Gradle n'utilise pas JDK 17 (voir {log})", ENVIRONMENT_FAILURE + ) + + +def required_tools(repository: Path) -> None: + for executable in ("meson", "python3"): + if shutil.which(executable) is None: + raise HarnessError(f"outil manquant: {executable}", DEPENDENCY_MISSING) + for path in ( + repository / "android/gradlew", + repository / "tools/validate_json.py", + repository / "tools/validate_import_contract.py", + ): + if not path.exists(): + raise HarnessError(f"prérequis absent: {path}", DEPENDENCY_MISSING) + + +def commands_for_suite(repository: Path, suite: str) -> list[tuple[str, list[str], Path]]: + python = sys.executable + gradle = str(repository / "android/gradlew") + android = repository / "android" + smoke = [ + ( + "sync-gap-characterization", + [python, str(repository / "tests/test_sync_gap_characterization.py")], + repository, + ), + ( + "android-environment-smoke", + [ + gradle, + "--no-daemon", + "--rerun-tasks", + ":app:testDebugUnitTest", + "--tests", + ENV_ANDROID_TEST, + ], + android, + ), + ] + if suite == "smoke": + return smoke + if suite == "preflight": + return [] + return [ + ("desktop-compile", ["meson", "compile", "-C", "build"], repository), + ( + "desktop-tests", + ["meson", "test", "-C", "build", "--print-errorlogs"], + repository, + ), + ( + "desktop-asan-compile", + ["meson", "compile", "-C", "build-asan"], + repository, + ), + ( + "desktop-asan-tests", + ["meson", "test", "-C", "build-asan", "--print-errorlogs"], + repository, + ), + smoke[0], + ("json-validator", [python, str(repository / "tools/validate_json.py")], repository), + ( + "import-contract-validator", + [python, str(repository / "tools/validate_import_contract.py")], + repository, + ), + ( + "android-targeted", + [ + gradle, + "--no-daemon", + "--rerun-tasks", + ":app:testDebugUnitTest", + "--tests", + TARGET_ANDROID_TEST, + ], + android, + ), + ( + "android-full-and-assemble", + [ + gradle, + "--no-daemon", + "--rerun-tasks", + ":app:testDebugUnitTest", + ":app:assembleDebug", + ], + android, + ), + ] + + +def write_summary(paths: RunPaths, payload: dict[str, object]) -> None: + destination = paths.reports / "summary.json" + destination.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") + destination.chmod(0o600) + + +def parse_arguments(arguments: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--suite", choices=("preflight", "smoke", "full"), default="full") + parser.add_argument("--jdk", help="JDK 17 home; auto-detected when omitted") + parser.add_argument("--run-parent", type=Path, help="private run parent outside /tmp") + parser.add_argument("--keep-run-dir", action="store_true", help="retain a successful run") + return parser.parse_args(arguments) + + +def main(arguments: list[str] | None = None) -> int: + options = parse_arguments(arguments if arguments is not None else sys.argv[1:]) + repository = Path(__file__).resolve().parents[1] + inherited = os.environ.copy() + active: set[subprocess.Popen[bytes]] = set() + paths: RunPaths | None = None + parent: Path | None = None + try: + required_tools(repository) + jdk = resolve_jdk(options.jdk, inherited) + parent = check_parent( + options.run_parent.resolve() + if options.run_parent + else default_run_parent(inherited) + ) + paths = create_run_paths(parent) + original_home = Path(inherited.get("HOME", str(Path.home()))).resolve() + gradle_cache = Path( + inherited.get("GRADLE_USER_HOME", str(original_home / ".gradle")) + ).resolve() + if not gradle_cache.is_dir(): + raise HarnessError( + f"cache Gradle préprovisionné absent: {gradle_cache}", + DEPENDENCY_MISSING, + ) + environment = build_environment(paths, jdk, inherited, gradle_cache) + verify_gradle_jdk(repository, environment, paths.reports, active) + executed: list[str] = [] + for label, command, cwd in commands_for_suite(repository, options.suite): + print(f"RUN {label}: {command_label(command)}", flush=True) + run_command( + command, + cwd=cwd, + environment=environment, + log_path=paths.reports / f"{label}.log", + active=active, + ) + executed.append(label) + android_counts = None + if options.suite == "full": + android_counts = count_android_results( + repository / "android/app/build/test-results/testDebugUnitTest" + ) + if android_counts["failures"] or android_counts["errors"]: + raise HarnessError( + f"résultats Android en échec: {android_counts}", TEST_FAILURE + ) + summary: dict[str, object] = { + "status": "success", + "suite": options.suite, + "run_root": str(paths.root), + "jdk_home": str(jdk), + "java_major": 17, + "gradle_user_home": str(gradle_cache), + "isolated_paths": { + "home": str(paths.home), + "data": str(paths.data), + "config": str(paths.config), + "cache": str(paths.cache), + "runtime": str(paths.runtime), + "tmp": str(paths.native_tmp), + "exchange": str(paths.exchange), + "databases": str(paths.databases), + }, + "commands": executed, + "android": android_counts, + } + write_summary(paths, summary) + print(json.dumps(summary, indent=2, sort_keys=True)) + if options.keep_run_dir: + print(f"RUN_RETAINED={paths.root}") + else: + safe_cleanup(paths.root, parent) + print(f"RUN_CLEANED={paths.root}") + return SUCCESS + except KeyboardInterrupt: + stop_children(active) + if paths: + print(f"INTERRUPTED RUN_RETAINED={paths.root}", file=sys.stderr) + return INTERRUPTED + except HarnessError as error: + stop_children(active) + if paths: + try: + write_summary( + paths, + {"status": "failed", "exit_code": error.exit_code, "error": str(error)}, + ) + except OSError: + pass + print(f"RUN_RETAINED={paths.root}", file=sys.stderr) + print(f"ERROR[{error.exit_code}] {error}", file=sys.stderr) + return error.exit_code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tui/meson.build b/tui/meson.build index ee8c8de..50379fc 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -708,6 +708,12 @@ test( args: [meson.project_source_root() / 'tests/test_sync_gap_characterization.py'], ) +test( + 'sync_isolated_harness', + python3_trainlog_tests, + args: [meson.project_source_root() / 'tests/test_validate_sync_isolated.py'], +) + test( 'equipment_associations_exchange', python3_trainlog_tests,