Files
Ludovic MarieandClaude Opus 5.5 2d257a0bdd Merge main into #6650
server_e2e: dialog_close now uses main's frame proxy (renamed PacketProxy,
with close_container through its send_packet) instead of a second proxy
class; the server runs with the proxy for book_menu or dialog_close.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 14:46:47 +01:00

527 lines
23 KiB
Python

#!/usr/bin/env python3
"""Horizon's client on a vanilla 26.3 dedicated server, end to end.
A vanilla server (the pinned server jar, offline mode, a flat world) and one Horizon game joined to it,
driven by horizon-ctl. The server's console does what a server or its plugins do; the checks read what
the game shows and what reaches the server (its console and, for custom click actions, its debug log).
python3 tools/server_e2e.py --bin-dir target/fast # every check
python3 tools/server_e2e.py --bin-dir target/fast --check dialog
Checks:
- dialog: `/dialog show` with a multi-action dialog (a message, a text and a boolean input, three
buttons): the game shows it with its inputs and buttons, the text typed and the box ticked
go back in a command template (`trigger`, run by the server) and in a custom action's
payload (the server's "Received custom click action" line), `wait_for_response` leaves the
waiting screen, and `/dialog clear` closes a dialog (servers used to see nothing: the game
dropped the packets).
- book_menu: an OpenBook packet over a retained container shows only the book; closing it restores
the menu with an available pointer.
- titles: a chest named with styles (`CustomName`, as a plugin's menu is): its screen's title keeps
the colours and bold (it used to be flattened to plain grey).
- dialog_close: a local frame proxy injects ContainerClose after a multi-action custom click,
reproducing Paper's closeInventory without a plugin. Covers the waiting screen,
parent screens, warning, lectern, replacement dialogs, key release and death;
clear_dialog and after_action close must retain their distinct parent semantics.
- dialog_text: custom click events inherited by message text and inline sprites send their id and
optional payload, with the dialog's none/close/wait_for_response screen behaviour.
The game runs offscreen with fresh HORIZON_HOME, launcher and XDG folders and without audio. Screenshots and both
logs go to --out.
"""
import argparse
import json
import os
import shutil
import socket
import threading
import subprocess
import sys
import tempfile
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import lab # noqa: E402
import test_rust_parity as base # noqa: E402
from agent_control_e2e import Failed # noqa: E402
from lan_e2e import Game # noqa: E402
from test_dedicated_server_parity import Server # noqa: E402
ROOT = Path(__file__).resolve().parents[1]
PLAYER = 'Steve'
PROPERTIES = {
'online-mode': 'false',
'white-list': 'false',
'level-type': 'minecraft\\:flat',
'generate-structures': 'false',
'spawn-protection': '0',
'view-distance': '3',
'simulation-distance': '3',
'pause-when-empty-seconds': '-1',
}
# MinecraftServer.handleCustomClickAction logs at debug level: the console shows that logger's debug lines.
LOG4J = """<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<Appenders><Console name="out" target="SYSTEM_OUT"><PatternLayout pattern="[%level] %msg%n"/></Console></Appenders>
<Loggers>
<Logger name="net.minecraft.server.MinecraftServer" level="debug"/>
<Root level="info"><AppenderRef ref="out"/></Root>
</Loggers>
</Configuration>
"""
def snbt(value):
"""A JSON value as SNBT (strings quoted, booleans as bytes)."""
if isinstance(value, bool):
return '1b' if value else '0b'
if isinstance(value, str):
return json.dumps(value)
if isinstance(value, list):
return '[' + ','.join(snbt(v) for v in value) + ']'
if isinstance(value, dict):
return '{' + ','.join(f'{k}:{snbt(v)}' for k, v in value.items()) + '}'
return str(value)
class PacketProxy:
"""Forward whole frames; inject the pinned protocol's OpenBook and ContainerClose packets.
Offline vanilla uses compression threshold 256. The injected packet is
below that threshold (uncompressed length 0). Forwarded frames stay opaque.
"""
def __init__(self, upstream):
self.upstream = upstream
self.listener = socket.socket()
self.listener.bind(('127.0.0.1', 0))
self.listener.listen(1)
self.port = self.listener.getsockname()[1]
self.client = None
self.server = None
self.lock = threading.Lock()
self.ready = threading.Event()
threading.Thread(target=self.run, daemon=True).start()
@staticmethod
def read(sock, count):
data = bytearray()
while len(data) < count:
chunk = sock.recv(count - len(data))
if not chunk:
raise EOFError()
data.extend(chunk)
return bytes(data)
def run(self):
try:
self.client, _ = self.listener.accept()
self.server = socket.create_connection(('127.0.0.1', self.upstream))
self.ready.set()
threading.Thread(target=self.upload, daemon=True).start()
while True:
prefix = bytearray()
length = 0
for shift in range(0, 35, 7):
byte = self.read(self.server, 1)[0]
prefix.append(byte)
length |= (byte & 127) << shift
if byte < 128:
break
else:
raise ValueError('invalid packet length')
frame = bytes(prefix) + self.read(self.server, length)
with self.lock:
self.client.sendall(frame)
except (EOFError, OSError):
pass
def upload(self):
try:
while data := self.client.recv(65536):
self.server.sendall(data)
except OSError:
pass
def open_book(self, hand=0):
# ClientboundOpenBookPacket: protocol 777, play packet 0x3b.
self.send_packet(0x3b, lab.varint(hand))
def close_container(self, container=0):
# ClientboundContainerClosePacket: protocol 777, play packet 0x11 (Paper's closeInventory).
self.send_packet(0x11, lab.varint(container))
def send_packet(self, packet, data):
assert self.ready.wait(10), 'client did not connect through the proxy'
body = b'\0' + lab.varint(packet) + data
with self.lock:
self.client.sendall(lab.varint(len(body)) + body)
def close(self):
for sock in (self.client, self.server, self.listener):
if sock is not None:
try:
sock.shutdown(socket.SHUT_RDWR)
except OSError:
pass
sock.close()
def check_dialog_close(server, game, out, proxy):
"""Paper closeInventory's packet must close a waiting dialog and its parent."""
dialog = {
'type': 'minecraft:multi_action', 'title': 'Container close regression',
'can_close_with_escape': False, 'pause': False, 'columns': 1,
'after_action': 'wait_for_response',
'actions': [{'label': 'Close', 'width': 20,
'action': {'type': 'minecraft:custom', 'id': 'horizon:close_dialog'}}],
}
def show(data=dialog):
server.console(f'dialog show {PLAYER} {snbt(data)}')
game.raw('wait-for', 'screen', 'dialog', '--timeout', 30)
game.until(lambda: game('state')['dialog'],
lambda state: state['screen'] is None and state['open'] is not None
and state['open']['title'] == data['title'])
def close():
proxy.close_container()
game.raw('wait-for', 'screen', 'playing', '--timeout', 10)
state = game('state')
assert state['dialog']['screen'] is None, state['dialog']
assert state['dialog']['open'] is None, state['dialog']
# WAIT_FOR_RESPONSE must survive clear_dialog, but not container_close.
show()
game.raw('click', 'close')
server.wait_for(r'Received custom click action horizon:close_dialog', timeout=30)
assert game('state')['dialog']['screen'] == 'waiting_for_response'
server.console(f'dialog clear {PLAYER}')
game.wait(10)
assert game('state')['dialog']['screen'] == 'waiting_for_response'
close()
# A normal server clear returns to the parent inventory. CLOSE does too.
game.raw('key', 'e', 'tap')
game.raw('wait-for', 'screen', 'inventory', '--timeout', 10)
show(dict(dialog, after_action='none'))
server.console(f'dialog clear {PLAYER}')
game.raw('wait-for', 'screen', 'inventory', '--timeout', 10)
show(dict(dialog, after_action='close'))
game.raw('click', 'close')
game.raw('wait-for', 'screen', 'inventory', '--timeout', 10)
# ContainerClose discards the waiting dialog AND that parent screen.
show()
game.raw('click', 'close')
assert game('state')['dialog']['screen'] == 'waiting_for_response'
close()
# An active dialog and its warning overlay must also close without Escape.
show(dict(dialog, after_action='none'))
close()
show()
game.raw('click', 'warning')
assert game('state')['dialog']['screen'] == 'warning'
close()
# A server response may replace the waiting screen with another dialog.
show()
game.raw('click', 'close')
show(dict(dialog, title='Replacement dialog', after_action='none'))
assert game('state')['dialog']['open']['title'] == 'Replacement dialog'
close()
# Closing a dialog over chat must not reveal the old chat input.
game.raw('key', 't', 'tap')
game.raw('wait-for', 'screen', 'chat', '--timeout', 10)
show(dict(dialog, after_action='none'))
close()
# Keep the existing lectern close path working, including a dialog over it.
x, y, z = (int(v // 1) for v in game('state')['player']['position'])
at = (x + 2, y, z)
book = ('{id:"minecraft:written_book",count:1,components:{"minecraft:written_book_content":'
'{title:"Dialog",author:"Test",pages:["one","two"]}}}')
server.console(f'setblock {at[0]} {at[1]} {at[2]} minecraft:lectern[has_book=true]{{Book:{book}}}')
game.wait(10)
for overlay in (False, True):
game.raw('interact', *at)
game.raw('wait-for', 'screen', 'book', '--timeout', 10)
if overlay:
show(dict(dialog, after_action='none'))
close()
server.console(f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
game.wait(10)
# Preserve releaseAll on dialog open: W must not remain held afterwards.
game.raw('key', 'w', 'down')
show()
game.raw('key', 'w', 'up')
close()
game.wait(10)
before = game('state')['player']['position']
game.wait(10)
after = game('state')['player']['position']
assert sum((after[i] - before[i]) ** 2 for i in (0, 2)) < .0001, (before, after)
# Preserve the prior fix closing server dialogs on player death.
show()
server.console(f'kill {PLAYER}')
game.raw('wait-for', 'screen', 'dead', '--timeout', 30)
assert game('state')['dialog'] == {'open': None, 'screen': None}
game.raw('respawn')
game.raw('wait-for', 'screen', 'playing', '--timeout', 30)
game.shot(out, 'dialog-closed')
return {'waiting_closed': True, 'parent_closed': True, 'warning_closed': True,
'clear_keeps_waiting': True, 'clear_and_close_restore_parent': True,
'replacement_dialog': True, 'chat_parent_closed': True,
'lectern_closes': True, 'held_keys_released': True, 'death_closes_dialog': True}
def check_dialog(server, game, out):
server.console('scoreboard objectives add answer trigger')
server.console(f'scoreboard players enable {PLAYER} answer')
dialog = {
'type': 'minecraft:multi_action',
'title': {'text': 'Sign in', 'color': 'gold'},
'body': {'type': 'minecraft:plain_message', 'contents': 'Pick a number.'},
'inputs': [
{'type': 'minecraft:text', 'key': 'number', 'label': 'Number'},
{'type': 'minecraft:boolean', 'key': 'agree', 'label': 'Agree', 'on_true': '1', 'on_false': '0'},
],
'actions': [
{'label': 'Send', 'action': {'type': 'minecraft:dynamic/run_command',
'template': 'trigger answer set $(number)$(agree)'}},
{'label': 'Submit', 'action': {'type': 'minecraft:dynamic/custom', 'id': 'horizon:e2e',
'additions': {'tag': 'x'}}},
{'label': 'Wait', 'action': {'type': 'minecraft:dynamic/custom', 'id': 'horizon:wait'}},
],
'after_action': 'none',
'pause': False, # a pausing dialog must close after an action
}
shown = {}
server.console(f'dialog show {PLAYER} {snbt(dialog)}')
game.raw('wait-for', 'screen', 'dialog', '--timeout', 30)
state = game('state')
shown['shown'] = state['dialog']['open']
assert shown['shown']['title'] == 'Sign in', shown
assert [i['key'] for i in shown['shown']['inputs']] == ['number', 'agree'], shown
assert shown['shown']['buttons'] == ['Send', 'Submit', 'Wait'], shown
game.shot(out, 'dialog')
# The text, the box, then the command the template makes of them.
game.raw('type', '4')
game.raw('click', 'agree')
game.raw('click', 'send')
time.sleep(1)
server.console(f'scoreboard players get {PLAYER} answer')
server.wait_for(rf'{PLAYER} has 41 \[answer\]', timeout=30)
shown['trigger'] = 41
# A custom action: its additions and every input's tag.
game.raw('click', 'submit')
server.wait_for(r'Received custom click action horizon:e2e with payload', timeout=30)
line = next(l for l in server.lines if 'horizon:e2e' in l)
shown['custom'] = line.split('payload ', 1)[1]
for part in ('agree:1b', 'number:"4"', 'tag:"x"'):
assert part in shown['custom'], shown
# after_action none: still open. Then clear it.
assert game('state')['dialog']['open'], 'the dialog closed after an action with after_action none'
server.console(f'dialog clear {PLAYER}')
game.raw('wait-for', 'screen', 'playing', '--timeout', 30)
# wait_for_response: the waiting screen until the server answers (here, never).
server.console(f'dialog show {PLAYER} {snbt(dict(dialog, after_action="wait_for_response"))}')
game.raw('wait-for', 'screen', 'dialog', '--timeout', 30)
game.raw('click', 'wait')
server.wait_for(r'Received custom click action horizon:wait', timeout=30)
shown['waiting'] = game('state')['dialog']['screen']
game.shot(out, 'waiting')
assert shown['waiting'] == 'waiting_for_response', shown
# Its Back works after 5 s, and so does Escape (clear_dialog leaves it open, as in vanilla).
time.sleep(5.5)
game.raw('key', 'esc', 'tap')
game.raw('wait-for', 'screen', 'playing', '--timeout', 10)
return shown
def check_book_menu(server, game, out, proxy):
"""An active book is the only rendered/interactive screen over a menu."""
server.console(f'item replace entity {PLAYER} weapon.mainhand with minecraft:written_book'
'[minecraft:written_book_content={title:"Overlay",author:"Test",pages:["First","Second"]}]')
game.until(lambda: game('state')['held'],
lambda held: held is not None and held['item'] == 'minecraft:written_book')
x, y, z = (int(v // 1) for v in game('state')['player']['position'])
at = (x + 2, y, z)
server.console(f'setblock {at[0]} {at[1]} {at[2]} minecraft:chest')
game.wait(10)
game.raw('interact', *at)
game.raw('wait-for', 'screen', 'container', '--timeout', 30)
assert any(w['kind'] == 'slot' for w in game.raw('ui')['widgets'])
proxy.open_book()
game.raw('wait-for', 'screen', 'book', '--timeout', 10)
game.wait(3)
game.shot(out, 'book-over-menu')
ui = game.raw('ui')
assert not any(w['kind'] == 'slot' for w in ui['widgets']), ui
game.raw('book', 'next')
assert game.raw('book')['page'] == 1
game.raw('book', 'done')
game.raw('wait-for', 'screen', 'container', '--timeout', 10)
state = game('state')
assert not state['pointer_grabbed'], 'book close grabbed the mouse behind the menu'
assert any(w['kind'] == 'slot' for w in game.raw('ui')['widgets'])
game.shot(out, 'menu-after-book')
game.raw('key', 'esc', 'tap')
game.raw('wait-for', 'screen', 'playing', '--timeout', 10)
server.console(f'setblock {at[0]} {at[1]} {at[2]} minecraft:air')
return {'book_excludes_menu': True, 'pages_work': True,
'menu_restored': True, 'menu_keeps_mouse': True}
def check_titles(server, game, out):
# AbstractContainerScreen draws the title the server sent, its styles included (#1645). A named
# chest is what a plugin's menu looks like: its CustomName is the open_screen title.
x, y, z = (int(v // 1) for v in game('state')['player']['position'])
chest = (x + 2, y, z)
name = [{'text': 'Server ', 'color': 'gold', 'bold': True}, {'text': 'Selector', 'color': 'aqua'}]
server.console(f'setblock {chest[0]} {chest[1]} {chest[2]} minecraft:chest{{CustomName:{snbt(name)}}}')
game.wait(10)
game.raw('interact', *chest)
game.raw('wait-for', 'screen', 'container', '--timeout', 30)
game.wait(5)
title = game('screen').get('title')
game.shot(out, 'titles')
game.raw('key', 'esc', 'tap')
# A list is its first component with the rest as its children: they inherit its bold.
want = [{'text': 'Server ', 'color': '#FFAA00', 'bold': True, 'italic': False},
{'text': 'Selector', 'color': '#55FFFF', 'bold': True, 'italic': False}]
assert title == want, title
return {'title': title}
def check_dialog_text(server, game, out):
"""Custom component clicks (including an inherited inline sprite) reach the real server."""
results = {}
for after in ('none', 'close', 'wait_for_response'):
event = {'action': 'custom', 'id': f'horizon:text_{after}'}
if after != 'none':
event['payload'] = {'shop': 'potions', 'amount': 3}
dialog = {
'type': 'minecraft:multi_action', 'title': 'Text actions', 'pause': False,
'after_action': after,
'body': {'type': 'minecraft:plain_message', 'width': 325, 'contents': {
'text': '', 'click_event': event, 'extra': [
{'object': 'atlas', 'atlas': 'minecraft:items', 'sprite': 'minecraft:item/potion'},
' Shop',
],
}},
'actions': [{'label': 'Unused'}],
}
server.console(f'dialog show {PLAYER} {snbt(dialog)}')
game.raw('wait-for', 'screen', 'dialog', '--timeout', 30)
# Read the rendered layout without depending on simulation ticks: dialogs are UI.
width = game.raw('ui')['gui_size'][0]
# The single message starts at y=63 with four pixels of padding. Blank space
# within its 325-wide widget must not execute the component's click event.
game.raw('click-at', width / 2 + 100, 70)
time.sleep(0.25)
assert not any(f'Received custom click action {event["id"]} ' in line for line in server.lines)
# The icon and the letters inherit the same event. Exercise both paths.
game.raw('click-at', width / 2 - 15 if after == 'none' else width / 2 + 3, 70)
server.wait_for(rf'Received custom click action {event["id"]} with payload', timeout=10)
line = next(line for line in server.lines if f'Received custom click action {event["id"]} ' in line)
if 'payload' in event:
assert 'shop:"potions"' in line and 'amount:3' in line, line
else:
assert 'with payload null' in line, line
state = game('state')
if after == 'none':
assert state['dialog']['open'], state['dialog']
server.console(f'dialog clear {PLAYER}')
elif after == 'wait_for_response':
assert state['dialog']['screen'] == 'waiting_for_response', state['dialog']
game.shot(out, 'text-waiting')
time.sleep(5.5)
game.raw('key', 'esc', 'tap')
game.raw('wait-for', 'screen', 'playing', '--timeout', 10)
results[after] = line
return results
CHECKS = {
'dialog': check_dialog,
'dialog_text': check_dialog_text,
'titles': check_titles,
'book_menu': check_book_menu,
'dialog_close': check_dialog_close,
}
def run(bins, out, checks):
lock, jars, libraries = base.verify_artifacts()
work = Path(tempfile.mkdtemp(prefix='hz-server-e2e-'))
port = lab.free_port()
directory = work / 'server'
directory.mkdir()
props = dict(PROPERTIES, **{'server-port': str(port)})
if 'book_menu' in checks or 'dialog_close' in checks:
props['network-compression-threshold'] = '256'
(directory / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items()))
(directory / 'eula.txt').write_text('eula=true\n')
(work / 'log4j2.xml').write_text(LOG4J)
classpath = os.pathsep.join(map(str, [jars['server'], *libraries]))
server = Server(['java', '-Xmx2g', f'-Dlog4j.configurationFile={work / "log4j2.xml"}', '-cp', classpath,
'net.minecraft.server.Main', 'nogui'], directory, 'vanilla')
proxy = PacketProxy(port) if 'book_menu' in checks or 'dialog_close' in checks else None
game = Game('client', bins / 'horizon-ctl', lab.RESOURCES.resolve())
for key in ('XDG_DATA_HOME', 'XDG_CONFIG_HOME', 'HORIZON_LAUNCHER_HOME'):
game.d.env[key] = str(game.work / key.lower())
game.d.env['HORIZON_LAUNCHER_NO_SELF_UPDATE'] = '1'
results = {}
try:
server.wait_for(r'Done \(')
server.console('gamerule spawn_mobs false')
game('launch', wait=False, offscreen=True)
game.raw('connect', f'127.0.0.1:{proxy.port if proxy else port}', PLAYER)
game.raw('wait-for', 'loaded', '--timeout', 300)
server.wait_for(rf'{PLAYER} joined the game')
server.console(f'op {PLAYER}')
game.wait(20)
for name in checks:
try:
detail = CHECKS[name](server, game, out, proxy) if name in ('book_menu', 'dialog_close') else CHECKS[name](server, game, out)
results[name] = {'pass': True, 'detail': detail}
except (Failed, AssertionError, StopIteration, KeyError) as e:
results[name] = {'pass': False, 'detail': repr(e)}
print(f'[server] {name}: {"PASS" if results[name]["pass"] else "FAIL"} {results[name]["detail"]}')
finally:
(out / 'client.log').write_text(game.close())
if proxy:
proxy.close()
server.stop()
shutil.copy(server.log_path, out / 'server.log')
shutil.rmtree(game.work, ignore_errors=True)
shutil.rmtree(work, ignore_errors=True)
return results
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--bin-dir', help='prebuilt horizon-client and horizon-ctl (default: build with the fast profile)')
parser.add_argument('--check', action='append', choices=list(CHECKS), help='run only these checks (repeatable)')
parser.add_argument('--out', default=str(ROOT / '.local/server-e2e'), help='screenshots and logs go here')
args = parser.parse_args()
if args.bin_dir:
bins = Path(args.bin_dir).resolve()
else:
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-client', '-p', 'horizon-client-lib', '--bins'],
cwd=ROOT, check=True)
bins = ROOT / 'target/fast'
out = Path(args.out)
out.mkdir(parents=True, exist_ok=True)
results = run(bins, out, args.check or list(CHECKS))
print(json.dumps(results, indent=1))
sys.exit(0 if results and all(r['pass'] for r in results.values()) else 1)
if __name__ == '__main__':
main()