test(sync): add isolated validation harness

This commit is contained in:
fy59 2026-09-17 06:31:07 +02:00
parent 32877f11e9
commit 14b863eb0f
9 changed files with 851 additions and 13 deletions

View file

@ -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,

View file

@ -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")
}
}

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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=<run>/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:

View file

@ -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(
'<testsuite tests="5" failures="1" errors="0" skipped="2"/>'
)
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()

539
tools/validate_sync_isolated.py Executable file
View file

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

View file

@ -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,