File size: 11,784 Bytes
5f400f8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
import asyncio
import json
import os
import shutil
import stat
import sys
import time

import pytest

from bot import speed
from bot.speed import SpeedError, SpeedResult

CLI_RESULT = {
    "type": "result", "timestamp": "2026-10-21T04:00:00Z",
    "ping": {"jitter": 0.52, "latency": 4.21, "low": 3.9, "high": 5.0},
    "download": {"bandwidth": 31_250_000, "bytes": 400_000_000, "elapsed": 12000},
    "upload": {"bandwidth": 12_500_000, "bytes": 150_000_000, "elapsed": 11000},
    "packetLoss": 0, "isp": "Hetzner Online GmbH",
    "server": {"id": 1234, "host": "speed.example.net", "name": "Example Telecom", "location": "Frankfurt", "country": "Germany"},
    "result": {"id": "abc", "url": "https://www.speedtest.net/result/c/abc"},
}
PY_RESULT = {
    "download": 245_310_000.0, "upload": 118_200_000.0, "ping": 11.5, "share": "http://www.speedtest.net/result/1.png",
    "server": {"sponsor": "Example Telecom", "name": "Frankfurt", "country": "Germany"},
    "client": {"isp": "Hetzner Online GmbH", "ip": "1.2.3.4"},
}


def fake_binary(directory, name, version_text, body):
    """An executable `name` that answers --version with ``version_text`` and anything else by running ``body`` (sh)."""
    path = directory / name
    path.write_text(f'#!/bin/sh\nif [ "$1" = "--version" ]; then\n  echo "{version_text}"\n  exit 0\nfi\n{body}\n')
    path.chmod(path.stat().st_mode | stat.S_IEXEC)
    return path


SLEEP = shutil.which("sleep") or "/bin/sleep"  # PATH is replaced in these tests, so scripts need absolute paths
CAT = shutil.which("cat") or "/bin/cat"


def emit(bindir, obj):
    """Shell line that prints ``obj`` as one JSON line (it lives in a file, so quotes inside cannot break the script)."""
    path = bindir.parent / f"out{len(list(bindir.parent.glob('out*.json')))}.json"
    path.write_text(json.dumps(obj) + "\n")
    return f"{CAT} {path}"


@pytest.fixture
def bindir(tmp_path, monkeypatch):
    d = tmp_path / "bin"
    d.mkdir()
    monkeypatch.setenv("PATH", str(d))  # only our fakes can be found
    monkeypatch.delenv("SPEEDTEST_BIN", raising=False)
    return d


# ---------------------------------------------------------------------------
# parsing
# ---------------------------------------------------------------------------


def test_parse_cli_converts_bytes_per_second_to_megabits():
    r = speed.parse_cli(CLI_RESULT)
    assert round(r.download_mbps, 2) == 250.0 and round(r.upload_mbps, 2) == 100.0
    assert r.ping_ms == 4.21 and r.jitter_ms == 0.52 and r.packet_loss == 0
    assert r.isp == "Hetzner Online GmbH" and r.server == "Example Telecom" and r.location == "Frankfurt, Germany"
    assert r.url == "https://www.speedtest.net/result/c/abc" and r.source == "Ookla Speedtest CLI"


def test_parse_cli_copes_with_missing_details_but_not_missing_speeds():
    r = speed.parse_cli({"download": {"bandwidth": 1_000_000}, "upload": {"bandwidth": 500_000}})
    assert r.ping_ms == 0.0 and r.jitter_ms is None and r.packet_loss is None and r.isp == "" and r.url == "" and r.location == ""
    for broken in ({}, {"download": {"bandwidth": 1}}, {"download": {"bandwidth": "x"}, "upload": {"bandwidth": 1}}):
        with pytest.raises(SpeedError):
            speed.parse_cli(broken)


def test_parse_python_converts_bits_per_second_to_megabits():
    r = speed.parse_python(PY_RESULT)
    assert round(r.download_mbps, 2) == 245.31 and round(r.upload_mbps, 2) == 118.2 and r.ping_ms == 11.5
    assert r.isp == "Hetzner Online GmbH" and r.server == "Example Telecom" and r.location == "Frankfurt, Germany"
    assert r.url.endswith("1.png") and "speedtest-cli" in r.source
    with pytest.raises(SpeedError):
        speed.parse_python({"ping": 3})


def test_the_result_line_is_picked_out_of_the_cli_output():
    out = "\n".join(["noise", json.dumps({"type": "log", "message": "x"}), json.dumps(CLI_RESULT), "", "{broken json"])
    assert speed._last_result(out)["isp"] == "Hetzner Online GmbH"
    assert speed._last_result("nothing useful") is None
    assert speed._last_result(json.dumps({"type": "log", "message": "x"})) is None


def test_the_reason_for_a_failure_comes_from_the_log_line():
    out = json.dumps({"type": "log", "level": "error", "message": "Configuration - Couldn't resolve host name"})
    assert speed._log_message(out, "") == "Configuration - Couldn't resolve host name"
    assert speed._log_message("", "segmentation fault") == "segmentation fault"
    assert speed._log_message("", "") == "no output"


# ---------------------------------------------------------------------------
# finding and running the CLI
# ---------------------------------------------------------------------------


async def test_the_official_cli_is_found_by_its_version_text(bindir):
    assert await speed.find_ookla_cli() is None
    fake_binary(bindir, "speedtest", "speedtest-cli 2.1.3\nPython 3.12", "exit 1")  # the pip package's script is not the CLI
    assert await speed.find_ookla_cli() is None
    ookla = fake_binary(bindir, "ookla-speedtest", "Speedtest by Ookla 1.2.0.84 (5ae238b) Linux/x86_64", "exit 1")
    assert await speed.find_ookla_cli() == str(ookla)


async def test_a_plain_speedtest_binary_is_accepted_when_it_is_the_ookla_one(bindir):
    path = fake_binary(bindir, "speedtest", "Speedtest by Ookla 1.2.0.84", "exit 1")
    assert await speed.find_ookla_cli() == str(path)


async def test_the_speedtest_bin_variable_wins(bindir, tmp_path, monkeypatch):
    other = tmp_path / "elsewhere"
    other.mkdir()
    chosen = fake_binary(other, "mytest", "Speedtest by Ookla 1.2.0.84", "exit 1")
    fake_binary(bindir, "ookla-speedtest", "Speedtest by Ookla 1.2.0.84", "exit 1")
    monkeypatch.setenv("SPEEDTEST_BIN", str(chosen))
    assert await speed.find_ookla_cli() == str(chosen)


async def test_run_cli_returns_the_parsed_result(bindir):
    path = fake_binary(bindir, "ookla-speedtest", "Speedtest by Ookla 1.2.0.84", emit(bindir, CLI_RESULT))
    r = await speed.run_cli(str(path))
    assert round(r.download_mbps) == 250 and r.server == "Example Telecom"


async def test_run_cli_passes_the_flags_that_make_it_non_interactive(bindir, tmp_path):
    args_file = tmp_path / "args.txt"
    path = fake_binary(bindir, "ookla-speedtest", "Speedtest by Ookla 1.2.0.84", f'echo "$@" > {args_file}\n{emit(bindir, CLI_RESULT)}')
    await speed.run_cli(str(path))
    args = args_file.read_text().split()
    assert "--accept-license" in args and "--accept-gdpr" in args and "--format=json" in args


async def test_run_cli_reports_why_it_failed(bindir):
    log = {"type": "log", "level": "error", "message": "Configuration - Couldn't resolve host name"}
    path = fake_binary(bindir, "ookla-speedtest", "Speedtest by Ookla 1.2.0.84", f"{emit(bindir, log)}\nexit 1")
    with pytest.raises(SpeedError, match="Couldn't resolve host name"):
        await speed.run_cli(str(path))


async def test_run_cli_gives_up_after_the_timeout_and_kills_the_process(bindir, tmp_path):
    pid_file = tmp_path / "pid"
    path = fake_binary(bindir, "ookla-speedtest", "Speedtest by Ookla 1.2.0.84", f"echo $$ > {pid_file}\nexec {SLEEP} 30")
    started = time.monotonic()
    with pytest.raises(SpeedError, match="took too long"):
        await speed.run_cli(str(path), timeout=0.5)
    assert time.monotonic() - started < 5
    pid = int(pid_file.read_text())
    await asyncio.sleep(0.2)
    with pytest.raises(ProcessLookupError):
        os.kill(pid, 0)


async def test_a_binary_that_cannot_start_is_a_speed_error(tmp_path):
    broken = tmp_path / "broken"
    broken.write_text("not executable")
    with pytest.raises(SpeedError):
        await speed.run_cli(str(broken))


# ---------------------------------------------------------------------------
# run_speedtest: CLI first, Python client second
# ---------------------------------------------------------------------------


async def test_the_cli_is_used_when_it_works(bindir, monkeypatch):
    fake_binary(bindir, "ookla-speedtest", "Speedtest by Ookla 1.2.0.84", emit(bindir, CLI_RESULT))

    def boom():
        raise AssertionError("the Python client is not needed")

    monkeypatch.setattr(speed, "_run_python", boom)
    r = await speed.run_speedtest()
    assert r.source == "Ookla Speedtest CLI"


async def test_the_python_client_takes_over_when_the_cli_is_missing(bindir, monkeypatch):
    monkeypatch.setattr(speed, "_run_python", lambda: PY_RESULT)
    r = await speed.run_speedtest()
    assert "speedtest-cli" in r.source and round(r.download_mbps) == 245


async def test_the_python_client_takes_over_when_the_cli_fails(bindir, monkeypatch):
    fake_binary(bindir, "ookla-speedtest", "Speedtest by Ookla 1.2.0.84", "exit 1")
    monkeypatch.setattr(speed, "_run_python", lambda: PY_RESULT)
    assert "speedtest-cli" in (await speed.run_speedtest()).source


async def test_when_both_fail_the_reasons_are_all_given(bindir, monkeypatch):
    fake_binary(bindir, "ookla-speedtest", "Speedtest by Ookla 1.2.0.84", "echo 'server unreachable'\nexit 1")

    def failing():
        raise SpeedError("speedtest-cli failed: HTTP Error 403: Forbidden")

    monkeypatch.setattr(speed, "_run_python", failing)
    with pytest.raises(SpeedError) as exc:
        await speed.run_speedtest()
    assert "server unreachable" in str(exc.value) and "403" in str(exc.value)


async def test_a_missing_python_client_is_explained(bindir, monkeypatch):
    monkeypatch.setitem(sys.modules, "speedtest", None)  # makes `import speedtest` fail, installed or not
    with pytest.raises(SpeedError) as exc:
        await speed.run_speedtest()
    assert "Speedtest CLI is not installed" in str(exc.value) and "speedtest-cli package is not installed" in str(exc.value)


async def test_errors_from_the_python_client_are_wrapped(bindir, monkeypatch):
    class FakeSpeedtest:
        def __init__(self, *a, **kw):
            raise RuntimeError("HTTP Error 403: Forbidden")

    monkeypatch.setitem(sys.modules, "speedtest", type(sys)("speedtest"))
    sys.modules["speedtest"].Speedtest = FakeSpeedtest
    with pytest.raises(SpeedError, match="speedtest-cli failed: HTTP Error 403"):
        await speed.run_speedtest()


async def test_a_python_client_that_hangs_is_given_up_on(bindir, monkeypatch):
    monkeypatch.setattr(speed, "CLI_TIMEOUT", 0.1)
    monkeypatch.setattr(speed, "_run_python", lambda: time.sleep(0.4) or PY_RESULT)
    with pytest.raises(SpeedError, match="took too long"):
        await speed.run_speedtest()


# ---------------------------------------------------------------------------
# the message
# ---------------------------------------------------------------------------


def test_format_result_lists_every_detail():
    text = speed.format_result(speed.parse_cli(CLI_RESULT))
    assert "SPEEDTEST" in text and "Ookla" in text
    assert "<code>250.00 Mbps</code>" in text and "<code>100.00 Mbps</code>" in text
    assert "<code>4.21 ms</code>" in text and "jitter <code>0.52 ms</code>" in text and "<code>0%</code>" in text
    assert "Hetzner Online GmbH" in text and "Example Telecom · Frankfurt, Germany" in text
    assert '<a href="https://www.speedtest.net/result/c/abc">speedtest.net</a>' in text


def test_format_result_skips_what_is_unknown_and_escapes_the_rest():
    r = SpeedResult(download_mbps=1234.5, upload_mbps=2.0, ping_ms=30, isp="A&B <ISP>", server="", location="", url="javascript:alert(1)")
    text = speed.format_result(r)
    assert "<code>1,234.50 Mbps</code>" in text and "A&amp;B &lt;ISP&gt;" in text
    assert "Server" not in text and "Packet loss" not in text and "jitter" not in text and "<a " not in text
    quoted = SpeedResult(download_mbps=1, upload_mbps=1, ping_ms=1, url='https://x.test/a"b')
    assert 'href="https://x.test/a&quot;b"' in speed.format_result(quoted)