"""The websocket node, both roles: served under /hooks, or dialled out.""" import asyncio import json import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from starlette.websockets import WebSocketDisconnect from websockets.asyncio.server import ServerConnection, serve from fluksio.flow.controller import HOOK_PREFIX from fluksio.flow.messages import DType, MessageSpec from fluksio.flow.nodes import WebSocketNode from fluksio.flow.pipeline import Pipeline SECRET = "s0cket-key" HOOK = f"{HOOK_PREFIX}/house/ticker" def ws_node(url: str, *, sends: bool = False, **params: object) -> WebSocketNode: port = [MessageSpec(name="temp", dtype=DType.FLOAT)] node = WebSocketNode( requires=port if sends else (), provides=() if sends else port, params={"url": url, **params}, name="ticker", ) node.assign_flow("house", "ticker") if node.serves: node.url = HOOK # what the controller does return node def test_frames_map_to_ports(): node = ws_node("ws://x", json_key="value") assert node._decode('{"value": "21.5"}') == {"temp": 21.5} # One output takes a frame that names no port whole. assert node._decode("19") == {"temp": 19.0} sender = ws_node("ws://x", sends=True) assert sender._encode({"temp": 21.5}) == "21.5" assert sender._encode({"cmd": {"op": "on"}}) == '{"op": "on"}' assert json.loads(sender._encode({"a": 1, "b": "x"})) == {"a": 1, "b": "x"} with pytest.raises(ValueError): ws_node("http://x") def test_a_served_socket_injects_frames_and_guards_its_secret(): node = ws_node("/ticker", secret=SECRET) pipeline = Pipeline(nodes=[node]) app = FastAPI() app.mount("/", FastAPI()) # the MCP catch-all the route must sit ahead of node.register_route(app) client = TestClient(app) with pytest.raises(WebSocketDisconnect): with client.websocket_connect(f"{HOOK}/wrong") as ws: ws.receive_text() with client.websocket_connect(f"{HOOK}/{SECRET}") as ws: ws.send_text('{"temp": 21.5}') assert pipeline.values()["house.temp"]["value"] == 21.5 def test_a_served_sender_reaches_every_connected_client(): node = ws_node("/ticker", sends=True) app = FastAPI() with TestClient(app) as client: client.portal.call(node.start, app) with ( client.websocket_connect(HOOK) as first, client.websocket_connect(HOOK) as second, ): # Both are registered once either has been accepted and spoken to. client.portal.call(asyncio.sleep, 0.05) node._sender_handler({}, temp=21.5) assert first.receive_text() == "21.5" assert second.receive_text() == "21.5" client.portal.call(node.stop, app) def test_a_client_listens_and_sends_over_a_held_connection(): received: list[str] = [] async def server(conn: ServerConnection) -> None: if conn.request is not None and conn.request.path == "/feed": await conn.send('{"temp": 19.5}') await conn.wait_closed() else: async for frame in conn: received.append(str(frame)) async def scenario() -> None: async with serve(server, "127.0.0.1", 0) as srv: port = srv.sockets[0].getsockname()[1] listener = ws_node(f"ws://127.0.0.1:{port}/feed") pipeline = Pipeline(nodes=[listener]) sender = ws_node( f"ws://127.0.0.1:{port}/cmd", sends=True, on_connect=[{"op": "subscribe"}], ) await listener.start() await sender.start() await asyncio.to_thread(sender._sender_handler, {}, temp=21.5) for _ in range(100): if len(received) == 2 and pipeline.values(): break await asyncio.sleep(0.02) await listener.stop() await sender.stop() assert received == ['{"op": "subscribe"}', "21.5"] assert pipeline.values()["house.temp"]["value"] == 19.5 asyncio.run(scenario()) def test_start_respawns_a_loop_cancelled_underneath_it(): """Enabling a flow cancels its supervised loops and starts the nodes again, without a stop in between.""" node = ws_node("/ticker", sends=True) async def scenario() -> None: await node.start() first = node._task assert first is not None await node._cancel_task(first) await node.start() assert node._task is not first and not node._task.done() await node.stop() asyncio.run(scenario())