874b6f20c2
Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
516 lines
24 KiB
Python
516 lines
24 KiB
Python
#!/usr/bin/env python3
|
|
"""Server resource packs end to end: a real Horizon client joins a real dedicated server (vanilla 26.3's, or
|
|
Horizon's with --horizon-server BIN) that offers a test pack from a local HTTP server, through tools/net_capture.py,
|
|
which records the statuses the client sends back.
|
|
|
|
The pack is built here: dirt recoloured blue, and three lang strings (dirt's name, the pause menu's "Back to Game",
|
|
the server list's "Join Server"). Each scenario sets the server list entry's Server Resource Packs setting in
|
|
servers.dat, joins, answers the prompt the way a player would (horizon-ctl clicks its buttons), and checks the
|
|
statuses against what vanilla's client sends (ServerPackManager):
|
|
|
|
- required, Prompt, Disconnect: no status, the disconnected screen says "This server requires a custom resource
|
|
pack", the setting stays Prompt;
|
|
- required, Prompt, Proceed: ACCEPTED, DOWNLOADED, SUCCESSFULLY_LOADED; the setting becomes Enabled, the file is in
|
|
downloads/ under its SHA-1 with a log.json entry, the HTTP request carries vanilla's X-Minecraft-* headers, the
|
|
blue dirt is on screen (screenshot) and the pause menu says the pack's "Back to Game"; after leaving, the server
|
|
list says "Join Server" again (the pack is gone);
|
|
- required, Enabled: no prompt, the same three statuses, and no second download (the cache);
|
|
- required with a wrong SHA-1: ACCEPTED, FAILED_DOWNLOAD, and the player still joins;
|
|
- optional, Prompt, No: DECLINED, joins, the setting becomes Disabled; then joining again sends DECLINED without
|
|
asking; then Prompt and Yes: the pack (no hash given) is downloaded and loaded;
|
|
- optional on a dead URL: ACCEPTED, FAILED_DOWNLOAD, joins.
|
|
|
|
python3 tools/server_pack_e2e.py # vanilla's server
|
|
python3 tools/server_pack_e2e.py --horizon-server target/release/horizon-client
|
|
|
|
Every game and server here runs in fresh temporary folders (HORIZON_HOME, HORIZON_LAUNCHER_HOME, XDG_*), with no
|
|
audio and no self-update. Needs .local (the vanilla jars and prepared resources) and a built horizon-client and
|
|
horizon-ctl (--bin DIR, default target/fast).
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import http.server
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import socket
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import zipfile
|
|
import zlib
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
import lab # noqa: E402
|
|
import net_capture # noqa: E402
|
|
import test_rust_parity as base # noqa: E402
|
|
from test_dedicated_server_parity import Server, free_port # noqa: E402
|
|
|
|
# ServerboundResourcePackPacket.Action, by ordinal.
|
|
ACTIONS = ['successfully_loaded', 'declined', 'failed_download', 'accepted', 'downloaded', 'invalid_url',
|
|
'failed_reload', 'discarded']
|
|
LOADED = ['accepted', 'downloaded', 'successfully_loaded']
|
|
FAILED = ['accepted', 'failed_download']
|
|
REQUIRED_REASON = 'This server requires a custom resource pack'
|
|
PLAYER = 'Steve'
|
|
PROPERTIES = {
|
|
'online-mode': 'false', 'white-list': 'false', 'level-seed': '12345', 'level-type': 'minecraft\\:flat',
|
|
'generate-structures': 'false', 'view-distance': '3', 'simulation-distance': '3', 'spawn-protection': '0',
|
|
'pause-when-empty-seconds': '-1', 'gamemode': 'creative',
|
|
}
|
|
|
|
|
|
class Failed(Exception):
|
|
pass
|
|
|
|
|
|
def check(condition, what):
|
|
if not condition:
|
|
raise Failed(what)
|
|
print(f' ok: {what}')
|
|
|
|
|
|
# The pack.
|
|
|
|
def png(width, height, pixel):
|
|
rows = b''.join(b'\0' + b''.join(bytes(pixel(x, y)) for x in range(width)) for y in range(height))
|
|
|
|
def chunk(kind, data):
|
|
return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data))
|
|
header = struct.pack('>IIBBBBB', width, height, 8, 6, 0, 0, 0)
|
|
return b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', header) + chunk(b'IDAT', zlib.compress(rows)) + chunk(b'IEND', b'')
|
|
|
|
|
|
def build_pack(path):
|
|
"""The test pack, byte for byte the same every time (fixed dates), and its SHA-1."""
|
|
def blue(x, y):
|
|
return (40, 90, 230, 255) if (x // 4 + y // 4) % 2 else (20, 50, 170, 255)
|
|
files = {
|
|
'pack.mcmeta': json.dumps({'pack': {'description': 'Horizon server pack test',
|
|
'min_format': 97, 'max_format': 97}}).encode(),
|
|
'assets/minecraft/textures/block/dirt.png': png(16, 16, blue),
|
|
'assets/minecraft/lang/en_us.json': json.dumps({
|
|
'block.minecraft.dirt': 'Blue Test Dirt',
|
|
'menu.returnToGame': 'Back to Game (server pack)',
|
|
'selectServer.select': 'Join Server (server pack)',
|
|
}).encode(),
|
|
}
|
|
with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as z:
|
|
for name, data in files.items():
|
|
z.writestr(zipfile.ZipInfo(name, (2026, 1, 1, 0, 0, 0)), data)
|
|
return hashlib.sha1(path.read_bytes()).hexdigest()
|
|
|
|
|
|
def decode_png(data):
|
|
"""RGB(A) pixels of an 8-bit PNG: (width, height, rows of (r, g, b))."""
|
|
pos, idat, width = 8, b'', 0
|
|
while pos < len(data):
|
|
length, kind = struct.unpack('>I4s', data[pos:pos + 8])
|
|
body = data[pos + 8:pos + 8 + length]
|
|
if kind == b'IHDR':
|
|
width, height, depth, colour = struct.unpack('>IIBB', body[:10])
|
|
assert depth == 8 and colour in (2, 6), (depth, colour)
|
|
channels = 4 if colour == 6 else 3
|
|
elif kind == b'IDAT':
|
|
idat += body
|
|
pos += 12 + length
|
|
raw, stride, rows, previous = zlib.decompress(idat), width * channels, [], bytearray(width * channels)
|
|
for y in range(height):
|
|
kind, line = raw[y * (stride + 1)], bytearray(raw[y * (stride + 1) + 1:(y + 1) * (stride + 1)])
|
|
for i in range(stride):
|
|
left = line[i - channels] if i >= channels else 0
|
|
up, corner = previous[i], previous[i - channels] if i >= channels else 0
|
|
if kind == 1:
|
|
line[i] = (line[i] + left) & 255
|
|
elif kind == 2:
|
|
line[i] = (line[i] + up) & 255
|
|
elif kind == 3:
|
|
line[i] = (line[i] + (left + up) // 2) & 255
|
|
elif kind == 4:
|
|
p = left + up - corner
|
|
pa, pb, pc = abs(p - left), abs(p - up), abs(p - corner)
|
|
line[i] = (line[i] + (left if pa <= pb and pa <= pc else up if pb <= pc else corner)) & 255
|
|
rows.append([tuple(line[x * channels:x * channels + 3]) for x in range(width)])
|
|
previous = line
|
|
return width, height, rows
|
|
|
|
|
|
def blue_share(path):
|
|
"""How much of the picture's lower middle (the ground under a player looking down) is blue."""
|
|
width, height, rows = decode_png(path.read_bytes())
|
|
samples = [rows[y][x] for y in range(height // 2 + height // 8, height - height // 8, 4)
|
|
for x in range(width // 4, 3 * width // 4, 4)]
|
|
return sum(b > r + 60 and b > g + 30 for r, g, b in samples) / len(samples)
|
|
|
|
|
|
# The HTTP server the pack comes from.
|
|
|
|
class Files(http.server.ThreadingHTTPServer):
|
|
def __init__(self, files):
|
|
self.files, self.requests = files, []
|
|
|
|
class Handler(http.server.BaseHTTPRequestHandler):
|
|
def do_GET(handler):
|
|
headers = {k: v for k, v in handler.headers.items() if k.lower().startswith(('x-minecraft', 'user-agent'))}
|
|
self.requests.append((handler.path, headers))
|
|
data = self.files.get(handler.path)
|
|
if data is None:
|
|
handler.send_error(404)
|
|
return
|
|
handler.send_response(200)
|
|
handler.send_header('Content-Type', 'application/zip')
|
|
handler.send_header('Content-Length', str(len(data)))
|
|
handler.end_headers()
|
|
handler.wfile.write(data)
|
|
|
|
def log_message(handler, *args):
|
|
pass
|
|
super().__init__(('127.0.0.1', 0), Handler)
|
|
threading.Thread(target=self.serve_forever, daemon=True).start()
|
|
|
|
def url(self, path):
|
|
return f'http://127.0.0.1:{self.server_address[1]}{path}'
|
|
|
|
|
|
# servers.dat (NbtIo.write: uncompressed, a named root compound).
|
|
|
|
def nbt_string(s):
|
|
data = s.encode()
|
|
return struct.pack('>H', len(data)) + data
|
|
|
|
|
|
def write_servers(path, ip, setting):
|
|
"""One entry for ip; setting True (Enabled), False (Disabled) or None (Prompt: no acceptTextures)."""
|
|
entry = b'\x08' + nbt_string('name') + nbt_string('Pack test') + b'\x08' + nbt_string('ip') + nbt_string(ip)
|
|
if setting is not None:
|
|
entry += b'\x01' + nbt_string('acceptTextures') + bytes([int(setting)])
|
|
root = b'\x09' + nbt_string('servers') + b'\x0a' + struct.pack('>i', 1) + entry + b'\x00'
|
|
path.write_bytes(b'\x0a' + nbt_string('') + root + b'\x00')
|
|
|
|
|
|
def read_setting(path):
|
|
data = path.read_bytes()
|
|
key = b'\x01' + nbt_string('acceptTextures')
|
|
at = data.find(key)
|
|
return None if at < 0 else bool(data[at + len(key)])
|
|
|
|
|
|
# The game.
|
|
|
|
class Game:
|
|
def __init__(self, bins, resources, work):
|
|
self.home = work / 'home'
|
|
for d in ('home', 'launcher', 'data', 'config'):
|
|
(work / d).mkdir(parents=True)
|
|
self.env = lab.game_env(HORIZON_HOME=self.home, HORIZON_LAUNCHER_HOME=work / 'launcher',
|
|
XDG_DATA_HOME=work / 'data', XDG_CONFIG_HOME=work / 'config', HORIZON_TELEMETRY_URL='off')
|
|
self.base = [str(bins / 'horizon-ctl'), '--socket', str(work / 'c.sock')]
|
|
self.log = work / 'c.log'
|
|
reply = self('launch', '--game', bins / 'horizon-client', '--offscreen', '--resources', resources)
|
|
self.pid = reply.get('pid')
|
|
|
|
def __call__(self, *words, ok=True, timeout=300):
|
|
result = subprocess.run(self.base + [str(w) for w in words], cwd=self.home, env=self.env,
|
|
capture_output=True, text=True, timeout=timeout)
|
|
lines = result.stdout.strip().splitlines()
|
|
reply = json.loads(lines[-1]) if lines else {'error': result.stderr.strip()}
|
|
print(f' {" ".join(map(str, words))[:80]} -> {json.dumps(reply)[:200]}')
|
|
if ok and (result.returncode or 'error' in reply):
|
|
raise Failed(f'{" ".join(map(str, words))}: {reply}')
|
|
return reply
|
|
|
|
def labels(self):
|
|
return [w.get('label') for w in self('ui').get('widgets', [])]
|
|
|
|
def wait_widget(self, widget, timeout=60):
|
|
return self('wait-for', 'widget', widget, '--timeout', timeout, ok=False)
|
|
|
|
def quit(self):
|
|
try:
|
|
self('quit', ok=False, timeout=60)
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
if self.pid:
|
|
for _ in range(40):
|
|
try:
|
|
os.kill(self.pid, 0)
|
|
except OSError:
|
|
return
|
|
time.sleep(0.5)
|
|
os.kill(self.pid, 9)
|
|
|
|
|
|
def statuses(capture, conn):
|
|
"""The resource pack statuses the client sent on one connection: (id, action), in order."""
|
|
out = []
|
|
for e in net_capture.load(capture):
|
|
if e.get('conn') == conn and e.get('dir') == 'c2s' and e.get('name') == 'resource_pack':
|
|
data = bytes.fromhex(e['data'])
|
|
out.append((data[:16].hex(), ACTIONS[data[16]]))
|
|
return out
|
|
|
|
|
|
def connections(capture):
|
|
return max([e['conn'] for e in net_capture.load(capture) if 'conn' in e], default=0)
|
|
|
|
|
|
class Run:
|
|
def __init__(self, args, out):
|
|
self.args, self.out = args, out
|
|
lock, jars, libraries = base.verify_artifacts()
|
|
deps = os.pathsep.join(map(str, [jars['server'], *libraries]))
|
|
self.vanilla = ['java', '-Xmx2g', '-cp', deps, 'net.minecraft.server.Main', 'nogui']
|
|
self.resources = lab.LOCAL / 'horizon-client' / lab.VERSION
|
|
self.files = {}
|
|
self.http = Files(self.files)
|
|
self.sha1 = build_pack(out / 'test-pack.zip')
|
|
self.files['/test-pack.zip'] = (out / 'test-pack.zip').read_bytes()
|
|
self.files['/optional-pack.zip'] = self.files['/test-pack.zip']
|
|
dead = free_port() # nothing listens there
|
|
self.packs = {
|
|
'required': {'resource-pack': self.http.url('/test-pack.zip'), 'resource-pack-sha1': self.sha1,
|
|
'require-resource-pack': 'true', 'resource-pack-id': '5b2c7a4e-1f3d-4c8b-9a6e-2d4f8b1c3e5a',
|
|
'resource-pack-prompt': '["Blue dirt for ",{"text":"this test","color":"aqua"}]'},
|
|
'bad-sha1': {'resource-pack': self.http.url('/test-pack.zip'), 'resource-pack-sha1': '0' * 40,
|
|
'require-resource-pack': 'true'},
|
|
'optional': {'resource-pack': self.http.url('/optional-pack.zip'), 'require-resource-pack': 'false'},
|
|
'dead-url': {'resource-pack': f'http://127.0.0.1:{dead}/gone.zip', 'require-resource-pack': 'false'},
|
|
}
|
|
self.results = []
|
|
|
|
def start_server(self, kind, work):
|
|
directory = work / f'server-{kind}'
|
|
directory.mkdir()
|
|
port = free_port()
|
|
props = dict(PROPERTIES, **self.packs[kind], **{'server-port': str(port)})
|
|
(directory / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items()),
|
|
encoding='iso-8859-1')
|
|
(directory / 'eula.txt').write_text('eula=true\n')
|
|
if self.args.horizon_server:
|
|
command = [self.args.horizon_server, '--resources', self.resources, '--dedicated', '--nogui']
|
|
else:
|
|
command = self.vanilla
|
|
server = Server(command, directory, f'server-{kind}')
|
|
server.wait_for(r'Done \(', timeout=300)
|
|
return server, port
|
|
|
|
def join(self, game, address, scenario):
|
|
"""Connects; returns the capture's connection number."""
|
|
game('connect', address, PLAYER)
|
|
deadline = time.time() + 30
|
|
while connections(self.capture) <= self.conn and time.time() < deadline:
|
|
time.sleep(0.2)
|
|
self.conn = connections(self.capture)
|
|
print(f' [{scenario}] connection {self.conn}')
|
|
return self.conn
|
|
|
|
def scenario(self, name, fn):
|
|
print(f'- {name}')
|
|
try:
|
|
fn()
|
|
self.results.append((name, 'pass', ''))
|
|
except Exception as e: # noqa: BLE001: report every scenario
|
|
print(f' FAILED: {e}')
|
|
self.results.append((name, 'FAIL', str(e)[:300]))
|
|
if self.game('state', ok=False).get('screen') not in (None, 'title'):
|
|
self.leave(self.game)
|
|
|
|
def wait_statuses(self, conn, want, timeout=60):
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
got = [a for _, a in statuses(self.capture, conn)]
|
|
if len(got) >= len(want) and got[:len(want)] == want or got[:1] and got != want[:len(got)]:
|
|
break
|
|
time.sleep(0.3)
|
|
time.sleep(1.5) # nothing more comes after the last one
|
|
got = [a for _, a in statuses(self.capture, conn)]
|
|
check(got == want, f'statuses {got} == {want}')
|
|
|
|
def leave(self, game):
|
|
game('disconnect', ok=False)
|
|
time.sleep(1)
|
|
|
|
def run(self):
|
|
work = Path(tempfile.mkdtemp(prefix='hz-server-packs-'))
|
|
print(f'scratch: {work}')
|
|
# The servers too get scratch homes (the game sets its own below).
|
|
for var, sub in (('HORIZON_HOME', 'home'), ('HORIZON_LAUNCHER_HOME', 'launcher'), ('XDG_DATA_HOME', 'data'),
|
|
('XDG_CONFIG_HOME', 'config')):
|
|
(work / 'servers' / sub).mkdir(parents=True)
|
|
os.environ[var] = str(work / 'servers' / sub)
|
|
os.environ.update(lab.game_env(HORIZON_TELEMETRY_URL='off'))
|
|
game = self.game = Game(Path(self.args.bin), self.resources, work / 'game')
|
|
servers_dat = game.home / 'servers.dat'
|
|
downloads = game.home / 'downloads'
|
|
try:
|
|
for kind, scenarios in self.plan(game, servers_dat, downloads):
|
|
server, port = self.start_server(kind, work)
|
|
# One capture per server: its connections are numbered from 1.
|
|
self.capture, self.conn = self.out / f'capture-{kind}.jsonl', 0
|
|
self.capture.touch()
|
|
listen = free_port()
|
|
stop = net_capture.start_background(listen, f'127.0.0.1:{port}', self.capture)
|
|
self.address = f'127.0.0.1:{listen}'
|
|
try:
|
|
self.server = server
|
|
for name, fn in scenarios:
|
|
self.scenario(f'{kind}: {name}', fn)
|
|
finally:
|
|
stop.set()
|
|
server.stop()
|
|
shutil.copy(server.log_path, self.out / server.log_path.name)
|
|
finally:
|
|
game.quit()
|
|
if game.log.exists():
|
|
shutil.copy(game.log, self.out / 'client.log')
|
|
if downloads.exists():
|
|
shutil.copytree(downloads, self.out / 'downloads')
|
|
return work
|
|
|
|
def plan(self, game, servers_dat, downloads):
|
|
sha1 = self.sha1
|
|
|
|
def required_disconnect():
|
|
write_servers(servers_dat, self.address, None)
|
|
conn = self.join(game, self.address, 'decline')
|
|
check(game.wait_widget('disconnect').get('ok'), 'the required prompt opened')
|
|
labels = game.labels()
|
|
check('Proceed' in labels and 'Disconnect' in labels, f'Proceed and Disconnect buttons: {labels}')
|
|
game('screenshot', self.out / 'prompt-required.png')
|
|
game('click', 'disconnect')
|
|
self.wait_statuses(conn, [])
|
|
screen = json.dumps(game('screen'))
|
|
check(REQUIRED_REASON in screen, 'the disconnected screen says "This server requires a custom resource pack"')
|
|
check(read_setting(servers_dat) is None, 'the setting stays Prompt')
|
|
check(not any(p == '/test-pack.zip' for p, _ in self.http.requests), 'nothing was downloaded')
|
|
|
|
def required_proceed():
|
|
conn = self.join(game, self.address, 'proceed')
|
|
check(game.wait_widget('proceed').get('ok'), 'the required prompt opened')
|
|
game('click', 'proceed')
|
|
game('wait-for', 'loaded', '--timeout', 180)
|
|
self.wait_statuses(conn, LOADED)
|
|
check(read_setting(servers_dat) is True, 'the setting became Enabled (acceptTextures 1)')
|
|
pack_id = self.packs['required']['resource-pack-id']
|
|
check((downloads / pack_id / sha1).exists(), f'downloads/{pack_id}/{sha1} is cached')
|
|
log = json.loads((downloads / 'log.json').read_text().splitlines()[-1])
|
|
check(log.get('id') == pack_id and log.get('hash') == sha1
|
|
and log.get('file', {}).get('name') == f'{pack_id}/{sha1}', f'log.json entry {log}')
|
|
path, headers = [r for r in self.http.requests if r[0] == '/test-pack.zip'][-1]
|
|
want = {'X-Minecraft-Username': PLAYER, 'X-Minecraft-Version': lab.VERSION,
|
|
'X-Minecraft-Version-ID': lab.VERSION, 'X-Minecraft-Pack-Format': '97.1',
|
|
'User-Agent': f'Minecraft Java/{lab.VERSION}'}
|
|
check(all(headers.get(k) == v for k, v in want.items()) and len(headers.get('X-Minecraft-UUID', '')) == 32,
|
|
f'download headers {headers}')
|
|
self.server.console(f'execute at {PLAYER} run fill ~-6 ~-1 ~-6 ~6 ~-1 ~6 minecraft:dirt')
|
|
game('look', 0, 90)
|
|
game('wait', 40)
|
|
shot = self.out / 'applied-blue-dirt.png'
|
|
game('screenshot', shot)
|
|
share = blue_share(shot)
|
|
check(share > 0.5, f'the ground is the pack\'s blue dirt ({share:.0%} of the samples)')
|
|
game('key', 'esc', 'tap')
|
|
game('wait', 5)
|
|
labels = game.labels()
|
|
check('Back to Game (server pack)' in labels, f'the pause menu uses the pack\'s lang: {labels}')
|
|
game('key', 'esc', 'tap')
|
|
self.leave(game)
|
|
labels = game.labels()
|
|
check('Join Server' in labels and 'Join Server (server pack)' not in labels,
|
|
f'after leaving, the pack is gone: {labels}')
|
|
|
|
def required_enabled():
|
|
before = len(self.http.requests)
|
|
conn = self.join(game, self.address, 'enabled')
|
|
game('wait-for', 'loaded', '--timeout', 180)
|
|
self.wait_statuses(conn, LOADED)
|
|
check(len(self.http.requests) == before, 'no second download: the cached file is used')
|
|
game('key', 'esc', 'tap')
|
|
game('wait', 5)
|
|
check('Back to Game (server pack)' in game.labels(), 'the pack is applied without a prompt')
|
|
game('key', 'esc', 'tap')
|
|
self.leave(game)
|
|
|
|
def bad_sha1():
|
|
write_servers(servers_dat, self.address, True)
|
|
conn = self.join(game, self.address, 'bad-sha1')
|
|
game('wait-for', 'loaded', '--timeout', 180)
|
|
self.wait_statuses(conn, FAILED)
|
|
entries = [json.loads(line) for line in (downloads / 'log.json').read_text().splitlines()]
|
|
failed = [e for e in entries if e.get('hash') == '0' * 40]
|
|
check(failed and failed[-1].get('error') == 'download_failed' and 'file' not in failed[-1],
|
|
f'log.json records the failure: {failed[-1:]}')
|
|
self.leave(game)
|
|
|
|
def optional_no():
|
|
write_servers(servers_dat, self.address, None)
|
|
conn = self.join(game, self.address, 'optional-no')
|
|
check(game.wait_widget('no').get('ok'), 'the optional prompt opened')
|
|
labels = game.labels()
|
|
check('Yes' in labels and 'No' in labels, f'Yes and No buttons: {labels}')
|
|
game('screenshot', self.out / 'prompt-optional.png')
|
|
game('click', 'no')
|
|
game('wait-for', 'loaded', '--timeout', 180)
|
|
self.wait_statuses(conn, ['declined'])
|
|
check(read_setting(servers_dat) is False, 'the setting became Disabled (acceptTextures 0)')
|
|
self.leave(game)
|
|
|
|
def optional_disabled():
|
|
conn = self.join(game, self.address, 'optional-disabled')
|
|
game('wait-for', 'loaded', '--timeout', 180)
|
|
self.wait_statuses(conn, ['declined'])
|
|
self.leave(game)
|
|
|
|
def optional_yes():
|
|
write_servers(servers_dat, self.address, None)
|
|
conn = self.join(game, self.address, 'optional-yes')
|
|
check(game.wait_widget('yes').get('ok'), 'the optional prompt opened')
|
|
game('click', 'yes')
|
|
game('wait-for', 'loaded', '--timeout', 180)
|
|
self.wait_statuses(conn, LOADED)
|
|
check(any(r[0] == '/optional-pack.zip' for r in self.http.requests), 'downloaded without a hash')
|
|
self.leave(game)
|
|
|
|
def dead_url():
|
|
write_servers(servers_dat, self.address, True)
|
|
conn = self.join(game, self.address, 'dead-url')
|
|
game('wait-for', 'loaded', '--timeout', 180)
|
|
self.wait_statuses(conn, FAILED)
|
|
self.leave(game)
|
|
|
|
return [
|
|
('required', [('Disconnect at the prompt', required_disconnect), ('Proceed', required_proceed),
|
|
('Enabled, cached', required_enabled)]),
|
|
('bad-sha1', [('wrong SHA-1', bad_sha1)]),
|
|
('optional', [('No at the prompt', optional_no), ('Disabled', optional_disabled),
|
|
('Yes at the prompt, no hash', optional_yes)]),
|
|
('dead-url', [('dead URL', dead_url)]),
|
|
]
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument('--bin', default=str(lab.cargo_target() / 'fast'), help='horizon-client and horizon-ctl')
|
|
parser.add_argument('--horizon-server', help='a horizon-client binary to run as the dedicated server')
|
|
parser.add_argument('--out', default=str(lab.LOCAL / 'server-pack-e2e'))
|
|
args = parser.parse_args()
|
|
out = Path(args.out) / ('horizon' if args.horizon_server else 'vanilla')
|
|
shutil.rmtree(out, ignore_errors=True)
|
|
out.mkdir(parents=True)
|
|
run = Run(args, out)
|
|
work = run.run()
|
|
print(f'\nserver: {"horizon" if args.horizon_server else "vanilla 26.3"}; evidence in {out}')
|
|
for name, result, detail in run.results:
|
|
print(f' {result:4} {name}' + (f': {detail}' if detail else ''))
|
|
shutil.rmtree(work, ignore_errors=True)
|
|
if any(r != 'pass' for _, r, _ in run.results):
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|