4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
150 lines
5.3 KiB
Python
150 lines
5.3 KiB
Python
"""Shared Hermes-log polling helper for the test suite.
|
|
|
|
This is the test-side counterpart to ``integrations.hermes.tools.hermes_logs_tool``: it
|
|
wraps :func:`integrations.hermes.poller.poll_hermes_logs` with the small
|
|
ergonomics every Hermes test ends up wanting — a temp-file context
|
|
manager that appends lines with realistic line endings and flushing,
|
|
a cursor-aware polling loop with a deadline, and helpers that pull
|
|
records and incidents out of the underlying primitive without each
|
|
test re-rolling its own boilerplate.
|
|
|
|
Centralising this in one helper means production code and tests
|
|
exercise the **exact same** polling engine. A regression in the poller
|
|
(rotation-safe rewind, cursor token round-trip, byte budget enforcement)
|
|
fails the synthetic suite as well as the unit tests for the tool.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from collections.abc import Iterable, Iterator
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
from integrations.hermes.classifier import IncidentClassifier
|
|
from integrations.hermes.incident import HermesIncident, LogLevel, LogRecord
|
|
from integrations.hermes.poller import HermesLogCursor, HermesLogPoll, poll_hermes_logs
|
|
|
|
_DEFAULT_POLL_BUDGET_S: float = 5.0
|
|
_DEFAULT_POLL_INTERVAL_S: float = 0.02
|
|
|
|
|
|
@dataclass
|
|
class HermesLogFixture:
|
|
"""Mutable wrapper around a temp log file for tests."""
|
|
|
|
path: Path
|
|
cursor: HermesLogCursor
|
|
classifier: IncidentClassifier = field(default_factory=IncidentClassifier)
|
|
accumulated_records: list[LogRecord] = field(default_factory=list)
|
|
accumulated_incidents: list[HermesIncident] = field(default_factory=list)
|
|
|
|
def write_line(self, line: str) -> None:
|
|
"""Append a single log line (newline added if missing)."""
|
|
if not line.endswith("\n"):
|
|
line = line + "\n"
|
|
with self.path.open("a", encoding="utf-8") as handle:
|
|
handle.write(line)
|
|
handle.flush()
|
|
|
|
def write_lines(self, lines: Iterable[str], *, stagger_s: float = 0.0) -> None:
|
|
"""Append many lines, optionally pausing between writes.
|
|
|
|
``stagger_s`` mimics real-time log production for tests that
|
|
exercise the live-tail behaviour through HermesAgent's daemon
|
|
thread; for synchronous poll-based tests leave it at 0.
|
|
"""
|
|
for line in lines:
|
|
self.write_line(line)
|
|
if stagger_s:
|
|
time.sleep(stagger_s)
|
|
|
|
def poll_once(
|
|
self,
|
|
*,
|
|
max_lines: int | None = 2000,
|
|
level_filter: frozenset[LogLevel] | None = None,
|
|
) -> HermesLogPoll:
|
|
"""Drain whatever is new and advance the cursor."""
|
|
poll = poll_hermes_logs(
|
|
self.path,
|
|
self.cursor,
|
|
max_lines=max_lines,
|
|
classifier=self.classifier,
|
|
level_filter=level_filter,
|
|
)
|
|
self.cursor = poll.cursor
|
|
self.accumulated_records.extend(poll.records)
|
|
self.accumulated_incidents.extend(poll.incidents)
|
|
return poll
|
|
|
|
def poll_until(
|
|
self,
|
|
predicate,
|
|
*,
|
|
budget_s: float = _DEFAULT_POLL_BUDGET_S,
|
|
interval_s: float = _DEFAULT_POLL_INTERVAL_S,
|
|
) -> bool:
|
|
"""Poll repeatedly until ``predicate(fixture)`` becomes True.
|
|
|
|
Returns ``True`` if the predicate was satisfied within the
|
|
budget, ``False`` otherwise. Useful in combination with the
|
|
live ``HermesAgent`` daemon thread: the test writes lines,
|
|
then waits for the classifier to surface a specific incident.
|
|
|
|
``predicate`` is called *after each poll* with the fixture so
|
|
it sees the accumulated state — typical use:
|
|
|
|
::
|
|
|
|
fixture.write_lines(scenario.log_lines)
|
|
assert fixture.poll_until(
|
|
lambda f: any(i.rule == "warning_burst" for i in f.accumulated_incidents)
|
|
)
|
|
"""
|
|
deadline = time.monotonic() + budget_s
|
|
while time.monotonic() < deadline:
|
|
self.poll_once()
|
|
if predicate(self):
|
|
return True
|
|
time.sleep(interval_s)
|
|
# Final poll after the deadline for clear error messages.
|
|
self.poll_once()
|
|
return predicate(self)
|
|
|
|
def rule_counts(self) -> dict[str, int]:
|
|
counts: dict[str, int] = {}
|
|
for incident in self.accumulated_incidents:
|
|
counts[incident.rule] = counts.get(incident.rule, 0) + 1
|
|
return counts
|
|
|
|
def incidents_by_rule(self, rule: str) -> list[HermesIncident]:
|
|
return [i for i in self.accumulated_incidents if i.rule == rule]
|
|
|
|
|
|
@contextmanager
|
|
def hermes_log_fixture(
|
|
tmp_path: Path, *, filename: str = "errors.log"
|
|
) -> Iterator[HermesLogFixture]:
|
|
"""Context manager yielding a :class:`HermesLogFixture` rooted at
|
|
``tmp_path/filename``.
|
|
|
|
The file is created empty (so the poller's first :func:`stat`
|
|
captures device + inode) and the cursor is anchored at
|
|
end-of-file so writes appended inside the block are observed by
|
|
the next :meth:`HermesLogFixture.poll_once`. Tearing down the
|
|
context does nothing — pytest's ``tmp_path`` fixture handles
|
|
cleanup.
|
|
"""
|
|
log_path = tmp_path / filename
|
|
log_path.touch()
|
|
cursor = HermesLogCursor.at_end(log_path)
|
|
yield HermesLogFixture(path=log_path, cursor=cursor)
|
|
|
|
|
|
__all__ = [
|
|
"HermesLogFixture",
|
|
"hermes_log_fixture",
|
|
]
|