a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
242 lines
8.6 KiB
Python
Executable File
242 lines
8.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""End-to-end protocol smoke test against the mock gateway.
|
|
|
|
Exercises: /health, /pair, ws upgrade, subscribe, get_history, message stream.
|
|
Pure stdlib so it runs anywhere. Asserts the full happy-path event sequence.
|
|
"""
|
|
import argparse
|
|
import base64
|
|
import http.client
|
|
import json
|
|
import os
|
|
import socket
|
|
import struct
|
|
import sys
|
|
|
|
|
|
def http_get(host, port, path):
|
|
conn = http.client.HTTPConnection(host, port, timeout=5)
|
|
conn.request("GET", path)
|
|
resp = conn.getresponse()
|
|
data = resp.read().decode()
|
|
conn.close()
|
|
return resp.status, data
|
|
|
|
|
|
def http_post(host, port, path, body):
|
|
conn = http.client.HTTPConnection(host, port, timeout=5)
|
|
conn.request("POST", path, json.dumps(body), {"Content-Type": "application/json"})
|
|
resp = conn.getresponse()
|
|
data = resp.read().decode()
|
|
conn.close()
|
|
return resp.status, data
|
|
|
|
|
|
def ws_connect(host, port, token):
|
|
s = socket.create_connection((host, port), timeout=5)
|
|
key = base64.b64encode(os.urandom(16)).decode()
|
|
req = (
|
|
f"GET /ws HTTP/1.1\r\n"
|
|
f"Host: {host}:{port}\r\n"
|
|
"Upgrade: websocket\r\n"
|
|
"Connection: Upgrade\r\n"
|
|
f"Sec-WebSocket-Key: {key}\r\n"
|
|
"Sec-WebSocket-Version: 13\r\n"
|
|
f"Authorization: Bearer {token}\r\n"
|
|
"\r\n"
|
|
)
|
|
s.sendall(req.encode())
|
|
# Read handshake response headers.
|
|
buf = b""
|
|
while b"\r\n\r\n" not in buf:
|
|
buf += s.recv(1024)
|
|
assert b"101" in buf.split(b"\r\n")[0], buf
|
|
return s
|
|
|
|
|
|
def ws_send(s, text):
|
|
payload = text.encode()
|
|
mask = os.urandom(4)
|
|
masked = bytes(payload[i] ^ mask[i % 4] for i in range(len(payload)))
|
|
header = bytearray([0x81])
|
|
length = len(payload)
|
|
if length < 126:
|
|
header.append(0x80 | length)
|
|
elif length < 65536:
|
|
header.append(0x80 | 126)
|
|
header += struct.pack(">H", length)
|
|
else:
|
|
header.append(0x80 | 127)
|
|
header += struct.pack(">Q", length)
|
|
s.sendall(bytes(header) + mask + masked)
|
|
|
|
|
|
def ws_recv(s):
|
|
def recv_exact(n):
|
|
data = b""
|
|
while len(data) < n:
|
|
chunk = s.recv(n - len(data))
|
|
if not chunk:
|
|
raise ConnectionError("closed")
|
|
data += chunk
|
|
return data
|
|
|
|
b = recv_exact(2)
|
|
opcode = b[0] & 0x0F
|
|
length = b[1] & 0x7F
|
|
if length == 126:
|
|
length = struct.unpack(">H", recv_exact(2))[0]
|
|
elif length == 127:
|
|
length = struct.unpack(">Q", recv_exact(8))[0]
|
|
data = recv_exact(length) if length else b""
|
|
return opcode, data
|
|
|
|
|
|
def collect_events(s, until_type, max_events=200):
|
|
events = []
|
|
for _ in range(max_events):
|
|
opcode, data = ws_recv(s)
|
|
if opcode == 0x9: # ping -> ignore (client would pong)
|
|
continue
|
|
if opcode == 0x8:
|
|
break
|
|
text = data.decode("utf-8", errors="replace")
|
|
for line in text.split("\n"):
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
obj = json.loads(line)
|
|
events.append(obj)
|
|
if obj.get("type") == until_type:
|
|
return events
|
|
return events
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--host", default="127.0.0.1")
|
|
ap.add_argument("--port", type=int, default=7643)
|
|
ap.add_argument("--code", default="123456")
|
|
args = ap.parse_args()
|
|
|
|
failures = []
|
|
|
|
def check(name, cond):
|
|
status = "PASS" if cond else "FAIL"
|
|
print(f" [{status}] {name}")
|
|
if not cond:
|
|
failures.append(name)
|
|
|
|
# 1. health
|
|
st, body = http_get(args.host, args.port, "/health")
|
|
h = json.loads(body)
|
|
check("health 200", st == 200)
|
|
check("health status ok", h.get("status") == "ok")
|
|
check("health gateway flag", h.get("gateway") is True)
|
|
|
|
# 2. pair bad code
|
|
st, body = http_post(args.host, args.port, "/pair", {"code": "000000", "device_id": "d", "device_name": "Test"})
|
|
check("pair bad code 401", st == 401)
|
|
|
|
# 3. pair good code
|
|
st, body = http_post(args.host, args.port, "/pair", {"code": args.code, "device_id": "d", "device_name": "Test"})
|
|
p = json.loads(body)
|
|
check("pair 200", st == 200)
|
|
token = p.get("token", "")
|
|
check("pair returns token", bool(token))
|
|
check("pair server_name", p.get("server_name") == "mock-jcode")
|
|
|
|
# 4. ws connect + subscribe + history
|
|
s = ws_connect(args.host, args.port, token)
|
|
ws_send(s, json.dumps({"id": 1, "type": "subscribe"}))
|
|
evs = collect_events(s, until_type="state")
|
|
types = [e["type"] for e in evs]
|
|
check("subscribe -> ack", "ack" in types)
|
|
check("subscribe -> session", "session" in types)
|
|
check("subscribe -> state", "state" in types)
|
|
|
|
ws_send(s, json.dumps({"id": 2, "type": "get_history"}))
|
|
evs = collect_events(s, until_type="history")
|
|
hist = next((e for e in evs if e["type"] == "history"), None)
|
|
check("get_history -> history", hist is not None)
|
|
if hist:
|
|
check("history available_models", len(hist.get("available_models", [])) >= 1)
|
|
check("history all_sessions", len(hist.get("all_sessions", [])) >= 1)
|
|
|
|
# 5. message -> full stream
|
|
ws_send(s, json.dumps({"id": 3, "type": "message", "content": "hi there"}))
|
|
evs = collect_events(s, until_type="done")
|
|
types = [e["type"] for e in evs]
|
|
check("message -> ack", "ack" in types)
|
|
check("message -> reasoning_delta", "reasoning_delta" in types)
|
|
check("message -> text_delta", "text_delta" in types)
|
|
check("message -> tool_start", "tool_start" in types)
|
|
check("message -> tool_exec", "tool_exec" in types)
|
|
check("message -> tool_done", "tool_done" in types)
|
|
check("message -> message_end", "message_end" in types)
|
|
check("message -> tokens", "tokens" in types)
|
|
check("message -> done", "done" in types)
|
|
|
|
# reconstruct streamed text
|
|
streamed = "".join(e.get("text", "") for e in evs if e["type"] == "text_delta")
|
|
check("streamed text contains echo of input", "hi there" in streamed)
|
|
check("streamed text has code fence", "```" in streamed)
|
|
|
|
# 6. set_model
|
|
ws_send(s, json.dumps({"id": 4, "type": "set_model", "model": "openai:gpt-5"}))
|
|
evs = collect_events(s, until_type="available_models_updated")
|
|
mc = next((e for e in evs if e["type"] == "model_changed"), None)
|
|
check("set_model -> model_changed", mc is not None and mc.get("model") == "openai:gpt-5")
|
|
|
|
# 7. history carries display_title + reasoning_effort (session titles UI)
|
|
if hist:
|
|
check("history display_title", bool(hist.get("display_title")))
|
|
check("history reasoning_effort", bool(hist.get("reasoning_effort")))
|
|
|
|
# 8. soft_interrupt -> injection ack precedes the streamed response
|
|
ws_send(s, json.dumps({"id": 5, "type": "soft_interrupt",
|
|
"content": "queued mid-run", "urgent": False}))
|
|
evs = collect_events(s, until_type="done")
|
|
types = [e["type"] for e in evs]
|
|
check("soft_interrupt -> soft_interrupt_injected", "soft_interrupt_injected" in types)
|
|
inj = next((e for e in evs if e["type"] == "soft_interrupt_injected"), None)
|
|
check("injected content echoes request",
|
|
inj is not None and inj.get("content") == "queued mid-run")
|
|
if "soft_interrupt_injected" in types and "text_delta" in types:
|
|
check("injected before stream",
|
|
types.index("soft_interrupt_injected") < types.index("text_delta"))
|
|
|
|
# 9. set_reasoning_effort
|
|
ws_send(s, json.dumps({"id": 6, "type": "set_reasoning_effort", "effort": "low"}))
|
|
evs = collect_events(s, until_type="reasoning_effort_changed")
|
|
rc = next((e for e in evs if e["type"] == "reasoning_effort_changed"), None)
|
|
check("set_reasoning_effort -> reasoning_effort_changed",
|
|
rc is not None and rc.get("effort") == "low" and rc.get("error") is None)
|
|
|
|
# 10. compact
|
|
ws_send(s, json.dumps({"id": 7, "type": "compact"}))
|
|
evs = collect_events(s, until_type="compact_result")
|
|
cr = next((e for e in evs if e["type"] == "compact_result"), None)
|
|
check("compact -> compact_result success",
|
|
cr is not None and cr.get("success") is True and bool(cr.get("message")))
|
|
|
|
# 11. rename_session -> session_renamed broadcast (titles map)
|
|
ws_send(s, json.dumps({"id": 8, "type": "rename_session", "title": "Renamed by smoke"}))
|
|
evs = collect_events(s, until_type="session_renamed")
|
|
rn = next((e for e in evs if e["type"] == "session_renamed"), None)
|
|
check("rename_session -> session_renamed",
|
|
rn is not None and rn.get("display_title") == "Renamed by smoke"
|
|
and bool(rn.get("session_id")))
|
|
|
|
s.close()
|
|
|
|
print()
|
|
if failures:
|
|
print(f"FAILED ({len(failures)}): {failures}")
|
|
sys.exit(1)
|
|
print("ALL CHECKS PASSED")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|