aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_network_change.py
blob: 0d270ba75daae7aa07e776a31849dd1c2cba4c86 (plain) (blame)
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
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
"""
A connection follows the device to another network.

A phone moving between mobile data and the Wi-Fi the node is on keeps a
connection whose candidate pair runs over the network it left. ICE says so only
once its consent checks lapse, about thirty seconds, and on Android not before
the old network has lingered. Reported on the Android application: the group
stopped answering and the application had to be killed; in Chrome, a group that
had failed on Wi-Fi opened on mobile data only after a pull-to-refresh.

Now a change of network asks the open connection for a ping, and a connection
that does not answer is reconnected; a reconnect that is backing off tries again
at once; and a group page left on an error tries again (group-page.js).

These run the shipped transport: harness/network_change_harness.mjs.
"""

import json
import re
import shutil
import subprocess
from pathlib import Path

import pytest
from spa_source import transport_argv

STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
HARNESS = Path(__file__).parent / "harness" / "network_change_harness.mjs"

pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node is not available")

# Connected at 5 s, the network changes at 5 s: settled 1.5 s later, a ping
# unanswered for 5 s, the first reconnect attempt 1 s after that.
CHANGED_AT_MS = 5000


def _run(scenario: str) -> dict:
    proc = subprocess.run(["node", str(HARNESS), transport_argv(),
                           json.dumps({"scenario": scenario})],
                          capture_output=True, text=True, encoding="utf-8", timeout=120)
    assert proc.returncode == 0, proc.stderr
    return json.loads(proc.stdout)


def test_a_connection_left_on_the_old_network_is_reconnected():
    out = _run("change-dead")
    assert out["connected"]
    assert out["connections"] == 2, out
    # Long before ICE's own thirty seconds would have declared it failed.
    assert out["reconnectedAt"] - CHANGED_AT_MS < 10000, out


def test_a_connection_that_still_answers_is_kept():
    out = _run("change-alive")
    assert out["connections"] == 1 and out["reconnectedAt"] is None, out
    assert out["pings"] == 1


def test_an_ice_disconnection_is_checked_rather_than_waited_out():
    dead = _run("ice-dead")
    assert dead["connections"] == 2 and dead["reconnectedAt"] - CHANGED_AT_MS < 10000, dead
    alive = _run("ice-alive")
    assert alive["connections"] == 1 and alive["reconnectedAt"] is None, alive


def test_a_reconnect_backing_off_tries_again_on_the_new_network():
    """Without the wake-up, the attempt after 30 s of failures waits 16 s more."""
    out = _run("backoff")
    assert out["reconnectedAt"] is not None, out
    assert out["reconnectedAt"] - out["backAt"] < 3000, out


def test_only_a_change_of_network_counts_and_once():
    """A bandwidth estimate is not news; Wi-Fi against mobile data, `online` and
    the application's own event are, and a burst of them is one change."""
    out = _run("events")
    assert out["calls"] == [0, 1, 2, 3, 4, 4], out
    assert out["leftListening"] == 0, out


def test_a_closed_transport_stops_listening():
    out = _run("closed")
    assert out["connections"] == 1 and out["leftListening"] == 0, out


def test_a_group_left_on_an_error_tries_again_when_the_network_changes():
    source = (STATIC / "group-page.js").read_text(encoding="utf-8")
    block = re.search(r"onNetworkChange\(.*?\)", source, re.S)
    assert block, "the group page does not listen for another network"
    assert "setRetryKey" in source[block.start():block.start() + 200]


def test_a_reconnect_refused_the_local_network_says_so_and_goes_on():
    """Connected on mobile data, then on the node's Wi-Fi without the permission:
    each attempt used to fail in silence, for ever. Each is now told to the page
    with its reason, the reconnect goes on, and once the permission is granted
    the page's "try now" reaches the group without waiting out the backoff."""
    out = _run("refused")
    assert out["failures"] and set(out["failures"]) == {"local_network_blocked"}, out
    assert out["stillReconnecting"], out
    assert out["reconnectedAt"] - out["backAt"] < 3000, out


def test_try_now_gives_up_an_attempt_set_up_before_the_permission():
    """Measured on the Pixel: granted while an attempt was waiting on ICE, the
    group came back 35 s later, once that attempt and the next backoff ran out."""
    out = _run("refused-during")
    assert out["reconnectedAt"] - out["backAt"] < 2000, out


def test_the_group_page_explains_a_refused_reconnect_and_clears_it():
    source = (STATIC / "group-page.js").read_text(encoding="utf-8")
    start = source.index("addReconnectFailureListener(")
    refused = source[start:start + 400]
    assert "local_network_blocked" in refused and "explainLanRefusal()" in refused
    start = source.index("addReconnectListener(", start)
    back = source[start:start + 300]
    assert "lanLostRef.current" in back and "setError('')" in back
    # The buttons ask the reconnect under way rather than tear it down.
    assert "transport.retryNow()" in source
    for button in ("group.retry", "group.lan_allow", "group.lan_open_settings"):
        end = source.index(f"t('{button}')")
        assert "retry()" in source[end - 700:end] or "onClick=${retry}" in source[end - 100:end], button