在准备环境前提交次全部更改。
This commit is contained in:
186
apps/backend/tests/test_ws_logs.py
Normal file
186
apps/backend/tests/test_ws_logs.py
Normal file
@@ -0,0 +1,186 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""WebSocket 日志推送端点测试
|
||||
|
||||
测试 /ws/logs/{execution_id} 端点的连接、日志回放、实时推送和断开行为。
|
||||
利用 TaskExecutor 已有的 subscribe/broadcast 机制进行验证。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from app.main import app
|
||||
from app.services.task_executor import task_executor
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _cleanup_executor():
|
||||
"""每个测试前后清理 TaskExecutor 内部状态。"""
|
||||
yield
|
||||
# 清理所有残留的缓冲区和订阅者
|
||||
for eid in list(task_executor._log_buffers.keys()):
|
||||
task_executor.cleanup(eid)
|
||||
task_executor._subscribers.clear()
|
||||
task_executor._log_buffers.clear()
|
||||
|
||||
|
||||
class TestWebSocketConnection:
|
||||
"""WebSocket 连接/断开基本行为"""
|
||||
|
||||
def test_connect_and_disconnect(self):
|
||||
"""客户端能成功建立和关闭 WebSocket 连接。"""
|
||||
client = TestClient(app)
|
||||
with client.websocket_connect("/ws/logs/test-exec-001") as ws:
|
||||
# 连接成功,直接关闭
|
||||
pass # __exit__ 会关闭连接
|
||||
|
||||
def test_connect_registers_subscriber(self):
|
||||
"""连接后 TaskExecutor 应注册订阅者。"""
|
||||
client = TestClient(app)
|
||||
# 预先初始化缓冲区(模拟有任务在运行)
|
||||
task_executor._log_buffers["test-exec-002"] = []
|
||||
|
||||
with client.websocket_connect("/ws/logs/test-exec-002"):
|
||||
# 连接期间应有订阅者
|
||||
assert "test-exec-002" in task_executor._subscribers
|
||||
assert len(task_executor._subscribers["test-exec-002"]) >= 1
|
||||
|
||||
|
||||
class TestLogReplay:
|
||||
"""历史日志回放"""
|
||||
|
||||
def test_replay_existing_logs(self):
|
||||
"""连接时应先收到内存缓冲区中已有的日志行。"""
|
||||
eid = "test-exec-replay"
|
||||
# 预填充日志缓冲区
|
||||
task_executor._log_buffers[eid] = [
|
||||
"[stdout] 第一行",
|
||||
"[stdout] 第二行",
|
||||
"[stderr] 警告信息",
|
||||
]
|
||||
|
||||
client = TestClient(app)
|
||||
with client.websocket_connect(f"/ws/logs/{eid}") as ws:
|
||||
# 应按顺序收到 3 条历史日志
|
||||
msg1 = ws.receive_text()
|
||||
msg2 = ws.receive_text()
|
||||
msg3 = ws.receive_text()
|
||||
|
||||
assert msg1 == "[stdout] 第一行"
|
||||
assert msg2 == "[stdout] 第二行"
|
||||
assert msg3 == "[stderr] 警告信息"
|
||||
|
||||
def test_no_logs_no_replay(self):
|
||||
"""没有历史日志时不应收到回放消息。"""
|
||||
eid = "test-exec-empty"
|
||||
task_executor._log_buffers[eid] = []
|
||||
|
||||
client = TestClient(app)
|
||||
with client.websocket_connect(f"/ws/logs/{eid}") as ws:
|
||||
# 发送结束信号让连接正常关闭
|
||||
task_executor._broadcast_end(eid)
|
||||
# 不应有回放消息,直接收到的是后续的结束信号处理
|
||||
|
||||
|
||||
class TestBroadcastReceive:
|
||||
"""实时日志广播接收"""
|
||||
|
||||
def test_receive_broadcast_messages(self):
|
||||
"""连接后应能收到 TaskExecutor 广播的实时日志。"""
|
||||
eid = "test-exec-broadcast"
|
||||
task_executor._log_buffers[eid] = []
|
||||
|
||||
client = TestClient(app)
|
||||
with client.websocket_connect(f"/ws/logs/{eid}") as ws:
|
||||
# 模拟 TaskExecutor 广播日志
|
||||
task_executor._broadcast(eid, "[stdout] 实时日志行1")
|
||||
task_executor._broadcast(eid, "[stderr] 实时错误行")
|
||||
|
||||
msg1 = ws.receive_text()
|
||||
msg2 = ws.receive_text()
|
||||
|
||||
assert msg1 == "[stdout] 实时日志行1"
|
||||
assert msg2 == "[stderr] 实时错误行"
|
||||
|
||||
def test_end_signal_closes_connection(self):
|
||||
"""收到 None 结束信号后 WebSocket 应正常关闭。"""
|
||||
eid = "test-exec-end"
|
||||
task_executor._log_buffers[eid] = []
|
||||
|
||||
client = TestClient(app)
|
||||
with client.websocket_connect(f"/ws/logs/{eid}") as ws:
|
||||
# 广播一条日志后发送结束信号
|
||||
task_executor._broadcast(eid, "[stdout] 最后一行")
|
||||
task_executor._broadcast_end(eid)
|
||||
|
||||
msg = ws.receive_text()
|
||||
assert msg == "[stdout] 最后一行"
|
||||
|
||||
def test_replay_then_broadcast(self):
|
||||
"""先回放历史日志,再接收实时广播。"""
|
||||
eid = "test-exec-mixed"
|
||||
task_executor._log_buffers[eid] = ["[stdout] 历史行"]
|
||||
|
||||
client = TestClient(app)
|
||||
with client.websocket_connect(f"/ws/logs/{eid}") as ws:
|
||||
# 先收到历史回放
|
||||
replay = ws.receive_text()
|
||||
assert replay == "[stdout] 历史行"
|
||||
|
||||
# 再收到实时广播
|
||||
task_executor._broadcast(eid, "[stdout] 新行")
|
||||
task_executor._broadcast_end(eid)
|
||||
|
||||
live = ws.receive_text()
|
||||
assert live == "[stdout] 新行"
|
||||
|
||||
|
||||
class TestLogBroadcasterUnit:
|
||||
"""直接测试 TaskExecutor 的 subscribe/unsubscribe/broadcast 方法
|
||||
(作为 LogBroadcaster 功能的单元测试)。
|
||||
"""
|
||||
|
||||
def test_subscribe_creates_queue(self):
|
||||
eid = "unit-sub"
|
||||
q = task_executor.subscribe(eid)
|
||||
assert isinstance(q, asyncio.Queue)
|
||||
assert eid in task_executor._subscribers
|
||||
task_executor.unsubscribe(eid, q)
|
||||
|
||||
def test_unsubscribe_removes_queue(self):
|
||||
eid = "unit-unsub"
|
||||
q = task_executor.subscribe(eid)
|
||||
task_executor.unsubscribe(eid, q)
|
||||
# 最后一个订阅者移除后,key 也应被清理
|
||||
assert eid not in task_executor._subscribers
|
||||
|
||||
def test_broadcast_delivers_to_all_subscribers(self):
|
||||
eid = "unit-multi"
|
||||
q1 = task_executor.subscribe(eid)
|
||||
q2 = task_executor.subscribe(eid)
|
||||
|
||||
task_executor._broadcast(eid, "测试消息")
|
||||
|
||||
assert q1.get_nowait() == "测试消息"
|
||||
assert q2.get_nowait() == "测试消息"
|
||||
|
||||
task_executor.unsubscribe(eid, q1)
|
||||
task_executor.unsubscribe(eid, q2)
|
||||
|
||||
def test_broadcast_end_sends_none(self):
|
||||
eid = "unit-end"
|
||||
q = task_executor.subscribe(eid)
|
||||
|
||||
task_executor._broadcast_end(eid)
|
||||
|
||||
assert q.get_nowait() is None
|
||||
task_executor.unsubscribe(eid, q)
|
||||
|
||||
def test_broadcast_no_subscribers_is_safe(self):
|
||||
"""没有订阅者时广播不应报错。"""
|
||||
task_executor._broadcast("nonexistent", "无人接收")
|
||||
task_executor._broadcast_end("nonexistent")
|
||||
Reference in New Issue
Block a user