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
|
## 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,
|
- Froze `TRAINLOG_SYNC_GAP_CONTRACT_V1` without changing production sync,
|
||||||
schemas or published formats. The contract distinguishes current evidence
|
schemas or published formats. The contract distinguishes current evidence
|
||||||
from future behavior and defines domain ownership, active-draft lifecycle,
|
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
|
# Current implementation state
|
||||||
|
|
||||||
Snapshot date: **2026-09-16**.
|
Snapshot date: **2026-09-17**.
|
||||||
|
|
||||||
This document owns the current implemented state. Historical checkpoints and
|
This document owns the current implemented state. Historical checkpoints and
|
||||||
closed incidents belong in [reviews](reviews/) and the
|
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` |
|
| Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` |
|
||||||
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
|
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
|
||||||
| Complete synchronization gap contract | `TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_NOT_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
|
Desktop and Android schema numbers are independent. Neither changes the frozen
|
||||||
Trainlog JSON V1 contract.
|
Trainlog JSON V1 contract.
|
||||||
|
|
@ -181,10 +182,10 @@ browser launch or layout persistence exists yet.
|
||||||
|
|
||||||
## Validation status
|
## Validation status
|
||||||
|
|
||||||
The durable commands are owned by [tests.md](tests.md). On 2026-09-16 final
|
The durable commands are owned by [tests.md](tests.md). On 2026-09-17 isolated
|
||||||
validation passed desktop compilation, **74/74 normal Meson tests** and
|
validation passed desktop compilation, **75/75 normal Meson tests** and
|
||||||
**74/74 ASan/UBSan Meson tests**, Android debug assembly and **202 Android
|
**75/75 ASan/UBSan Meson tests**, Android debug assembly and **203 Android
|
||||||
tests (198 passed, 4 skipped, 0 failed)**, the JSON validator, and the
|
tests (199 passed, 4 skipped, 0 failed)**, the JSON validator, and the
|
||||||
import-contract validator. The source-derived TUI
|
import-contract validator. The source-derived TUI
|
||||||
`TRANSLATABLE_UI=0` check and Android resource parity also passed. Link and
|
`TRANSLATABLE_UI=0` check and Android resource parity also passed. Link and
|
||||||
diff safety checks pass in the final audit evidence.
|
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
|
**[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é.
|
(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 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
|
le home ; Robolectric est resté actif.
|
||||||
pas encore le harness reproductible du futur lot `TRAINLOG_SYNC_TEST_ENV_V1`.
|
|
||||||
|
**[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
|
## 16. Sources propriétaires
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,15 +32,17 @@ tile rendering exist. Advanced visualizations remain a future slice.
|
||||||
## Current cursor
|
## Current cursor
|
||||||
|
|
||||||
```text
|
```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`
|
`TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED`
|
||||||
defines the complete-sync target and its bounded dependency order. Its first
|
defines the complete-sync target and its bounded dependency order.
|
||||||
implementation cursor is only `TRAINLOG_SYNC_TEST_ENV_V1`: make the isolated
|
`TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` now supplies the reproducible isolated
|
||||||
desktop/Android validation environment reproducible, including the documented
|
desktop/Android validation entry point, JDK 17 gate and private JVM/XDG paths.
|
||||||
JDK and resource gates. It does not authorize a protocol, schema, endpoint or
|
The next cursor is only `TRAINLOG_SYNC_CHARACTERIZATION_V1`; it must reuse the
|
||||||
button change. [Contract details](design/sync_gap_contract_v1.md).
|
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,
|
`WEB_FRONTEND_SHELL_V1=PASS/FROZEN`. `trainlog -w` serves the embedded React,
|
||||||
TypeScript and Vite shell with its five client routes, Catppuccin Mocha design
|
TypeScript and Vite shell with its five client routes, Catppuccin Mocha design
|
||||||
|
|
|
||||||
|
|
@ -388,6 +388,59 @@ Notable regression coverage:
|
||||||
|
|
||||||
## 5. Build
|
## 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
|
Stable-release validation additionally builds the signed release variant and
|
||||||
checks the packaged product versions and binary linkage:
|
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'],
|
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(
|
test(
|
||||||
'equipment_associations_exchange',
|
'equipment_associations_exchange',
|
||||||
python3_trainlog_tests,
|
python3_trainlog_tests,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue