mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-30 16:01:49 +08:00
Follows up on the initial dashboard plugin with the items called out
during self-review — ships the GUI-reality claims the PR body made,
closes the WebSocket auth gap, and lands the 'Triage' status the design
spec's Fusion-style screenshot leads with.
Kernel changes
- kanban_db.VALID_STATUSES gains 'triage'. status is TEXT without a
CHECK constraint so no schema migration is needed.
- create_task(triage=True) forces the initial status to 'triage'
regardless of parents, and parent ids are still validated so the
eventual link rows don't dangle. recompute_ready() only promotes
'todo' -> 'ready', so triage tasks are naturally isolated from the
dispatcher pipeline.
- hermes kanban create gains --triage.
Patterns table (docs) gains P9 'Triage specifier'.
Plugin backend (plugins/kanban/dashboard/plugin_api.py)
- GET /board now auto-init's kanban.db on first read (idempotent).
A fresh install shows an empty board instead of 'failed to load'.
- GET /board returns a new 'progress' field per task — {done, total}
of child-task completion, or None if the task has no children.
- BOARD_COLUMNS prepends 'triage'.
- POST /tasks accepts {triage: bool}; PATCH /tasks/:id accepts
{status: 'triage'}.
- WebSocket /events now requires ?token=<session_token> as a query
param — browsers can't set Authorization on a WS upgrade, so this
matches the pattern the in-browser PTY bridge uses. Constant-time
compare against hermes_cli.web_server._SESSION_TOKEN. In bare-test
contexts (no dashboard module) the check no-ops so the tail loop
stays testable. Security boundary documented in the module header
and in website/docs/user-guide/features/kanban.md.
Plugin UI (plugins/kanban/dashboard/dist/index.js + style.css)
- Adds the Triage column (lilac dot) with helper text
'Raw ideas — a specifier will flesh out the spec'. Inline-create
from the Triage column parks new tasks in triage.
- Status action row in the drawer gains '→ triage'.
- Progress pill (N/M) on cards that have children. Full-complete
state tints the pill green.
- 'Lanes by profile' toolbar toggle — sub-groups the Running column
by assignee so you see at a glance which specialist is busy on
what.
- Destructive status moves (done / archived / blocked) via drag-drop
OR via the drawer action row now prompt for confirmation.
- Escape closes the drawer.
- Live-update reloads are debounced (250ms) so a burst of
task_events triggers one refetch, not N.
- WebSocket includes ?token= built from window.__HERMES_SESSION_TOKEN__.
- WebSocket reconnect uses exponential backoff capped at 30s, not
a fixed 1.5s spin loop, and surfaces a user-visible error on
code-1008 (auth rejected) instead of reconnecting forever.
- ErrorBoundary wraps the page — a bad card render shows a
'rendering error, reload view' card instead of crashing the tab.
Tests (tests/plugins/test_kanban_dashboard_plugin.py, +5 tests = 21)
- empty-board shape now asserts all 6 columns including 'triage'
- create_triage_lands_in_triage_column
- triage_task_not_promoted_to_ready (dispatcher bypasses triage)
- patch_status_triage_works (both into triage and out of it)
- board_progress_rollup (0/2 -> 1/2 -> childless cards = None)
- board_auto_initializes_missing_db
- ws_events_rejects_when_token_required (three sub-assertions:
missing → 1008, wrong → 1008, correct → handshake accepted)
All 82 kanban tests pass under scripts/run_tests.sh.
Docs
- kanban.md 'What the plugin gives you' fully rewritten to match
shipped reality (triage, progress pill, assignee lanes,
destructive-confirm, Escape-close, debounce).
- New 'Security model' subsection documents the explicit-plugin-
route-bypass, the WS token requirement, and the --host 0.0.0.0
warning; also notes that kanban.db is profile-agnostic on purpose
(the coordination primitive) so cross-profile visibility is
expected.
- CLI command reference shows --triage.
- Collaboration patterns table adds P9 'Triage specifier'.
506 lines
17 KiB
Python
506 lines
17 KiB
Python
"""Tests for the Kanban dashboard plugin backend (plugins/kanban/dashboard/plugin_api.py).
|
|
|
|
The plugin mounts as /api/plugins/kanban/ inside the dashboard's FastAPI app,
|
|
but here we attach its router to a bare FastAPI instance so we can test the
|
|
REST surface without spinning up the whole dashboard.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from hermes_cli import kanban_db as kb
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _load_plugin_router():
|
|
"""Dynamically load plugins/kanban/dashboard/plugin_api.py and return its router."""
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
plugin_file = repo_root / "plugins" / "kanban" / "dashboard" / "plugin_api.py"
|
|
assert plugin_file.exists(), f"plugin file missing: {plugin_file}"
|
|
|
|
spec = importlib.util.spec_from_file_location(
|
|
"hermes_dashboard_plugin_kanban_test", plugin_file,
|
|
)
|
|
assert spec is not None and spec.loader is not None
|
|
mod = importlib.util.module_from_spec(spec)
|
|
sys.modules[spec.name] = mod
|
|
spec.loader.exec_module(mod)
|
|
return mod.router
|
|
|
|
|
|
@pytest.fixture
|
|
def kanban_home(tmp_path, monkeypatch):
|
|
"""Isolated HERMES_HOME with an empty kanban DB."""
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
kb.init_db()
|
|
return home
|
|
|
|
|
|
@pytest.fixture
|
|
def client(kanban_home):
|
|
app = FastAPI()
|
|
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
|
|
return TestClient(app)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /board on an empty DB
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_board_empty(client):
|
|
r = client.get("/api/plugins/kanban/board")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
# All canonical columns present (triage + the rest), each empty.
|
|
names = [c["name"] for c in data["columns"]]
|
|
for expected in ("triage", "todo", "ready", "running", "blocked", "done"):
|
|
assert expected in names, f"missing column {expected}: {names}"
|
|
assert all(len(c["tasks"]) == 0 for c in data["columns"])
|
|
assert data["tenants"] == []
|
|
assert data["assignees"] == []
|
|
assert data["latest_event_id"] == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /tasks then GET /board sees it
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_create_task_appears_on_board(client):
|
|
r = client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={
|
|
"title": "Research LLM caching",
|
|
"assignee": "researcher",
|
|
"priority": 3,
|
|
"tenant": "acme",
|
|
},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
task = r.json()["task"]
|
|
assert task["title"] == "Research LLM caching"
|
|
assert task["assignee"] == "researcher"
|
|
assert task["status"] == "ready" # no parents -> immediately ready
|
|
assert task["priority"] == 3
|
|
assert task["tenant"] == "acme"
|
|
task_id = task["id"]
|
|
|
|
# Board now lists it under 'ready'.
|
|
r = client.get("/api/plugins/kanban/board")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
ready = next(c for c in data["columns"] if c["name"] == "ready")
|
|
assert len(ready["tasks"]) == 1
|
|
assert ready["tasks"][0]["id"] == task_id
|
|
assert "acme" in data["tenants"]
|
|
assert "researcher" in data["assignees"]
|
|
|
|
|
|
def test_tenant_filter(client):
|
|
client.post("/api/plugins/kanban/tasks", json={"title": "A", "tenant": "t1"})
|
|
client.post("/api/plugins/kanban/tasks", json={"title": "B", "tenant": "t2"})
|
|
|
|
r = client.get("/api/plugins/kanban/board?tenant=t1")
|
|
counts = {c["name"]: len(c["tasks"]) for c in r.json()["columns"]}
|
|
total = sum(counts.values())
|
|
assert total == 1
|
|
|
|
r = client.get("/api/plugins/kanban/board?tenant=t2")
|
|
total = sum(len(c["tasks"]) for c in r.json()["columns"])
|
|
assert total == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /tasks/:id returns body + comments + events + links
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_task_detail_includes_links_and_events(client):
|
|
parent = client.post(
|
|
"/api/plugins/kanban/tasks", json={"title": "parent"},
|
|
).json()["task"]
|
|
child = client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={"title": "child", "parents": [parent["id"]]},
|
|
).json()["task"]
|
|
assert child["status"] == "todo" # parent not done yet
|
|
|
|
# Detail for the child shows the parent link.
|
|
r = client.get(f"/api/plugins/kanban/tasks/{child['id']}")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["task"]["id"] == child["id"]
|
|
assert parent["id"] in data["links"]["parents"]
|
|
|
|
# Detail for the parent shows the child.
|
|
r = client.get(f"/api/plugins/kanban/tasks/{parent['id']}")
|
|
assert child["id"] in r.json()["links"]["children"]
|
|
|
|
# Events exist from creation.
|
|
assert len(data["events"]) >= 1
|
|
|
|
|
|
def test_task_detail_404_on_unknown(client):
|
|
r = client.get("/api/plugins/kanban/tasks/does-not-exist")
|
|
assert r.status_code == 404
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PATCH /tasks/:id — status transitions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_patch_status_complete(client):
|
|
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
|
r = client.patch(
|
|
f"/api/plugins/kanban/tasks/{t['id']}",
|
|
json={"status": "done", "result": "shipped"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["task"]["status"] == "done"
|
|
|
|
# Board reflects the move.
|
|
done = next(
|
|
c for c in client.get("/api/plugins/kanban/board").json()["columns"]
|
|
if c["name"] == "done"
|
|
)
|
|
assert any(x["id"] == t["id"] for x in done["tasks"])
|
|
|
|
|
|
def test_patch_block_then_unblock(client):
|
|
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
|
r = client.patch(
|
|
f"/api/plugins/kanban/tasks/{t['id']}",
|
|
json={"status": "blocked", "block_reason": "need input"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["task"]["status"] == "blocked"
|
|
|
|
r = client.patch(
|
|
f"/api/plugins/kanban/tasks/{t['id']}",
|
|
json={"status": "ready"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["task"]["status"] == "ready"
|
|
|
|
|
|
def test_patch_drag_drop_move_todo_to_ready(client):
|
|
"""Direct status write: the drag-drop path for statuses without a
|
|
dedicated verb (e.g. manually promoting todo -> ready)."""
|
|
parent = client.post("/api/plugins/kanban/tasks", json={"title": "p"}).json()["task"]
|
|
child = client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={"title": "c", "parents": [parent["id"]]},
|
|
).json()["task"]
|
|
assert child["status"] == "todo"
|
|
|
|
r = client.patch(
|
|
f"/api/plugins/kanban/tasks/{child['id']}",
|
|
json={"status": "ready"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["task"]["status"] == "ready"
|
|
|
|
|
|
def test_patch_reassign(client):
|
|
t = client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={"title": "x", "assignee": "a"},
|
|
).json()["task"]
|
|
r = client.patch(
|
|
f"/api/plugins/kanban/tasks/{t['id']}",
|
|
json={"assignee": "b"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["task"]["assignee"] == "b"
|
|
|
|
|
|
def test_patch_priority_and_edit(client):
|
|
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
|
r = client.patch(
|
|
f"/api/plugins/kanban/tasks/{t['id']}",
|
|
json={"priority": 5, "title": "renamed"},
|
|
)
|
|
assert r.status_code == 200
|
|
data = r.json()["task"]
|
|
assert data["priority"] == 5
|
|
assert data["title"] == "renamed"
|
|
|
|
|
|
def test_patch_invalid_status(client):
|
|
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
|
r = client.patch(
|
|
f"/api/plugins/kanban/tasks/{t['id']}",
|
|
json={"status": "banana"},
|
|
)
|
|
assert r.status_code == 400
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Comments + Links
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_add_comment(client):
|
|
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
|
r = client.post(
|
|
f"/api/plugins/kanban/tasks/{t['id']}/comments",
|
|
json={"body": "how's progress?", "author": "teknium"},
|
|
)
|
|
assert r.status_code == 200
|
|
|
|
r = client.get(f"/api/plugins/kanban/tasks/{t['id']}")
|
|
comments = r.json()["comments"]
|
|
assert len(comments) == 1
|
|
assert comments[0]["body"] == "how's progress?"
|
|
assert comments[0]["author"] == "teknium"
|
|
|
|
|
|
def test_add_comment_empty_rejected(client):
|
|
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
|
r = client.post(
|
|
f"/api/plugins/kanban/tasks/{t['id']}/comments",
|
|
json={"body": " "},
|
|
)
|
|
assert r.status_code == 400
|
|
|
|
|
|
def test_add_link_and_delete_link(client):
|
|
a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"]
|
|
b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"]
|
|
|
|
r = client.post(
|
|
"/api/plugins/kanban/links",
|
|
json={"parent_id": a["id"], "child_id": b["id"]},
|
|
)
|
|
assert r.status_code == 200
|
|
|
|
r = client.get(f"/api/plugins/kanban/tasks/{b['id']}")
|
|
assert a["id"] in r.json()["links"]["parents"]
|
|
|
|
r = client.delete(
|
|
"/api/plugins/kanban/links",
|
|
params={"parent_id": a["id"], "child_id": b["id"]},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["ok"] is True
|
|
|
|
|
|
def test_add_link_cycle_rejected(client):
|
|
a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"]
|
|
b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"]
|
|
client.post(
|
|
"/api/plugins/kanban/links",
|
|
json={"parent_id": a["id"], "child_id": b["id"]},
|
|
)
|
|
r = client.post(
|
|
"/api/plugins/kanban/links",
|
|
json={"parent_id": b["id"], "child_id": a["id"]},
|
|
)
|
|
assert r.status_code == 400
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dispatch nudge
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_dispatch_dry_run(client):
|
|
client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={"title": "work", "assignee": "researcher"},
|
|
)
|
|
r = client.post("/api/plugins/kanban/dispatch?dry_run=true&max=4")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
# DispatchResult is serialized as a dataclass dict.
|
|
assert isinstance(body, dict)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Triage column (new v1 status)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_create_triage_lands_in_triage_column(client):
|
|
r = client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={"title": "rough idea, spec me", "triage": True},
|
|
)
|
|
assert r.status_code == 200
|
|
task = r.json()["task"]
|
|
assert task["status"] == "triage"
|
|
|
|
r = client.get("/api/plugins/kanban/board")
|
|
triage = next(c for c in r.json()["columns"] if c["name"] == "triage")
|
|
assert len(triage["tasks"]) == 1
|
|
assert triage["tasks"][0]["title"] == "rough idea, spec me"
|
|
|
|
|
|
def test_triage_task_not_promoted_to_ready(client):
|
|
"""Triage tasks must stay in triage even when they have no parents."""
|
|
client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={"title": "must stay put", "triage": True},
|
|
)
|
|
# Run the dispatcher — it should NOT promote the triage task.
|
|
client.post("/api/plugins/kanban/dispatch?dry_run=false&max=4")
|
|
r = client.get("/api/plugins/kanban/board")
|
|
triage = next(c for c in r.json()["columns"] if c["name"] == "triage")
|
|
ready = next(c for c in r.json()["columns"] if c["name"] == "ready")
|
|
assert len(triage["tasks"]) == 1
|
|
assert len(ready["tasks"]) == 0
|
|
|
|
|
|
def test_patch_status_triage_works(client):
|
|
"""A user (or specifier) can push a task back into triage, and out of it."""
|
|
t = client.post(
|
|
"/api/plugins/kanban/tasks", json={"title": "x"},
|
|
).json()["task"]
|
|
# Normal creation is 'ready'; push to triage.
|
|
r = client.patch(
|
|
f"/api/plugins/kanban/tasks/{t['id']}", json={"status": "triage"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["task"]["status"] == "triage"
|
|
|
|
# Now promote to todo.
|
|
r = client.patch(
|
|
f"/api/plugins/kanban/tasks/{t['id']}", json={"status": "todo"},
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json()["task"]["status"] == "todo"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Progress rollup (done children / total children)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_board_progress_rollup(client):
|
|
parent = client.post(
|
|
"/api/plugins/kanban/tasks", json={"title": "parent"},
|
|
).json()["task"]
|
|
child_a = client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={"title": "a", "parents": [parent["id"]]},
|
|
).json()["task"]
|
|
child_b = client.post(
|
|
"/api/plugins/kanban/tasks",
|
|
json={"title": "b", "parents": [parent["id"]]},
|
|
).json()["task"]
|
|
# Children start as "todo" because the parent isn't done yet; promote
|
|
# them to "ready" so complete_task will accept the transition.
|
|
for cid in (child_a["id"], child_b["id"]):
|
|
r = client.patch(
|
|
f"/api/plugins/kanban/tasks/{cid}", json={"status": "ready"},
|
|
)
|
|
assert r.status_code == 200
|
|
|
|
# 0/2 done.
|
|
r = client.get("/api/plugins/kanban/board")
|
|
parent_row = next(
|
|
t for col in r.json()["columns"] for t in col["tasks"]
|
|
if t["id"] == parent["id"]
|
|
)
|
|
assert parent_row["progress"] == {"done": 0, "total": 2}
|
|
|
|
# Complete one child. 1/2.
|
|
r = client.patch(
|
|
f"/api/plugins/kanban/tasks/{child_a['id']}",
|
|
json={"status": "done"},
|
|
)
|
|
assert r.status_code == 200
|
|
r = client.get("/api/plugins/kanban/board")
|
|
parent_row = next(
|
|
t for col in r.json()["columns"] for t in col["tasks"]
|
|
if t["id"] == parent["id"]
|
|
)
|
|
assert parent_row["progress"] == {"done": 1, "total": 2}
|
|
|
|
# Childless tasks report progress=None, not {0/0}.
|
|
assert next(
|
|
t for col in r.json()["columns"] for t in col["tasks"]
|
|
if t["id"] == child_b["id"]
|
|
)["progress"] is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auto-init on first board read
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_board_auto_initializes_missing_db(tmp_path, monkeypatch):
|
|
"""If kanban.db doesn't exist yet, GET /board must create it, not 500."""
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
# Deliberately DO NOT call kb.init_db().
|
|
|
|
app = FastAPI()
|
|
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
|
|
c = TestClient(app)
|
|
r = c.get("/api/plugins/kanban/board")
|
|
assert r.status_code == 200
|
|
assert (home / "kanban.db").exists(), "init_db wasn't invoked by /board"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WebSocket auth (query-param token)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_ws_events_rejects_when_token_required(tmp_path, monkeypatch):
|
|
"""When _SESSION_TOKEN is set (normal dashboard context), a missing or
|
|
wrong ?token= query param must be rejected with policy-violation."""
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
kb.init_db()
|
|
|
|
# Stub web_server so _check_ws_token has a token to compare against.
|
|
import types
|
|
stub = types.SimpleNamespace(_SESSION_TOKEN="secret-xyz")
|
|
monkeypatch.setitem(sys.modules, "hermes_cli.web_server", stub)
|
|
|
|
app = FastAPI()
|
|
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
|
|
c = TestClient(app)
|
|
|
|
# No token → policy violation close.
|
|
from starlette.websockets import WebSocketDisconnect
|
|
with pytest.raises(WebSocketDisconnect) as exc:
|
|
with c.websocket_connect("/api/plugins/kanban/events"):
|
|
pass
|
|
assert exc.value.code == 1008
|
|
|
|
# Wrong token → policy violation close.
|
|
with pytest.raises(WebSocketDisconnect) as exc:
|
|
with c.websocket_connect("/api/plugins/kanban/events?token=nope"):
|
|
pass
|
|
assert exc.value.code == 1008
|
|
|
|
# Correct token → accepted (connect then close cleanly from our side).
|
|
with c.websocket_connect(
|
|
"/api/plugins/kanban/events?token=secret-xyz"
|
|
) as ws:
|
|
assert ws is not None # handshake succeeded
|