1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
|
"""
Sending a chat message must come back — accepted or refused.
The node's replies to a chat message name no request. The acceptance is a bare
`{"type": "ack"}`; the refusal is a bare `{"type": "error"}`, and it is not a
special case — `_dispatch_message`'s catch-all answers *every* failure that
way, and 238 of webrtc_server.py's 240 error sends name nothing either. So
`_dispatch` had nothing to match either reply on and left both to the
arrival-order guess at the end of the function.
That guess is wrong as soon as anything else this browser asked for is still
waiting, which is the ordinary case rather than a rare one: a `music_meta_req`
sits in `_pending` for as long as the third-party lookup behind it takes, and
that was measured live at over 100 seconds with the service failing. The reply
went to *that* request, and the send waited out `_sendAndWait`'s 30s timeout.
Since the composer is disabled while a send is in flight, the Chat tab stopped
taking clicks and keys, and the message never appeared.
The ack half was fixed by matching on request type. The refusal half could not
be: an `error` has no type of its own to match on. `req_id` is what closed it —
the caller's id, stamped on the reply by the node — so this now drives both
shapes of answer.
None of it is visible in `chat-app.js`, where every line is correct, so this
drives the real panel over the real transport in a browser rather than reading
either source.
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
HARNESS = Path(__file__).parent / "harness" / "chat_send_probe.py"
STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
pytestmark = pytest.mark.skipif(
shutil.which("google-chrome") is None or not (STATIC / "chat-app.js").exists(),
reason="Chrome or the SPA sources are not available")
@pytest.fixture(scope="module")
def probe():
run = subprocess.run(["python3", str(HARNESS)], capture_output=True, timeout=180)
assert run.returncode == 0, run.stderr.decode()[-2000:]
data = json.loads(run.stdout.decode())
steps = {sc["name"]: {s["label"]: s for s in sc["steps"]}
for sc in data["scenarios"]}
return data, steps
@pytest.mark.parametrize("reply", ["ack", "error"])
def test_the_composer_comes_back(probe, reply):
"""The one thing a person sees: the tab is usable again.
Both answers have to release it. A refusal that reaches nobody leaves the
composer disabled exactly as long as an acceptance that reaches nobody —
the composer is not waiting for good news, it is waiting for an answer.
"""
_, steps = probe
assert steps[reply]["older request pending"]["composerDisabled"] is False, (
"the composer was already unusable before the send")
assert steps[reply]["after send"]["composerDisabled"] is False, (
"the composer is still disabled well inside the 30s request timeout -- "
"the send never came back, which is what reads as a frozen Chat tab")
def test_an_accepted_message_is_displayed(probe):
"""A sent message appears at once, not on the next visit to the tab."""
_, steps = probe
before = steps["ack"]["older request pending"]["bubbles"]
assert steps["ack"]["after send"]["bubbles"] == before + 1, (
"the message was not added to the conversation")
assert steps["ack"]["after send"]["lastText"] == "hello"
assert steps["ack"]["after send"]["composerValue"] == "", (
"the text came back into the composer, so the send was treated as failed")
def test_a_refused_message_is_not_displayed_as_sent(probe):
"""The other direction, and the one routing this correctly makes possible.
While a refusal reached the wrong caller it did not matter what `sendChat`
would have done with it. Now that it arrives, a message the node rejected
must not appear in the conversation as though it had been stored — it must
come back into the composer, where a person can see it did not go.
"""
_, steps = probe
before = steps["error"]["older request pending"]["bubbles"]
assert steps["error"]["after send"]["bubbles"] == before, (
"a refused message was added to the conversation anyway")
assert steps["error"]["after send"]["composerValue"] == "hello", (
"the refused text was dropped instead of being handed back")
@pytest.mark.parametrize("reply", ["ack", "error"])
def test_the_reply_is_not_handed_to_another_request(probe, reply):
"""The other half of the same defect: whatever was waiting got the reply
and carried on with an answer to a question it never asked."""
data, _ = probe
stolen = [line for line in data["log"] if line.startswith(f"{reply}: music_meta")]
assert not stolen, (
f"the chat {reply} was routed to the pending music_meta_req ({stolen}) -- "
"that request now believes it has an answer, and the chat send is "
"waiting for a reply that already arrived")
|