mirror of
https://github.com/Mudlet/Mudlet
synced 2026-08-13 18:26:27 -04:00
#### Brief overview of PR changes/additions - **Media**: a preload that has to fetch its file now keeps it without playing it (#9783 - the same guard covers GMCP `Client.Media.Load`, which preloads the same way), each `load*File()` stamps its own media type so a load can only reach players of its own kind (#9784), and the load/play errors name the function that was actually called instead of always `loadMusicFile`/`playSoundFile` (#9785). - **IRC**: `setIrcServer()` keeps a stored password it was not given - an empty string still clears it - and takes an explicit `nil` for any optional argument the way an omitted one is taken (#9786, #9787); `getIrcConnectedHost()` returns the documented `true, host` pair instead of dropping the boolean (#9788); `setIrcChannels()` drops a name carrying whitespace or a comma rather than storing one channel that reads back as two (#9789). - Specs for six of the seven ship with them; the fixture server answers a GET below `/media` for a `.wav` with generated silence, so a preload spec has a real file to fetch. `getIrcConnectedHost()`'s success path needs a client connected far enough for the server's `RPL_YOURHOST`, which this suite deliberately never opens, so that one was checked with temporary instrumentation (before: 1 value, the host name; after: `true` plus the host name) rather than left to a spec that could not reach it. #### Motivation for adding to Mudlet All seven were found while writing the IRC and media specs in #9772 and filed from there. The two with teeth: a preload occupies a player and reports itself as playing, and a script that adjusts the IRC server destroys the saved password as a side effect. #### Other info (issues closed, discussion etc) Keeping an unmentioned password means it now survives a change of server too, which the spec pins; `setConfig("ircPassword", "")` and the empty string both clear it. No `mmcp*` code or Networking_spec MMCP region is touched, so #9744 stays clear. **Test case:** `lua loadSoundFile({name = "x.wav", url = "http://127.0.0.1:PORT"})` - after `sysDownloadDone` the file is in the profile's media directory and `getPlayingSounds()` is empty, while `playSoundFile()` with the same url still plays. Closes #9783 Closes #9784 Closes #9785 Closes #9786 Closes #9787 Closes #9788 Closes #9789 Assisted-by: Claude:claude-opus-5
152 lines
6.2 KiB
Python
152 lines
6.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Minimal fixture HTTP server for Mudlet's busted networking specs.
|
|
|
|
Serves the sibling ``http-fixtures/`` directory over localhost so the Lua test
|
|
suite can exercise getHTTP/downloadFile against a real, local endpoint instead
|
|
of the public internet.
|
|
|
|
A GET below ``/media`` for a ``.wav`` is answered with a generated WAV rather
|
|
than from disk (GET only, as with ``/echo`` below): the media specs need a file
|
|
long enough to still be playing when they look, whether they expect that or not,
|
|
and generating silence keeps an 80KB binary out of the repository.
|
|
|
|
Requests below ``/echo`` are answered by an echo endpoint instead of from disk:
|
|
it accepts GET and every verb this handler has no method of its own for
|
|
(postHTTP/putHTTP/deleteHTTP/customHTTP all need one) and reports the method,
|
|
path, request headers and body it received back in the response body, which is
|
|
what lets a spec prove that what Mudlet put on the wire is what the caller
|
|
asked for. HEAD is the one exception: it keeps serving files from disk.
|
|
|
|
An OS-assigned (ephemeral) port is used rather than a fixed one: Mudlet CI may
|
|
run several jobs on the same machine, and a hard-coded port would risk
|
|
collisions there. The chosen port is written to the file named by the
|
|
``MUDLET_TEST_HTTP_PORT_FILE`` environment variable so the launching CI step can
|
|
forward it to Mudlet as ``MUDLET_TEST_HTTP_PORT``.
|
|
"""
|
|
|
|
import http.server
|
|
import os
|
|
import socketserver
|
|
import struct
|
|
|
|
FIXTURES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "http-fixtures")
|
|
|
|
ECHO_PATH = "/echo"
|
|
|
|
MEDIA_PATH = "/media/"
|
|
|
|
# Long enough that a spec which starts playback still has it running when it
|
|
# asks what is playing, and that one which expects no playback would have seen
|
|
# it by then.
|
|
MEDIA_SECONDS = 10
|
|
|
|
|
|
def silent_wav(seconds):
|
|
"""A WAV of the given length: 8 bit, 8kHz mono silence, which needs no codec beyond PCM."""
|
|
sample_rate = 8000
|
|
# 128 is silence for unsigned 8 bit samples
|
|
samples = b"\x80" * (sample_rate * seconds)
|
|
# chunk size, PCM format, channels, sample rate, byte rate, block align, bits
|
|
header = b"fmt " + struct.pack("<IHHIIHH", 16, 1, 1, sample_rate, sample_rate, 1, 8)
|
|
data = b"data" + struct.pack("<I", len(samples)) + samples
|
|
body = b"WAVE" + header + data
|
|
return b"RIFF" + struct.pack("<I", len(body)) + body
|
|
|
|
|
|
class QuietHandler(http.server.SimpleHTTPRequestHandler):
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, directory=FIXTURES_DIR, **kwargs)
|
|
|
|
def log_message(self, *args):
|
|
# Keep CI logs quiet; the tests assert on effects, not on server chatter.
|
|
pass
|
|
|
|
def end_headers(self):
|
|
# Both are sent on every response, including the static file ones, so a
|
|
# spec can assert that the response headers and cookies tables Mudlet
|
|
# builds reach Lua.
|
|
self.send_header("X-Mudlet-Fixture", "1")
|
|
self.send_header("Set-Cookie", "mudlet-fixture=1; Path=/")
|
|
super().end_headers()
|
|
|
|
def do_GET(self):
|
|
if self.echo_requested():
|
|
self.echo()
|
|
return
|
|
if self.media_requested():
|
|
self.serve_media()
|
|
return
|
|
super().do_GET()
|
|
|
|
def media_requested(self):
|
|
return self.path.startswith(MEDIA_PATH) and self.path.endswith(".wav")
|
|
|
|
def serve_media(self):
|
|
payload = silent_wav(MEDIA_SECONDS)
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "audio/wav")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
|
|
def __getattr__(self, name):
|
|
# BaseHTTPRequestHandler dispatches "VERB /path" to a do_VERB method and
|
|
# answers 501 when there is none. Only GET and HEAD have one, so every
|
|
# other verb - POST/PUT/DELETE plus whatever customHTTP() invents - is
|
|
# routed here and handled by the echo endpoint. Matching against the
|
|
# verb being dispatched keeps a mistyped attribute elsewhere in this
|
|
# class an AttributeError instead of silently becoming an echo.
|
|
# __dict__ rather than self.command: the attribute only exists once a
|
|
# request line has been parsed, and reading it through the instance
|
|
# would come straight back here.
|
|
if name.startswith("do_") and name == "do_%s" % self.__dict__.get("command"):
|
|
return self.echo
|
|
raise AttributeError(name)
|
|
|
|
def echo_requested(self):
|
|
return self.path == ECHO_PATH or self.path.startswith(ECHO_PATH + "/") or self.path.startswith(ECHO_PATH + "?")
|
|
|
|
def echo(self):
|
|
if not self.echo_requested():
|
|
self.send_error(404, "Not Found", "only %s answers this method" % ECHO_PATH)
|
|
return
|
|
|
|
try:
|
|
length = int(self.headers.get("Content-Length") or 0)
|
|
except ValueError:
|
|
length = 0
|
|
body = self.rfile.read(length) if length > 0 else b""
|
|
|
|
lines = ["method=%s" % self.command, "path=%s" % self.path]
|
|
for name, value in self.headers.items():
|
|
lines.append("header:%s=%s" % (name.lower(), value))
|
|
# Body last: it is the only part that may itself contain newlines.
|
|
lines.append("body=%s" % body.decode("utf-8", "replace"))
|
|
payload = "\n".join(lines).encode("utf-8")
|
|
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
|
|
|
|
def main():
|
|
# Single-threaded on purpose: the specs issue one request at a time, and
|
|
# HTTP/1.0 (the default here) closes each connection, so no request can
|
|
# block another.
|
|
with socketserver.TCPServer(("127.0.0.1", 0), QuietHandler) as httpd:
|
|
port = httpd.server_address[1]
|
|
port_file = os.environ.get("MUDLET_TEST_HTTP_PORT_FILE")
|
|
if port_file:
|
|
# Write then rename so the launcher never reads a torn/empty port.
|
|
tmp_file = port_file + ".tmp"
|
|
with open(tmp_file, "w", encoding="utf-8") as handle:
|
|
handle.write(str(port))
|
|
os.replace(tmp_file, port_file)
|
|
print(f"Serving Mudlet test fixtures on http://127.0.0.1:{port}", flush=True)
|
|
httpd.serve_forever()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|