Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

566 lines
20 KiB
Python

"""Tests for REPL task registry and /tasks · /cancel."""
from __future__ import annotations
import io
import json
import os
import tempfile
from collections.abc import Callable, Iterator
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from rich.console import Console
from config.constants.prompts import SUGGESTED_PROMPT_AFTER_FAILED_SYNTHETIC_TEST
from platform.common.task_registry import TaskRegistry
from platform.common.task_types import TaskKind, TaskStatus
from surfaces.interactive_shell.command_registry import dispatch_slash
from surfaces.interactive_shell.session import (
Session,
)
from tools.interactive_shell.synthetic.runner import watch_synthetic_subprocess
def _capture() -> tuple[Console, io.StringIO]:
buf = io.StringIO()
return Console(file=buf, force_terminal=False, highlight=False), buf
@pytest.fixture
def stderr_buf() -> Iterator[tempfile.SpooledTemporaryFile]: # type: ignore[type-arg]
"""Stderr buffer for synthetic-watcher tests.
The watcher's ``finally`` block closes the buffer; the ``with`` here
is the belt-and-braces close that runs when a test exits before the
watcher does (e.g. deferred-thread tests where ``pending[0]()`` is
never invoked).
"""
with tempfile.SpooledTemporaryFile() as buf:
yield buf
class TestTaskRecord:
def test_lifecycle_completed(self) -> None:
reg = TaskRegistry()
t = reg.create(TaskKind.INVESTIGATION)
assert t.status == TaskStatus.PENDING
t.mark_running()
assert t.status == TaskStatus.RUNNING
t.mark_completed(result="done")
assert t.status == TaskStatus.COMPLETED
assert t.result == "done"
assert t.ended_at is not None
t.mark_failed("x")
assert t.status == TaskStatus.COMPLETED
def test_mark_cancelled_idempotent_after_terminal(self) -> None:
reg = TaskRegistry()
t = reg.create(TaskKind.INVESTIGATION)
t.mark_running()
t.mark_cancelled()
assert t.status == TaskStatus.CANCELLED
t.mark_completed(result="nope")
assert t.status == TaskStatus.CANCELLED
def test_request_cancel_sets_event_even_when_pending(self) -> None:
reg = TaskRegistry()
t = reg.create(TaskKind.INVESTIGATION)
assert t.request_cancel() is False
assert t.cancel_requested.is_set()
assert t.status == TaskStatus.PENDING
def test_request_cancel_sets_event_and_terminates_process(self) -> None:
reg = TaskRegistry()
t = reg.create(TaskKind.SYNTHETIC_TEST)
t.mark_running()
proc = MagicMock()
proc.poll.return_value = None
t.attach_process(proc)
assert t.request_cancel() is True
proc.terminate.assert_called_once()
assert t.cancel_requested.is_set()
class TestTaskRegistry:
def test_get_single_prefix_match(self) -> None:
reg = TaskRegistry()
t = reg.create(TaskKind.INVESTIGATION)
assert reg.get(t.task_id[:4]) == t
assert reg.get("") is None
def test_candidates_ambiguous_prefix(self, monkeypatch: pytest.MonkeyPatch) -> None:
_ids = iter(["11111111", "11112222"])
def _fake_hex(_nbytes: int) -> str:
return next(_ids)
monkeypatch.setattr("platform.common.task_registry.secrets.token_hex", _fake_hex)
session = Session()
session.task_registry.create(TaskKind.INVESTIGATION)
session.task_registry.create(TaskKind.INVESTIGATION)
console, buf = _capture()
dispatch_slash("/cancel 1111", session, console)
assert "ambiguous" in buf.getvalue().lower()
def test_ring_buffer_drops_oldest(self) -> None:
reg = TaskRegistry(max_tasks=3)
first = reg.create(TaskKind.INVESTIGATION)
reg.create(TaskKind.INVESTIGATION)
reg.create(TaskKind.INVESTIGATION)
reg.create(TaskKind.INVESTIGATION)
recent_ids = [t.task_id for t in reg.list_recent(10)]
assert first.task_id not in recent_ids
assert len(recent_ids) == 3
def test_persistent_registry_reloads_running_pid(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
import config.constants as const_module
monkeypatch.setattr(const_module, "OPENSRE_HOME_DIR", tmp_path)
reg = TaskRegistry.persistent()
task = reg.create(TaskKind.SYNTHETIC_TEST, command="opensre tests synthetic")
task.mark_running()
task.attach_pid(os.getpid())
reloaded = TaskRegistry.persistent()
[loaded] = reloaded.list_recent()
assert loaded.task_id == task.task_id
assert loaded.status == TaskStatus.RUNNING
assert loaded.pid == os.getpid()
assert loaded.command == "opensre tests synthetic"
def test_persistent_registry_marks_missing_pid_finished(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
import config.constants as const_module
monkeypatch.setattr(const_module, "OPENSRE_HOME_DIR", tmp_path)
store_path = tmp_path / "interactive_tasks.json"
store_path.parent.mkdir(parents=True, exist_ok=True)
store_path.write_text(
json.dumps(
[
{
"task_id": "abc12345",
"kind": "synthetic_test",
"status": "running",
"started_at": 1.0,
"ended_at": None,
"result": None,
"error": None,
"pid": 999_999,
"command": "opensre tests synthetic",
}
]
),
encoding="utf-8",
)
monkeypatch.setattr(
"platform.common.task_types.os.kill",
lambda _pid, _sig: (_ for _ in ()).throw(ProcessLookupError()),
)
reloaded = TaskRegistry.persistent()
[loaded] = reloaded.list_recent()
assert loaded.status == TaskStatus.COMPLETED
assert loaded.result == "process exited while shell was closed"
def test_cancel_rehydrated_task_does_not_signal_pid(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
import config.constants as const_module
monkeypatch.setattr(const_module, "OPENSRE_HOME_DIR", tmp_path)
calls: list[tuple[int, int]] = []
def _fake_kill(pid: int, sig: int) -> None:
calls.append((pid, sig))
monkeypatch.setattr("platform.common.task_types.os.kill", _fake_kill)
reg = TaskRegistry.persistent()
task = reg.create(TaskKind.SYNTHETIC_TEST, command="opensre tests synthetic")
task.mark_running()
task.attach_pid(12345)
reloaded = TaskRegistry.persistent()
loaded = reloaded.get(task.task_id)
assert loaded is not None
assert loaded.request_cancel() is True
assert loaded.status == TaskStatus.CANCELLED
assert (12345, 15) not in calls
def test_session_new_does_not_truncate_persistent_task_store(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
import config.constants as const_module
monkeypatch.setattr(const_module, "OPENSRE_HOME_DIR", tmp_path)
session = Session()
session.task_registry = TaskRegistry.persistent()
task = session.task_registry.create(TaskKind.SYNTHETIC_TEST, command="opensre tests")
task.mark_running()
task.mark_completed(result="ok")
session.clear()
# /new must keep the on-disk store intact: a fresh persistent registry
# still finds the task, and the session's swapped-in registry continues
# to surface persisted history via its disk-backed merge so /tasks does
# not "forget" the user's prior runs after /new.
reloaded = TaskRegistry.persistent()
[loaded] = reloaded.list_recent()
assert loaded.task_id == task.task_id
assert loaded.command == "opensre tests"
[visible_after_new] = session.task_registry.list_recent()
assert visible_after_new.task_id == task.task_id
class TestSlashTaskCommands:
def test_tasks_empty_message(self) -> None:
session = Session()
console, buf = _capture()
dispatch_slash("/tasks", session, console)
assert "no tasks" in buf.getvalue().lower()
def test_tasks_shows_recent_rows(self) -> None:
session = Session()
t = session.task_registry.create(TaskKind.INVESTIGATION)
t.mark_running()
t.mark_completed(result="rc")
console, buf = _capture()
dispatch_slash("/tasks", session, console)
out = buf.getvalue()
assert t.task_id in out
assert "investigation" in out
assert "completed" in out
def test_cancel_usage_without_id(self) -> None:
session = Session()
console, buf = _capture()
dispatch_slash("/cancel", session, console)
assert "usage" in buf.getvalue().lower()
def test_cancel_unknown_id(self) -> None:
session = Session()
console, buf = _capture()
dispatch_slash("/cancel deadbeef", session, console)
assert "no task" in buf.getvalue().lower()
def test_cancel_completed_task_message(self) -> None:
session = Session()
t = session.task_registry.create(TaskKind.INVESTIGATION)
t.mark_running()
t.mark_completed(result="x")
console, buf = _capture()
dispatch_slash(f"/cancel {t.task_id}", session, console)
assert "already finished" in buf.getvalue().lower()
def test_cancel_running_investigation_signals(self) -> None:
session = Session()
t = session.task_registry.create(TaskKind.INVESTIGATION)
t.mark_running()
console, buf = _capture()
dispatch_slash(f"/cancel {t.task_id}", session, console)
out = buf.getvalue()
assert "cancellation" in out.lower()
assert "Ctrl+C" in out
def test_cancel_running_synthetic_signals_and_terminates_process(self) -> None:
session = Session()
t = session.task_registry.create(TaskKind.SYNTHETIC_TEST)
t.mark_running()
proc = MagicMock()
proc.poll.return_value = None
t.attach_process(proc)
console, buf = _capture()
dispatch_slash(f"/cancel {t.task_id}", session, console)
assert t.cancel_requested.is_set()
proc.terminate.assert_called_once()
out = buf.getvalue()
assert "stop requested" in out.lower()
assert t.task_id in out
class _ImmediateThread:
"""Run ``target`` synchronously inside ``start()`` (for deterministic tests)."""
def __init__(
self,
group: object = None,
target: Callable[[], None] | None = None,
name: object = None,
args: tuple[object, ...] = (),
kwargs: dict[str, object] | None = None,
*,
daemon: object = None,
) -> None:
del group, args, kwargs, daemon, name # threaded API baggage
if target is None:
raise TypeError("target required")
self._target = target
def start(self) -> None:
self._target()
class _DeferredSyntheticThread:
"""Queue ``target`` via ``start()``; tests invoke queued callables explicitly."""
pending: list[Callable[[], None]] = []
def __init__(
self,
group: object = None,
target: Callable[[], None] | None = None,
name: object = None,
args: tuple[object, ...] = (),
kwargs: dict[str, object] | None = None,
*,
daemon: object = None,
) -> None:
del group, args, kwargs, daemon, name
if target is None:
raise TypeError("target required")
self._target = target
def start(self) -> None:
_DeferredSyntheticThread.pending.append(self._target)
def _synthetic_presenter(session: Session) -> MagicMock:
presenter = MagicMock()
presenter.session = session
presenter.start_task_output_streams.return_value = []
presenter.join_task_output_streams.return_value = None
presenter.report_exception.return_value = None
presenter.print_error.return_value = None
return presenter
class TestSyntheticSubprocessWatcher:
def test_watch_marks_completed_when_process_already_done(
self,
monkeypatch: pytest.MonkeyPatch,
stderr_buf: tempfile.SpooledTemporaryFile, # type: ignore[type-arg]
) -> None:
monkeypatch.setattr(
"tools.interactive_shell.synthetic.runner.threading.Thread",
_ImmediateThread,
)
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
session = Session()
presenter = _synthetic_presenter(session)
task = session.task_registry.create(TaskKind.SYNTHETIC_TEST)
task.mark_running()
task.attach_process(proc)
watch_synthetic_subprocess(task, proc, presenter, "rds_postgres", stderr_buf)
assert task.status == TaskStatus.COMPLETED
hist = session.history[-1]
assert hist["type"] == "synthetic_test"
assert hist["ok"] is True
assert "task:" in hist["text"]
def test_watch_honours_exit_code_when_cancel_races_loop_exit(
self,
monkeypatch: pytest.MonkeyPatch,
stderr_buf: tempfile.SpooledTemporaryFile, # type: ignore[type-arg]
) -> None:
"""Process exits naturally (code 0) in the same poll tick that /cancel fires.
The poll loop exits because proc.poll() returns non-None *before* the
cancel_requested branch runs, so terminated_by_watcher stays False.
The task must be COMPLETED, not CANCELLED — the process succeeded.
"""
monkeypatch.setattr(
"tools.interactive_shell.synthetic.runner.threading.Thread",
_ImmediateThread,
)
session = Session()
presenter = _synthetic_presenter(session)
task = session.task_registry.create(TaskKind.SYNTHETIC_TEST)
task.mark_running()
proc = MagicMock()
# poll() returns None once (enter loop body), then cancel fires via
# sleep, which also makes poll return 0 — loop exits via while condition.
pending: list[int | None] = [None]
def _poll_side() -> int | None:
return pending[0]
proc.poll.side_effect = _poll_side
proc.returncode = 0
sleeps: list[float] = []
def _fake_sleep(_secs: float) -> None:
sleeps.append(_secs)
task.cancel_requested.set()
pending[0] = 0 # process finishes naturally in the same window
monkeypatch.setattr("tools.interactive_shell.subprocess.time.sleep", _fake_sleep)
watch_synthetic_subprocess(task, proc, presenter, "rds_postgres", stderr_buf)
# terminated_by_watcher is False → honour exit code 0 → COMPLETED
assert task.status == TaskStatus.COMPLETED
assert sleeps
def test_watch_marks_cancelled_when_watcher_kills_process(
self,
monkeypatch: pytest.MonkeyPatch,
stderr_buf: tempfile.SpooledTemporaryFile, # type: ignore[type-arg]
) -> None:
"""cancel_requested is set while proc is still running; watcher terminates it."""
monkeypatch.setattr(
"tools.interactive_shell.synthetic.runner.threading.Thread",
_ImmediateThread,
)
session = Session()
presenter = _synthetic_presenter(session)
task = session.task_registry.create(TaskKind.SYNTHETIC_TEST)
task.mark_running()
proc = MagicMock()
# poll() always returns None so the watcher's cancel branch runs and
# calls _terminate_child_process; returncode is set after that.
proc.poll.return_value = None
proc.returncode = -15
task.cancel_requested.set() # cancel already set before first loop check
# Skip the sleep so the loop iterates immediately to the cancel branch.
monkeypatch.setattr("tools.interactive_shell.subprocess.time.sleep", lambda _: None)
watch_synthetic_subprocess(task, proc, presenter, "rds_postgres", stderr_buf)
assert task.status == TaskStatus.CANCELLED
hist = session.history[-1]
assert hist["type"] == "synthetic_test"
assert hist["ok"] is False
def test_watch_honours_exit_code_when_cancel_races_natural_exit(
self,
monkeypatch: pytest.MonkeyPatch,
stderr_buf: tempfile.SpooledTemporaryFile, # type: ignore[type-arg]
) -> None:
"""Process exits naturally (code 0) while /cancel fires concurrently.
The watcher should mark the task COMPLETED, not CANCELLED, because we
never called _terminate_child_process — the process was already gone.
"""
monkeypatch.setattr(
"tools.interactive_shell.synthetic.runner.threading.Thread",
_ImmediateThread,
)
session = Session()
presenter = _synthetic_presenter(session)
task = session.task_registry.create(TaskKind.SYNTHETIC_TEST)
task.mark_running()
proc = MagicMock()
# Process already finished; poll returns non-None immediately so the
# while-loop body never executes — terminated_by_watcher stays False.
proc.poll.return_value = 0
proc.returncode = 0
# Simulate /cancel arriving just as the watcher reads poll()
task.cancel_requested.set()
watch_synthetic_subprocess(task, proc, presenter, "rds_postgres", stderr_buf)
assert task.status == TaskStatus.COMPLETED
def test_watch_captures_stderr_on_failure(
self,
monkeypatch: pytest.MonkeyPatch,
stderr_buf: tempfile.SpooledTemporaryFile, # type: ignore[type-arg]
) -> None:
"""Diagnostic stderr output is included in mark_failed message."""
monkeypatch.setattr(
"tools.interactive_shell.synthetic.runner.threading.Thread",
_ImmediateThread,
)
session = Session()
presenter = _synthetic_presenter(session)
task = session.task_registry.create(TaskKind.SYNTHETIC_TEST)
task.mark_running()
proc = MagicMock()
proc.poll.return_value = 1
proc.returncode = 1
stderr_buf.write(b"ConnectionError: database unreachable\n")
watch_synthetic_subprocess(task, proc, presenter, "rds_postgres", stderr_buf)
assert task.status == TaskStatus.FAILED
assert "exit code 1" in (task.error or "")
assert "ConnectionError" in (task.error or "")
assert (
session.terminal.pending_prompt_default == SUGGESTED_PROMPT_AFTER_FAILED_SYNTHETIC_TEST
)
def test_watch_skips_synthetic_history_after_reset(
self,
monkeypatch: pytest.MonkeyPatch,
stderr_buf: tempfile.SpooledTemporaryFile, # type: ignore[type-arg]
) -> None:
_DeferredSyntheticThread.pending.clear()
monkeypatch.setattr(
"tools.interactive_shell.synthetic.runner.threading.Thread",
_DeferredSyntheticThread,
)
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
session = Session()
presenter = _synthetic_presenter(session)
task = session.task_registry.create(TaskKind.SYNTHETIC_TEST)
task.mark_running()
task.attach_process(proc)
watch_synthetic_subprocess(task, proc, presenter, "rds_postgres", stderr_buf)
assert len(_DeferredSyntheticThread.pending) == 1
session.clear()
_DeferredSyntheticThread.pending[0]()
assert session.history == []
_DeferredSyntheticThread.pending.clear()
def test_deferred_watcher_writes_history_when_no_reset(
self,
monkeypatch: pytest.MonkeyPatch,
stderr_buf: tempfile.SpooledTemporaryFile, # type: ignore[type-arg]
) -> None:
_DeferredSyntheticThread.pending.clear()
monkeypatch.setattr(
"tools.interactive_shell.synthetic.runner.threading.Thread",
_DeferredSyntheticThread,
)
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
session = Session()
presenter = _synthetic_presenter(session)
task = session.task_registry.create(TaskKind.SYNTHETIC_TEST)
task.mark_running()
task.attach_process(proc)
watch_synthetic_subprocess(task, proc, presenter, "rds_postgres", stderr_buf)
_DeferredSyntheticThread.pending[0]()
assert session.history[-1]["type"] == "synthetic_test"
_DeferredSyntheticThread.pending.clear()