chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,565 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user