Refactor cockpit to use DockerTmuxController pattern
Based on claude-code-tools TmuxCLIController, this refactor: - Added DockerTmuxController class for robust tmux session management - Implements send_keys() with configurable delay_enter - Implements capture_pane() for output retrieval - Implements wait_for_prompt() for pattern-based completion detection - Implements wait_for_idle() for content-hash-based idle detection - Implements wait_for_shell_prompt() for shell prompt detection Also includes workflow improvements: - Pre-task git snapshot before agent execution - Post-task commit protocol in agent guidelines Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
436
tests/test_time_metrics.py
Normal file
436
tests/test_time_metrics.py
Normal file
@@ -0,0 +1,436 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test cases for time_metrics module.
|
||||
|
||||
Run with: pytest /opt/server-agents/orchestrator/tests/test_time_metrics.py -v
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
# Add lib to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "lib"))
|
||||
|
||||
import pytest
|
||||
|
||||
# Import module under test
|
||||
from time_metrics import (
|
||||
get_utc_now,
|
||||
get_utc_now_with_offset,
|
||||
parse_iso_timestamp,
|
||||
calculate_duration_seconds,
|
||||
format_duration,
|
||||
format_duration_human,
|
||||
elapsed_since,
|
||||
convert_to_local_time,
|
||||
format_timestamp_with_local,
|
||||
get_system_load,
|
||||
get_memory_usage,
|
||||
get_disk_usage,
|
||||
capture_system_context,
|
||||
TaskTimeTracker,
|
||||
create_task_time_metadata,
|
||||
update_task_completion_metadata,
|
||||
format_job_with_timing,
|
||||
format_logs_header,
|
||||
DEFAULT_TIMEZONE
|
||||
)
|
||||
|
||||
|
||||
class TestTimestampFunctions:
|
||||
"""Test timestamp generation and parsing."""
|
||||
|
||||
def test_get_utc_now_format(self):
|
||||
"""UTC timestamp should be in ISO 8601 format with Z suffix."""
|
||||
ts = get_utc_now()
|
||||
assert ts.endswith("Z")
|
||||
# Should be parseable
|
||||
dt = datetime.fromisoformat(ts[:-1])
|
||||
assert dt is not None
|
||||
|
||||
def test_get_utc_now_with_offset_format(self):
|
||||
"""UTC timestamp with offset should end with +00:00."""
|
||||
ts = get_utc_now_with_offset()
|
||||
assert "+00:00" in ts
|
||||
|
||||
def test_parse_iso_timestamp_z_suffix(self):
|
||||
"""Parse timestamp with Z suffix."""
|
||||
ts = "2026-01-11T03:31:57Z"
|
||||
dt = parse_iso_timestamp(ts)
|
||||
assert dt is not None
|
||||
assert dt.year == 2026
|
||||
assert dt.month == 1
|
||||
assert dt.day == 11
|
||||
assert dt.hour == 3
|
||||
assert dt.minute == 31
|
||||
assert dt.second == 57
|
||||
|
||||
def test_parse_iso_timestamp_with_offset(self):
|
||||
"""Parse timestamp with timezone offset."""
|
||||
ts = "2026-01-11T00:31:57-03:00"
|
||||
dt = parse_iso_timestamp(ts)
|
||||
assert dt is not None
|
||||
assert dt.hour == 0 # Local hour
|
||||
|
||||
def test_parse_iso_timestamp_no_tz(self):
|
||||
"""Parse timestamp without timezone (assume UTC)."""
|
||||
ts = "2026-01-11T03:31:57"
|
||||
dt = parse_iso_timestamp(ts)
|
||||
assert dt is not None
|
||||
assert dt.hour == 3
|
||||
|
||||
def test_parse_iso_timestamp_none(self):
|
||||
"""None input should return None."""
|
||||
assert parse_iso_timestamp(None) is None
|
||||
assert parse_iso_timestamp("") is None
|
||||
|
||||
def test_parse_iso_timestamp_invalid(self):
|
||||
"""Invalid timestamp should return None."""
|
||||
assert parse_iso_timestamp("not-a-timestamp") is None
|
||||
assert parse_iso_timestamp("2026-99-99T99:99:99Z") is None
|
||||
|
||||
|
||||
class TestDurationCalculations:
|
||||
"""Test duration calculation and formatting."""
|
||||
|
||||
def test_calculate_duration_seconds(self):
|
||||
"""Calculate duration between two timestamps."""
|
||||
start = "2026-01-11T10:00:00Z"
|
||||
end = "2026-01-11T10:01:00Z"
|
||||
duration = calculate_duration_seconds(start, end)
|
||||
assert duration == 60.0
|
||||
|
||||
def test_calculate_duration_hours(self):
|
||||
"""Calculate duration spanning hours."""
|
||||
start = "2026-01-11T10:00:00Z"
|
||||
end = "2026-01-11T12:30:00Z"
|
||||
duration = calculate_duration_seconds(start, end)
|
||||
assert duration == 2.5 * 3600 # 2.5 hours
|
||||
|
||||
def test_calculate_duration_negative(self):
|
||||
"""Duration can be negative if end is before start."""
|
||||
start = "2026-01-11T12:00:00Z"
|
||||
end = "2026-01-11T10:00:00Z"
|
||||
duration = calculate_duration_seconds(start, end)
|
||||
assert duration < 0
|
||||
|
||||
def test_calculate_duration_none(self):
|
||||
"""Invalid inputs should return None."""
|
||||
assert calculate_duration_seconds(None, "2026-01-11T10:00:00Z") is None
|
||||
assert calculate_duration_seconds("2026-01-11T10:00:00Z", None) is None
|
||||
|
||||
def test_format_duration_seconds(self):
|
||||
"""Format durations under a minute."""
|
||||
assert format_duration(0) == "00:00:00"
|
||||
assert format_duration(45) == "00:00:45"
|
||||
assert format_duration(59) == "00:00:59"
|
||||
|
||||
def test_format_duration_minutes(self):
|
||||
"""Format durations in minutes."""
|
||||
assert format_duration(60) == "00:01:00"
|
||||
assert format_duration(125) == "00:02:05"
|
||||
assert format_duration(3599) == "00:59:59"
|
||||
|
||||
def test_format_duration_hours(self):
|
||||
"""Format durations in hours."""
|
||||
assert format_duration(3600) == "01:00:00"
|
||||
assert format_duration(3661) == "01:01:01"
|
||||
assert format_duration(7200) == "02:00:00"
|
||||
|
||||
def test_format_duration_none(self):
|
||||
"""None or negative should return placeholder."""
|
||||
assert format_duration(None) == "--:--:--"
|
||||
assert format_duration(-1) == "--:--:--"
|
||||
|
||||
def test_format_duration_human_seconds(self):
|
||||
"""Human-readable format for seconds."""
|
||||
assert format_duration_human(0) == "0s"
|
||||
assert format_duration_human(45) == "45s"
|
||||
assert format_duration_human(59) == "59s"
|
||||
|
||||
def test_format_duration_human_minutes(self):
|
||||
"""Human-readable format for minutes."""
|
||||
assert format_duration_human(60) == "1m 0s"
|
||||
assert format_duration_human(125) == "2m 5s"
|
||||
assert format_duration_human(3599) == "59m 59s"
|
||||
|
||||
def test_format_duration_human_hours(self):
|
||||
"""Human-readable format for hours."""
|
||||
assert format_duration_human(3600) == "1h 0m 0s"
|
||||
assert format_duration_human(3661) == "1h 1m 1s"
|
||||
assert format_duration_human(7200) == "2h 0m 0s"
|
||||
|
||||
def test_format_duration_human_days(self):
|
||||
"""Human-readable format for days."""
|
||||
assert format_duration_human(86400) == "1d 0h 0m"
|
||||
assert format_duration_human(90061) == "1d 1h 1m"
|
||||
|
||||
def test_format_duration_human_none(self):
|
||||
"""None or negative should return 'unknown'."""
|
||||
assert format_duration_human(None) == "unknown"
|
||||
assert format_duration_human(-1) == "unknown"
|
||||
|
||||
|
||||
class TestTimezoneConversion:
|
||||
"""Test timezone conversion functions."""
|
||||
|
||||
def test_convert_to_local_time_montevideo(self):
|
||||
"""Convert UTC to Montevideo time (UTC-3)."""
|
||||
utc_ts = "2026-01-11T03:31:57Z"
|
||||
local = convert_to_local_time(utc_ts, "America/Montevideo")
|
||||
# Should be 00:31:57 local (UTC-3)
|
||||
assert "00:31:57" in local
|
||||
|
||||
def test_convert_to_local_time_invalid(self):
|
||||
"""Invalid timestamp should return original."""
|
||||
result = convert_to_local_time("invalid", "America/Montevideo")
|
||||
assert "invalid" in result
|
||||
|
||||
def test_format_timestamp_with_local(self):
|
||||
"""Format timestamp showing both UTC and local."""
|
||||
utc_ts = "2026-01-11T03:31:57Z"
|
||||
result = format_timestamp_with_local(utc_ts, "America/Montevideo")
|
||||
assert "2026-01-11T03:31:57Z" in result
|
||||
assert "America/Montevideo" in result
|
||||
|
||||
|
||||
class TestSystemContext:
|
||||
"""Test system context capture functions."""
|
||||
|
||||
def test_get_system_load_returns_tuple(self):
|
||||
"""System load should return 3-element tuple."""
|
||||
load = get_system_load()
|
||||
assert isinstance(load, tuple)
|
||||
assert len(load) == 3
|
||||
assert all(isinstance(l, (int, float)) for l in load)
|
||||
|
||||
def test_get_memory_usage_keys(self):
|
||||
"""Memory usage should have expected keys."""
|
||||
mem = get_memory_usage()
|
||||
assert "total_mb" in mem
|
||||
assert "available_mb" in mem
|
||||
assert "used_mb" in mem
|
||||
assert "used_percent" in mem
|
||||
assert 0 <= mem["used_percent"] <= 100
|
||||
|
||||
def test_get_disk_usage_keys(self):
|
||||
"""Disk usage should have expected keys."""
|
||||
disk = get_disk_usage("/")
|
||||
assert "total_gb" in disk
|
||||
assert "free_gb" in disk
|
||||
assert "used_gb" in disk
|
||||
assert "used_percent" in disk
|
||||
assert 0 <= disk["used_percent"] <= 100
|
||||
|
||||
def test_capture_system_context_structure(self):
|
||||
"""System context should have complete structure."""
|
||||
ctx = capture_system_context()
|
||||
assert "timestamp" in ctx
|
||||
assert "system_load" in ctx
|
||||
assert "memory" in ctx
|
||||
assert "disk" in ctx
|
||||
|
||||
assert isinstance(ctx["system_load"], list)
|
||||
assert len(ctx["system_load"]) == 3
|
||||
|
||||
assert "used_percent" in ctx["memory"]
|
||||
assert "available_mb" in ctx["memory"]
|
||||
|
||||
assert "used_percent" in ctx["disk"]
|
||||
assert "free_gb" in ctx["disk"]
|
||||
|
||||
|
||||
class TestTaskTimeTracker:
|
||||
"""Test TaskTimeTracker class."""
|
||||
|
||||
def test_tracker_initialization(self):
|
||||
"""Tracker should initialize with task_id and project."""
|
||||
tracker = TaskTimeTracker("test-001", "admin")
|
||||
assert tracker.task_id == "test-001"
|
||||
assert tracker.project == "admin"
|
||||
assert tracker.dispatch_time is None
|
||||
assert tracker.completion_time is None
|
||||
|
||||
def test_mark_dispatched(self):
|
||||
"""mark_dispatched should record dispatch time and context."""
|
||||
tracker = TaskTimeTracker("test-001", "admin")
|
||||
result = tracker.mark_dispatched()
|
||||
|
||||
assert tracker.dispatch_time is not None
|
||||
assert tracker.dispatch_context is not None
|
||||
|
||||
assert "dispatch" in result
|
||||
assert "utc_time" in result["dispatch"]
|
||||
assert "system_load" in result["dispatch"]
|
||||
assert "memory_percent" in result["dispatch"]
|
||||
|
||||
def test_mark_started(self):
|
||||
"""mark_started should record start time."""
|
||||
tracker = TaskTimeTracker("test-001", "admin")
|
||||
tracker.mark_dispatched()
|
||||
result = tracker.mark_started()
|
||||
|
||||
assert tracker.start_time is not None
|
||||
assert "start_time" in result
|
||||
|
||||
def test_mark_completed(self):
|
||||
"""mark_completed should calculate duration."""
|
||||
tracker = TaskTimeTracker("test-001", "admin")
|
||||
tracker.mark_dispatched()
|
||||
time.sleep(1.1) # Delay for measurable duration (must be > 1 sec for second resolution)
|
||||
result = tracker.mark_completed(exit_code=0)
|
||||
|
||||
assert tracker.completion_time is not None
|
||||
assert "completion" in result
|
||||
assert "utc_time" in result["completion"]
|
||||
assert "duration_seconds" in result["completion"]
|
||||
# Duration should be at least 1 second
|
||||
assert result["completion"]["duration_seconds"] >= 1.0
|
||||
assert "exit_code" in result["completion"]
|
||||
assert result["completion"]["exit_code"] == 0
|
||||
|
||||
def test_get_full_metrics_running(self):
|
||||
"""get_full_metrics for running task should show elapsed."""
|
||||
tracker = TaskTimeTracker("test-001", "admin")
|
||||
tracker.mark_dispatched()
|
||||
metrics = tracker.get_full_metrics()
|
||||
|
||||
assert metrics["status"] == "running"
|
||||
assert "elapsed" in metrics
|
||||
assert "dispatch" in metrics
|
||||
|
||||
def test_get_full_metrics_completed(self):
|
||||
"""get_full_metrics for completed task should show duration."""
|
||||
tracker = TaskTimeTracker("test-001", "admin")
|
||||
tracker.mark_dispatched()
|
||||
tracker.mark_completed(0)
|
||||
metrics = tracker.get_full_metrics()
|
||||
|
||||
assert metrics["status"] == "completed"
|
||||
assert "completion" in metrics
|
||||
assert "duration_seconds" in metrics["completion"]
|
||||
|
||||
|
||||
class TestMetadataFunctions:
|
||||
"""Test metadata helper functions."""
|
||||
|
||||
def test_create_task_time_metadata(self):
|
||||
"""create_task_time_metadata should return dispatch info."""
|
||||
meta = create_task_time_metadata("test-001", "admin")
|
||||
|
||||
assert "time_metrics" in meta
|
||||
assert "time_tracker_data" in meta
|
||||
assert "dispatch" in meta["time_metrics"]
|
||||
assert meta["time_tracker_data"]["task_id"] == "test-001"
|
||||
assert meta["time_tracker_data"]["project"] == "admin"
|
||||
|
||||
def test_update_task_completion_metadata(self):
|
||||
"""update_task_completion_metadata should add completion info."""
|
||||
# Create initial metadata
|
||||
meta = create_task_time_metadata("test-001", "admin")
|
||||
time.sleep(0.1)
|
||||
|
||||
# Update with completion
|
||||
updated = update_task_completion_metadata(meta, exit_code=0)
|
||||
|
||||
assert "time_metrics" in updated
|
||||
assert "completion" in updated["time_metrics"]
|
||||
assert updated["time_metrics"]["completion"]["exit_code"] == 0
|
||||
|
||||
|
||||
class TestOutputFormatters:
|
||||
"""Test output formatting functions."""
|
||||
|
||||
def test_format_job_with_timing_complete(self):
|
||||
"""Format job with timing info should include all fields."""
|
||||
job = {
|
||||
"id": "123456-abcd",
|
||||
"project": "admin",
|
||||
"status": "completed",
|
||||
"time_metrics": {
|
||||
"dispatch": {
|
||||
"utc_time": "2026-01-11T10:00:00Z",
|
||||
"system_load": [0.5, 0.6, 0.7]
|
||||
},
|
||||
"completion": {
|
||||
"duration_formatted": "00:05:30"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = format_job_with_timing(job)
|
||||
assert "123456-abcd" in result
|
||||
assert "admin" in result
|
||||
assert "completed" in result
|
||||
assert "10:00:00" in result
|
||||
assert "00:05:30" in result
|
||||
|
||||
def test_format_job_with_timing_running(self):
|
||||
"""Format running job should show elapsed time."""
|
||||
job = {
|
||||
"id": "123456-abcd",
|
||||
"project": "admin",
|
||||
"status": "running",
|
||||
"time_metrics": {
|
||||
"dispatch": {
|
||||
"utc_time": "2026-01-11T10:00:00Z",
|
||||
"system_load": [0.5, 0.6, 0.7]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = format_job_with_timing(job)
|
||||
assert "123456-abcd" in result
|
||||
assert "running" in result
|
||||
|
||||
def test_format_logs_header_structure(self):
|
||||
"""Logs header should contain timing sections."""
|
||||
job = {
|
||||
"id": "123456-abcd",
|
||||
"project": "admin",
|
||||
"status": "completed",
|
||||
"time_metrics": {
|
||||
"dispatch": {
|
||||
"utc_time": "2026-01-11T10:00:00Z",
|
||||
"system_load": [0.5, 0.6, 0.7],
|
||||
"memory_percent": 65,
|
||||
"disk_percent": 45
|
||||
},
|
||||
"completion": {
|
||||
"utc_time": "2026-01-11T10:05:30Z",
|
||||
"duration_formatted": "00:05:30"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
header = format_logs_header(job)
|
||||
assert "═" in header # Box drawing
|
||||
assert "Job:" in header
|
||||
assert "Agent: admin" in header
|
||||
assert "Dispatched:" in header
|
||||
assert "Status:" in header
|
||||
assert "System:" in header
|
||||
|
||||
|
||||
class TestElapsedSince:
|
||||
"""Test elapsed_since function."""
|
||||
|
||||
def test_elapsed_since_recent(self):
|
||||
"""elapsed_since should calculate time from now."""
|
||||
# Use a timestamp 5 seconds ago
|
||||
past = datetime.utcnow() - timedelta(seconds=5)
|
||||
past_ts = past.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
elapsed = elapsed_since(past_ts)
|
||||
# Should be around 5s (allow some tolerance)
|
||||
assert "s" in elapsed # Should have seconds format
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
Reference in New Issue
Block a user