Files
Haocode/tests/test_bash_stream.py
T
sorrow404null a7412824e0 chore: import original project baseline
Import the pre-repair source tree as the history baseline.
Runtime data (data/), virtualenvs, bytecode caches and logs are
gitignored so local secrets and user state stay out of the repo.
2026-09-17 16:40:01 +08:00

198 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
tool_bash 实时输出(增量流)单测 —— 纯函数级,无 UI 依赖
本轮改动核心:communicate(timeout=1) 阻塞式收集
→ stdout/stderr reader 线程 + 队列 + 主循环抽干回调 on_update
本测试锁死「改动没有破坏任何既有语义」+「输出真的是实时的」。
覆盖:
T1 增量流:on_update 被多次调用(不是结束时一次性给)
T2 实时性:第一块输出到达时刻 << 总耗时(证明是流式而非收尾补发)
T3 上下文文本格式完全不变($ cmd / stdout / [stderr] / [exit N] (Ns)
T4 stderr 实时流带 [stderr] 分隔标记,且最终文本仍有 [stderr] 段
T5 每 1 秒读秒回调 on_timer 仍然工作
T6 超时:杀进程树 + 「命令超时(>Ns)已终止」+ 实际耗时远小于命令时长
T7 中止:AbortSignal.aborted → 「操作已中止」
T8 50KB 截断仍然生效
T9 退出非 0 → is_error,且 exit code 写入 details
T10 空命令 → 参数校验错误
T11 无输出命令:不产生 on_update,但读秒正常
T12 最终文本 = 全部 stdout 拼接(不丢不重)
运行: PYTHONIOENCODING=utf-8 python tests/test_bash_stream.py
"""
import os
import re
import sys
import time
import tempfile
import shutil
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.agent.tools import tool_bash # noqa: E402
from core.agent.types import AbortSignal # noqa: E402
RESULTS = []
def check(name, cond, extra=""):
RESULTS.append((name, bool(cond)))
print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True)
_TMP = tempfile.mkdtemp(prefix="hocode_bashstream_")
PY = sys.executable
def run(script=None, timeout=None, abort_after=None, shell_script=None):
"""把脚本写成临时文件再执行(避开 cmd 引号地狱)。
返回 (result, updates, first_t, dur, timer_calls)"""
path = None
if script is not None:
path = os.path.join(_TMP, f"s_{int(time.time() * 1000000) % 10**9}.py")
with open(path, "w", encoding="utf-8") as f:
f.write(script)
cmd = f'"{PY}" -u "{path}"'
else:
cmd = shell_script
updates = []
first_t = [None]
t0 = time.time()
def on_update(text):
if first_t[0] is None:
first_t[0] = time.time() - t0
updates.append(text)
timers = []
def on_timer(elapsed, total):
timers.append((elapsed, total))
sig = AbortSignal()
if abort_after is not None:
import threading
def _aborter():
time.sleep(abort_after)
sig.abort("test")
threading.Thread(target=_aborter, daemon=True).start()
args = {"command": cmd}
if timeout is not None:
args["timeout"] = timeout
res = tool_bash("call_test", args, sig, on_update,
{"cwd": _TMP, "shell": True, "on_timer": on_timer})
dur = time.time() - t0
return res, updates, first_t[0], dur, timers
def text_of(res):
c = res.content
if isinstance(c, str):
return c
return "".join(x.get("text", "") for x in c if isinstance(x, dict))
# ======================================================================
try:
# ---------- T1/T2/T3/T12: 流式 + 上下文格式 ----------
res, ups, first_t, dur, timers = run(
"import sys, time\n"
"for i in range(3):\n"
" print('LINE%d' % i, flush=True)\n"
" time.sleep(0.5)\n", timeout=30)
txt = text_of(res)
check("T1.1 增量流被多次回调(>=3 块)", len(ups) >= 3, f"updates={len(ups)} {ups!r}")
check("T1.2 每块都是本次输出的一部分", all(u.strip() for u in ups), repr(ups[:3]))
check("T2.1 第一块在 1s 内到达(实时,不是收尾补发)",
first_t is not None and first_t < 1.0, f"first_t={first_t}")
check("T2.2 第一块明显早于总耗时",
first_t is not None and (dur - first_t) > 0.6, f"first={first_t} dur={dur:.2f}")
check("T3.1 上下文文本以 $ 命令开头", txt.startswith("$ "), repr(txt[:60]))
check("T3.2 含 [exit 0] 与耗时",
re.search(r"\[exit 0\] \(\d+\.\d+s\)", txt) is not None, repr(txt[-60:]))
check("T12.1 三行输出全部进入最终文本,不丢不重",
all(f"LINE{i}" in txt for i in range(3)) and txt.count("LINE0") == 1, repr(txt))
check("T3.3 is_error=False", res.is_error is False)
check("T3.4 details 带 exit_code", res.details.get("exit_code") == 0, str(res.details))
# ---------- T4: stderr 实时 + 最终段 ----------
res, ups, first_t, dur, timers = run(
"import sys\nprint('OUT1', flush=True)\nprint('ERR1', file=sys.stderr, flush=True)\n",
timeout=30)
txt = text_of(res)
joined = "".join(ups)
check("T4.1 实时流里有 [stderr] 分隔标记", "[stderr]" in joined, repr(joined))
check("T4.2 最终文本含 [stderr] 段与内容",
"[stderr]" in txt and "ERR1" in txt, repr(txt))
check("T4.3 stdout 内容也在最终文本里", "OUT1" in txt, repr(txt))
# ---------- T5/T11: 读秒 ----------
res, ups, first_t, dur, timers = run(
"import time\ntime.sleep(2.6)\nprint('done', flush=True)\n", timeout=30)
check("T5.1 长命令期间读秒回调(>=2 次)", len(timers) >= 2, f"timers={timers}")
check("T5.2 读秒总量正确", all(t[1] == 30 for t in timers), str(timers))
res, ups, first_t, dur, timers = run(
"import time\ntime.sleep(1.5)\n", timeout=30)
check("T11.1 无输出命令不产生 on_update", len(ups) == 0, repr(ups))
check("T11.2 无输出命令仍有读秒", len(timers) >= 1, str(timers))
# ---------- T6: 超时杀树 ----------
res, ups, first_t, dur, timers = run(
"import time\nprint('start', flush=True)\ntime.sleep(30)\n", timeout=2)
txt = text_of(res)
check("T6.1 超时文案", "命令超时(>2s)已终止" in txt, repr(txt))
check("T6.2 真的提前返回(耗时应 <10s", dur < 10, f"dur={dur:.2f}")
check("T6.3 超时结果是 is_error", res.is_error is True)
# ---------- T7: 中止 ----------
res, ups, first_t, dur, timers = run(
"import time\ntime.sleep(30)\n", timeout=60, abort_after=1.5)
txt = text_of(res)
check("T7.1 中止文案", "操作已中止" in txt, repr(txt))
check("T7.2 中止后迅速返回(<8s", dur < 8, f"dur={dur:.2f}")
# ---------- T8: 50KB 截断 ----------
res, ups, first_t, dur, timers = run(
"import sys\n"
"line = 'X' * 100 + '\\n'\n"
"for _ in range(1200):\n"
" sys.stdout.write(line)\n"
"sys.stdout.flush()\n", timeout=30)
txt = text_of(res)
check("T8.1 超过 50KB 触发截断标记", "[输出超过 50KB 已截断]" in txt, repr(txt[-80:]))
check("T8.2 截断后字节数 <= 50KB+余量",
len(txt.encode("utf-8")) <= 50 * 1024 + 200, str(len(txt.encode("utf-8"))))
# ---------- T9: 非 0 退出 ----------
res, ups, first_t, dur, timers = run("import sys\nsys.exit(3)\n", timeout=30)
txt = text_of(res)
check("T9.1 exit 3 写入文本", "[exit 3]" in txt, repr(txt))
check("T9.2 is_error=True", res.is_error is True)
check("T9.3 details.exit_code=3", res.details.get("exit_code") == 3, str(res.details))
# ---------- T10: 空命令 ----------
res = tool_bash("c", {"command": " "}, AbortSignal(), None,
{"cwd": _TMP, "shell": True})
check("T10.1 空命令被拒", "command 不能为空" in text_of(res), repr(text_of(res)))
# ---------- T13: 秒级短命令仍正常(回归:不因线程化而变慢/丢输出) ----------
res, ups, first_t, dur, timers = run(shell_script="echo hello_from_cmd")
txt = text_of(res)
check("T13.1 短命令输出正确", "hello_from_cmd" in txt, repr(txt))
check("T13.2 短命令耗时 <3s", dur < 3, f"dur={dur:.2f}")
check("T13.3 短命令实时流也拿到了输出", "hello_from_cmd" in "".join(ups), repr(ups))
finally:
shutil.rmtree(_TMP, ignore_errors=True)
failed = [n for n, ok in RESULTS if not ok]
print(f"\n===== {len(RESULTS) - len(failed)}/{len(RESULTS)} PASS =====", flush=True)
print("ALL PASS" if not failed else f"FAILED: {failed}", flush=True)
sys.exit(0 if not failed else 1)