blob: 433f167af0d2d119b6edd1b555d755d1965cc3fe [file] [edit]
# Copyright 2026 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import json
import os
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
worktree_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, worktree_dir)
from worktree import Worktree
from worktree_metrics import WorktreeMetricsTracker
class TestWorktreeMetricsTracker(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = Path(tempfile.mkdtemp())
self.jiri_root = self.temp_dir / ".jiri_root"
self.jiri_root.mkdir(parents=True, exist_ok=True)
self.fuchsia_dir = self.temp_dir
self.metrics_file = (
self.fuchsia_dir / ".fx" / "metrics" / "worktree_metrics.json"
)
def tearDown(self) -> None:
shutil.rmtree(self.temp_dir)
def test_initial_record_state(self) -> None:
tracker = WorktreeMetricsTracker(
self.jiri_root,
self.fuchsia_dir,
)
t0 = 1000.0
emitted = tracker.record_state(
current_total=4,
current_leased=2,
current_built_recently=3,
current_not_built_recently=1,
now=t0,
)
self.assertFalse(emitted)
self.assertTrue(self.metrics_file.exists())
data = json.loads(self.metrics_file.read_text())
self.assertEqual(data["period_start_ts"], 1000.0)
self.assertEqual(data["last_change_ts"], 1000.0)
self.assertEqual(data["current_total"], 4)
self.assertEqual(data["current_leased"], 2)
self.assertEqual(data["current_built_recently"], 3)
self.assertEqual(data["current_not_built_recently"], 1)
self.assertEqual(data["accumulated_total_seconds"], 0.0)
self.assertEqual(data["accumulated_leased_seconds"], 0.0)
self.assertEqual(data["accumulated_built_recently_seconds"], 0.0)
self.assertEqual(data["accumulated_not_built_recently_seconds"], 0.0)
self.assertEqual(data["min_total"], 4)
self.assertEqual(data["max_total"], 4)
self.assertEqual(data["min_leased"], 2)
self.assertEqual(data["max_leased"], 2)
self.assertEqual(data["min_built_recently"], 3)
self.assertEqual(data["max_built_recently"], 3)
self.assertEqual(data["min_not_built_recently"], 1)
self.assertEqual(data["max_not_built_recently"], 1)
def test_time_accumulation_and_extrema(self) -> None:
tracker = WorktreeMetricsTracker(
self.jiri_root,
self.fuchsia_dir,
metrics_file_path=self.metrics_file,
)
t0 = 1000.0
tracker.record_state(
current_total=3,
current_leased=1,
current_built_recently=2,
current_not_built_recently=1,
now=t0,
)
# 1 hour later: add a worktree and build it (total: 4, leased: 2, built_recently: 3, not_built_recently: 1)
t1 = 1000.0 + 3600.0
emitted = tracker.record_state(
current_total=4,
current_leased=2,
current_built_recently=3,
current_not_built_recently=1,
now=t1,
)
self.assertFalse(emitted)
data = json.loads(self.metrics_file.read_text())
self.assertEqual(data["accumulated_total_seconds"], 3 * 3600.0)
self.assertEqual(data["accumulated_leased_seconds"], 1 * 3600.0)
self.assertEqual(data["accumulated_built_recently_seconds"], 2 * 3600.0)
self.assertEqual(
data["accumulated_not_built_recently_seconds"], 1 * 3600.0
)
self.assertEqual(data["min_total"], 3)
self.assertEqual(data["max_total"], 4)
self.assertEqual(data["min_leased"], 1)
self.assertEqual(data["max_leased"], 2)
self.assertEqual(data["min_built_recently"], 2)
self.assertEqual(data["max_built_recently"], 3)
self.assertEqual(data["min_not_built_recently"], 1)
self.assertEqual(data["max_not_built_recently"], 1)
def test_daily_snapshot_trigger_and_rotation(self) -> None:
tracker = WorktreeMetricsTracker(
self.jiri_root,
self.fuchsia_dir,
metrics_file_path=self.metrics_file,
snapshot_interval_sec=86400.0,
)
t0 = 1000.0
tracker.record_state(
current_total=2,
current_leased=1,
current_built_recently=1,
current_not_built_recently=1,
now=t0,
)
# 12 hours later: transition to total=4, leased=3, built_recently=3, not_built_recently=1
t1 = t0 + 43200.0
tracker.record_state(
current_total=4,
current_leased=3,
current_built_recently=3,
current_not_built_recently=1,
now=t1,
)
# 24 hours from t0: trigger snapshot
t2 = t0 + 86400.0
with patch.object(tracker, "_emit_telemetry") as mock_emit:
emitted = tracker.record_state(
current_total=4,
current_leased=3,
current_built_recently=3,
current_not_built_recently=1,
now=t2,
)
self.assertTrue(emitted)
mock_emit.assert_called_once_with(
"daily_snapshot",
'{"at":3.0,"mnt":2,"mxt":4,"al":2.0,"mnl":1,"mxl":3,'
'"abr":2.0,"mnbr":1,"mxbr":3,"anbr":1.0,"mnnbr":1,"mxnbr":1}',
)
# Confirm data rotated for new period
data = json.loads(self.metrics_file.read_text())
self.assertEqual(data["period_start_ts"], t2)
self.assertEqual(data["last_change_ts"], t2)
self.assertEqual(data["accumulated_total_seconds"], 0.0)
self.assertEqual(data["accumulated_leased_seconds"], 0.0)
self.assertEqual(data["accumulated_built_recently_seconds"], 0.0)
self.assertEqual(data["accumulated_not_built_recently_seconds"], 0.0)
self.assertEqual(data["min_total"], 4)
self.assertEqual(data["max_total"], 4)
self.assertEqual(data["min_leased"], 3)
self.assertEqual(data["max_leased"], 3)
self.assertEqual(data["min_built_recently"], 3)
self.assertEqual(data["max_built_recently"], 3)
self.assertEqual(data["min_not_built_recently"], 1)
self.assertEqual(data["max_not_built_recently"], 1)
def test_missing_report_script_raises_loud_error(self) -> None:
tracker = WorktreeMetricsTracker(
self.jiri_root,
self.fuchsia_dir,
metrics_file_path=self.metrics_file,
)
with self.assertRaises(FileNotFoundError) as ctx:
tracker._emit_telemetry("daily_snapshot", "label")
self.assertIn("Metrics reporting script not found", str(ctx.exception))
def test_emit_telemetry_runs_subprocess(self) -> None:
report_script = (
self.fuchsia_dir / "tools/devshell/lib/metrics_custom_report.sh"
)
report_script.parent.mkdir(parents=True, exist_ok=True)
report_script.write_text("#!/bin/bash\nexit 0\n")
tracker = WorktreeMetricsTracker(
self.jiri_root,
self.fuchsia_dir,
metrics_file_path=self.metrics_file,
)
with patch("subprocess.Popen") as mock_popen:
tracker._emit_telemetry("daily_snapshot", "test_label")
mock_popen.assert_called_once()
args, _ = mock_popen.call_args
self.assertEqual(
args[0],
[
"bash",
str(report_script),
"worktree",
"daily_snapshot",
"test_label",
],
)
def test_corrupted_file_recovery(self) -> None:
self.metrics_file.parent.mkdir(parents=True, exist_ok=True)
self.metrics_file.write_text("invalid json content")
tracker = WorktreeMetricsTracker(
self.jiri_root,
self.fuchsia_dir,
metrics_file_path=self.metrics_file,
)
# Should not crash but reset to initial state
emitted = tracker.record_state(
current_total=3,
current_leased=1,
current_built_recently=2,
current_not_built_recently=1,
now=1000.0,
)
self.assertFalse(emitted)
data = json.loads(self.metrics_file.read_text())
self.assertEqual(data["current_total"], 3)
self.assertEqual(data["current_built_recently"], 2)
self.assertEqual(data["current_not_built_recently"], 1)
def test_worktree_is_built_recently(self) -> None:
wt_path = self.temp_dir / "my_wt"
wt_path.mkdir(parents=True, exist_ok=True)
wt = Worktree("my_wt", wt_path, self.fuchsia_dir)
now = 1000000.0
# No build directory -> not built recently (False)
self.assertFalse(wt.is_built_recently(max_age_days=7, now=now))
# Add build dir with recent .ninja_log (2 days old)
build_dir = wt_path / "out" / "default_build"
build_dir.mkdir(parents=True, exist_ok=True)
(build_dir / "args.gn").write_text('build_info_board = "x64"\n')
ninja_log = build_dir / ".ninja_log"
ninja_log.write_text("# ninja log v5\n")
mtime_recent = now - (2 * 86400)
import os
os.utime(ninja_log, (mtime_recent, mtime_recent))
self.assertTrue(wt.is_built_recently(max_age_days=7, now=now))
# Update .ninja_log to be 10 days old (> 7 days) -> not built recently (False)
mtime_old = now - (10 * 86400)
os.utime(ninja_log, (mtime_old, mtime_old))
self.assertFalse(wt.is_built_recently(max_age_days=7, now=now))
def test_get_summary(self) -> None:
tracker = WorktreeMetricsTracker(
self.jiri_root,
self.fuchsia_dir,
metrics_file_path=self.metrics_file,
snapshot_interval_sec=86400.0,
)
t0 = 1000.0
tracker.record_state(
current_total=2,
current_leased=1,
current_built_recently=1,
current_not_built_recently=1,
now=t0,
)
t1 = t0 + 3600.0 # 1 hour later
summary = tracker.get_summary(
current_total=4,
current_leased=2,
current_built_recently=3,
current_not_built_recently=1,
now=t1,
)
self.assertEqual(summary["current"]["total"], 4)
self.assertEqual(summary["current"]["leased"], 2)
self.assertEqual(summary["current"]["built_recently"], 3)
self.assertEqual(summary["current"]["not_built_recently"], 1)
self.assertEqual(summary["average"]["total"], 2.0)
self.assertEqual(summary["average"]["leased"], 1.0)
self.assertEqual(summary["min"]["total"], 2)
self.assertEqual(summary["max"]["total"], 4)
self.assertEqual(
summary["seconds_until_next_snapshot"], 86400.0 - 3600.0
)
if __name__ == "__main__":
unittest.main()