test(sync): add isolated validation harness
This commit is contained in:
parent
32877f11e9
commit
14b863eb0f
9 changed files with 851 additions and 13 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
188
tests/test_validate_sync_isolated.py
Normal file
188
tests/test_validate_sync_isolated.py
Normal 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
539
tools/validate_sync_isolated.py
Executable 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())
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue