Files
RustNotMC/rsx-protocol/src/test/java/net/rustnotmc/protocol/ProtocolTest.java
T
sasheg dev 46784aa88f Клиент говорит с сервером по нашему протоколу
Раньше клиент умел только показывать меню. Теперь он заходит на сервер,
получает наш мир и рисует его.

Как это устроено:

- Наш сетевой слой (net/rustnotmc/client/net) читает наш протокол и
  переводит его пакеты в пакеты игры. Игра остаётся игрой: у неё есть
  мир, игрок, сущности и рендер, и все они работают. Не переписывать их
  ради того, чтобы нарисовать тот же мир иначе, незачем.
- Пакеты игры, которые не имеют соответствия у нас, не отправляются, а
  наоборот: перемещение игрока и ломание блока переводятся в наши.
- Экран входа игры (GuiConnecting) заменён наш: он спрашивает у лаунчера,
  кем пришёл игрок, и подставляет это в рукопожатие.
- Соединение качается из тика игры, а не из экрана. Рукопожатие, которое
  зависит от того, что экран ещё на экране, ломается alt-tab'ом.

Вход теперь через лаунчер, а не внутри игры:

- Лаунчер уже умеет логинить игрока и отдаёт игре имя, uuid и токен
  (--username, --uuid, --accessToken). Это та же роль, что у Yggdrasil в
  ванилле, только лаунчер наш.
- Сервер спрашивает лаунчер, один из ли наш этот игрок, и заодно получает
  бан-лист. Токен используется один раз и не хранится.
- Свои аккаунты сервера, когда настроен лаунчер, закрыты: сервер с двумя
  дверями не имеет дверей. Открыть их можно флагом, но намеренно.
- Если игру запустили без лаунчера, экран всё равно спрашивает имя и
  пароль: это путь для своего сервера и для разработки.

Проверено живьём: клиент вошёл по токену лаунчера, сервер спросил
лаунчер, лаунчер сказал "свой", игрок получил мир, 210 чанков пришли и
лежат на диске, рельеф нарисован.

Что чинилось по дороге:

- Minecraft.setIngameFocus() возвращал на главное меню, когда окно
  получало фокус. Игрок как раз кликает в окно, чтобы напечатать пароль, и
  экран входа исчезал в этот самый момент.
- Ответ лаунчера разбирается, а не ищется подстрокой: {"valid": true} и
  {"valid":true} это один и тот же ответ, и сервер, который понимает
  только второй, ломает работающий лаунчер.
2026-10-19 20:10:19 +03:00

561 lines
24 KiB
Java

package net.rustnotmc.protocol;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
/**
* Tests for the wire format.
*
* These are not example code. Every case here is something a hostile or simply
* broken peer can actually do, and the point of the test is that the server
* survives it without an OutOfMemoryError, an endless loop or a silent
* misinterpretation.
*/
public final class ProtocolTest {
private static int checks;
private static int failed;
private static final List<String> failures = new ArrayList<String>();
public static void main(String[] args) {
run("varint round trip", new Runnable() {
public void run() { testVarInt(); }
});
run("varint rejects overlong input", new Runnable() {
public void run() { testVarIntOverlong(); }
});
run("negative numbers stay small", new Runnable() {
public void run() { testVarIntZigZag(); }
});
run("varlong round trip", new Runnable() {
public void run() { testVarLong(); }
});
run("fixed width round trip", new Runnable() {
public void run() { testFixedWidth(); }
});
run("string round trip incl. non ascii", new Runnable() {
public void run() { testString(); }
});
run("string length is bounded", new Runnable() {
public void run() { testStringBound(); }
});
run("reading past the end fails", new Runnable() {
public void run() { testUnderflow(); }
});
run("position survives the round trip", new Runnable() {
public void run() { testPosition(); }
});
run("frame round trip", new Runnable() {
public void run() { testFrame(); }
});
run("partial frame decodes to nothing", new Runnable() {
public void run() { testPartialFrame(); }
});
run("foreign magic is refused", new Runnable() {
public void run() { testWrongMagic(); }
});
run("oversized declared body is refused", new Runnable() {
public void run() { testOversizedClaim(); }
});
run("byte at a time still works", new Runnable() {
public void run() { testSplitEverywhere(); }
});
run("many frames in one read", new Runnable() {
public void run() { testBatch(); }
});
run("handshake packets round trip", new Runnable() {
public void run() { testHandshake(); }
});
run("play packets round trip", new Runnable() {
public void run() { testPlayPackets(); }
});
run("a chunk survives compression", new Runnable() {
public void run() { testChunkCompression(); }
});
run("a chunk that is not compressed data is refused", new Runnable() {
public void run() { testBadChunkData(); }
});
run("a truncated chunk is refused", new Runnable() {
public void run() { testShortChunk(); }
});
run("every packet has a name", new Runnable() {
public void run() { testPacketNames(); }
});
run("a lying length cannot stall the heap", new Runnable() {
public void run() { testLyingLength(); }
});
System.out.println();
for (int i = 0; i < failures.size(); i++) {
System.out.println(" FAIL " + failures.get(i));
}
System.out.println();
System.out.println("checks: " + checks + ", failed: " + failed);
if (failed != 0) {
System.out.println("FAILED");
System.exit(1);
}
System.out.println("OK");
}
private static void run(String name, Runnable body) {
checks++;
int before = failed;
try {
body.run();
} catch (Throwable t) {
failed++;
failures.add(name + ": threw " + t);
t.printStackTrace();
return;
}
if (failed == before) {
System.out.println(" ok " + name);
} else {
failures.add(name + ": assertions failed");
}
}
private static void eq(Object what, Object expected, Object actual) {
// arrays compare by reference under equals(), which is never what a
// caller means when it hands over a body
if (expected instanceof byte[] && actual instanceof byte[]) {
if (!java.util.Arrays.equals((byte[]) expected, (byte[]) actual)) {
failed++;
failures.add(what + ": byte arrays differ");
}
return;
}
if (expected == null ? actual != null : !expected.equals(actual)) {
failed++;
failures.add(what + ": expected " + expected + " but got " + actual);
}
}
private static void yes(String what, boolean condition) {
if (!condition) {
failed++;
failures.add(what);
}
}
private static void refuses(String what, Runnable body) {
try {
body.run();
failed++;
failures.add(what + ": accepted something it should have refused");
} catch (ProtocolException expected) {
// exactly what we want
} catch (Throwable other) {
failed++;
failures.add(what + ": threw " + other + " instead of ProtocolException");
}
}
// ------------------------------------------------------------- the tests
private static void testVarInt() {
int[] values = {0, 1, -1, 127, 128, -128, 12345, -12345,
Integer.MAX_VALUE, Integer.MIN_VALUE};
for (int i = 0; i < values.length; i++) {
Buf buf = new Buf();
buf.writeVarInt(values[i]);
eq("varint " + values[i], Integer.valueOf(values[i]),
Integer.valueOf(new Buf(buf.toByteArray()).readVarInt()));
}
}
private static void testVarIntOverlong() {
// ten bytes of 0xFF: a five byte value cannot be this long
byte[] evil = new byte[10];
java.util.Arrays.fill(evil, (byte) 0xFF);
refuses("overlong varint", new Runnable() {
public void run() { new Buf(evil).readVarInt(); }
});
}
private static void testVarIntZigZag() {
Buf small = new Buf();
small.writeVarInt(-1);
yes("minus one is one byte, not five", small.writerIndex() == 1);
Buf zero = new Buf();
zero.writeVarInt(0);
yes("zero is one byte", zero.writerIndex() == 1);
Buf positive = new Buf();
positive.writeVarInt(63);
Buf negative = new Buf();
negative.writeVarInt(-63);
eq("63 and -63 cost the same", Integer.valueOf(positive.writerIndex()),
Integer.valueOf(negative.writerIndex()));
}
private static void testVarLong() {
long[] values = {0L, 1L, -1L, Long.MAX_VALUE, Long.MIN_VALUE, -1234567890123L};
for (int i = 0; i < values.length; i++) {
Buf buf = new Buf();
buf.writeVarLong(values[i]);
eq("varlong " + values[i], Long.valueOf(values[i]),
Long.valueOf(new Buf(buf.toByteArray()).readVarLong()));
}
}
private static void testFixedWidth() {
Buf buf = new Buf();
buf.writeBoolean(true);
buf.writeByte(0x7F);
buf.writeShort(0xBEEF);
buf.writeInt(-559038737);
buf.writeLong(-1234567890123L);
buf.writeFloat(0.5F);
buf.writeDouble(-2.25D);
Buf read = new Buf(buf.toByteArray());
yes("boolean", read.readBoolean());
eq("byte", Integer.valueOf(0x7F), Integer.valueOf(read.readUnsignedByte()));
eq("short", Integer.valueOf(0xBEEF), Integer.valueOf(read.readShort()));
eq("int", Integer.valueOf(-559038737), Integer.valueOf(read.readInt()));
eq("long", Long.valueOf(-1234567890123L), Long.valueOf(read.readLong()));
eq("float", Float.valueOf(0.5F), Float.valueOf(read.readFloat()));
eq("double", Double.valueOf(-2.25D), Double.valueOf(read.readDouble()));
eq("nothing left", Integer.valueOf(0), Integer.valueOf(read.readableBytes()));
}
private static void testString() {
String[] values = {"", "player", "Игрок", "a name with spaces", "~!@#$%^&*()"};
for (int i = 0; i < values.length; i++) {
Buf buf = new Buf();
buf.writeString(values[i]);
eq("string " + values[i], values[i],
new Buf(buf.toByteArray()).readString(Packets.MAX_TEXT));
}
}
private static void testStringBound() {
// announces a huge string and then does not send it
Buf buf = new Buf();
buf.writeVarInt(0x7FFFFFF0);
refuses("string longer than the limit", new Runnable() {
public void run() { buf.readString(Packets.MAX_TEXT); }
});
}
private static void testUnderflow() {
refuses("reading past the end", new Runnable() {
public void run() { new Buf(new byte[2]).readInt(); }
});
}
private static void testPosition() {
// Y is deliberately whole blocks: it only has 12 bits, so it is stored
// as a signed block number and a fractional Y is quantised away
double[][] positions = {
{0.0D, 0.0D, 0.0D},
{1.5D, -64.0D, 300.25D},
{-1234.5D, 319.0D, -4096.75D},
{0.03125D, -2048.0D, 0.03125D},
{0.03125D, 2047.0D, 0.03125D},
};
for (int i = 0; i < positions.length; i++) {
double[] p = positions[i];
Buf buf = new Buf();
buf.writePosition(p[0], p[1], p[2]);
double[] back = new Buf(buf.toByteArray()).readPosition();
eq("position x " + p[0], Double.valueOf(p[0]), Double.valueOf(back[0]));
eq("position y " + p[1], Double.valueOf(p[1]), Double.valueOf(back[1]));
eq("position z " + p[2], Double.valueOf(p[2]), Double.valueOf(back[2]));
}
// and the quantisation is a documented property, not an accident
Buf fractional = new Buf();
fractional.writePosition(1.0D, 64.5D, 2.0D);
double[] cut = new Buf(fractional.toByteArray()).readPosition();
eq("fractional Y rounds to a whole block", Double.valueOf(64.0), Double.valueOf(cut[1]));
}
private static void testFrame() {
byte[] body = new byte[300];
new Random(7).nextBytes(body);
Frame frame = new Frame(Packets.HELLO, body);
Buf encoded = frame.encode();
Frame back = Frame.decode(encoded, 1024 * 1024);
yes("frame came back", back != null);
eq("frame id", Integer.valueOf(Packets.HELLO), Integer.valueOf(back.packetId()));
eq("frame version", Integer.valueOf(Frame.VERSION), Integer.valueOf(back.version()));
yes("frame body", java.util.Arrays.equals(body, back.body()));
}
private static void testPartialFrame() {
Buf encoded = new Frame(Packets.LOGIN, new byte[64]).encode();
byte[] all = encoded.toByteArray();
// a fresh decoder per prefix: the question is whether any prefix short of
// the whole frame decodes to nothing, not what happens when prefixes are
// concatenated into one stream
for (int cut = 0; cut < all.length - 1; cut++) {
FrameDecoder decoder = new FrameDecoder();
List<Frame> got = decoder.offer(all, 0, cut);
yes("nothing complete after " + cut + " bytes", got.isEmpty());
}
FrameDecoder whole = new FrameDecoder();
for (int cut = 0; cut < all.length; cut++) {
whole.offer(all, cut, 1);
}
eq("exactly one frame once it all arrived", Integer.valueOf(1),
Integer.valueOf(whole.framesRead()));
}
private static void testWrongMagic() {
byte[] notOurs = new byte[] {0x7F, 'E', 'L', 'F', 0x01, 0x00, 0x00, 0x00};
FrameDecoder decoder = new FrameDecoder();
refuses("foreign magic", new Runnable() {
public void run() { decoder.offer(notOurs, 0, notOurs.length); }
});
}
private static void testOversizedClaim() {
// valid magic and version, but a body of two gigabytes
Buf buf = new Buf();
buf.writeInt(Frame.MAGIC);
buf.writeVarInt(Frame.VERSION);
buf.writeVarInt(Packets.HELLO);
buf.writeVarInt(Integer.MAX_VALUE);
byte[] bytes = buf.toByteArray();
FrameDecoder decoder = new FrameDecoder(4096);
refuses("oversized claim", new Runnable() {
public void run() { decoder.offer(bytes, 0, bytes.length); }
});
}
private static void testSplitEverywhere() {
List<Frame> expected = new ArrayList<Frame>();
expected.add(new Frame(Packets.HELLO, body("hello")));
expected.add(new Frame(Packets.HELLO_ACK, body("hello-ack")));
expected.add(new Frame(Packets.AUTH, body("auth")));
expected.add(new Frame(Packets.AUTH_ACK, body("auth-ack")));
expected.add(new Frame(Packets.LOGIN, body("login")));
expected.add(new Frame(Packets.LOGIN_ACK, body("login-ack")));
expected.add(new Frame(Packets.DISCONNECT, body("disconnect")));
Buf all = new Buf();
for (int i = 0; i < expected.size(); i++) {
all.writeBytes(expected.get(i).encode().toByteArray());
}
byte[] wire = all.toByteArray();
FrameDecoder decoder = new FrameDecoder();
List<Frame> got = new ArrayList<Frame>();
for (int i = 0; i < wire.length; i++) {
got.addAll(decoder.offer(wire, i, 1));
}
eq("every frame arrived", Integer.valueOf(expected.size()), Integer.valueOf(got.size()));
for (int i = 0; i < expected.size() && i < got.size(); i++) {
eq("frame " + i + " id", Integer.valueOf(expected.get(i).packetId()),
Integer.valueOf(got.get(i).packetId()));
yes("frame " + i + " body",
java.util.Arrays.equals(expected.get(i).body(), got.get(i).body()));
}
eq("nothing left buffered", Integer.valueOf(0), Integer.valueOf(decoder.bufferedBytes()));
}
private static void testBatch() {
FrameDecoder decoder = new FrameDecoder();
List<Frame> all = new ArrayList<Frame>();
Buf wire = new Buf();
for (int i = 0; i < 50; i++) {
Frame frame = new Frame(Packets.LOGIN, ("frame-" + i).getBytes());
all.add(frame);
wire.writeBytes(frame.encode().toByteArray());
}
byte[] bytes = wire.toByteArray();
List<Frame> got = decoder.offer(bytes, 0, bytes.length);
eq("all fifty in one read", Integer.valueOf(50), Integer.valueOf(got.size()));
eq("counter agrees", Integer.valueOf(50), Integer.valueOf(decoder.framesRead()));
for (int i = 0; i < got.size(); i++) {
eq("body " + i, all.get(i).body(), got.get(i).body());
}
}
private static byte[] body(String text) {
return new Buf().writeString(text).toByteArray();
}
private static void testHandshake() {
// the shapes the handshake relies on, exercised through the buffer
Buf b = new Buf();
b.writeString("Player");
eq("account name", "Player", new Buf(b.toByteArray()).readString(Packets.MAX_TEXT));
Buf t = new Buf();
t.writeString("proof-token-value");
eq("proof", "proof-token-value", new Buf(t.toByteArray()).readString(Packets.MAX_PROOF));
Buf n = new Buf();
n.writeVarInt(200);
n.writeVarInt(500);
Buf nr = new Buf(n.toByteArray());
eq("online", Integer.valueOf(200), Integer.valueOf(nr.readVarInt()));
eq("max", Integer.valueOf(500), Integer.valueOf(nr.readVarInt()));
}
/** The play phase packets, over a buffer. */
private static void testPlayPackets() {
Packets.JoinWorld join = Packets.JoinWorld.at(120.5D, 64.0D, -300.25D, 90.0F, -10.0F);
Packets.JoinWorld joinBack =
Packets.JoinWorld.read(new Buf(join.write().toByteArray()));
yes("the join carried a position", joinBack.hasPosition);
eq("join x", Double.valueOf(120.5D), Double.valueOf(joinBack.x));
eq("join y", Double.valueOf(64.0D), Double.valueOf(joinBack.y));
eq("join z", Double.valueOf(-300.25D), Double.valueOf(joinBack.z));
eq("join yaw", Float.valueOf(90.0F), Float.valueOf(joinBack.yaw));
eq("join pitch", Float.valueOf(-10.0F), Float.valueOf(joinBack.pitch));
Packets.JoinWorld fresh = Packets.JoinWorld.fresh();
Packets.JoinWorld freshBack =
Packets.JoinWorld.read(new Buf(fresh.write().toByteArray()));
yes("a fresh join carries no position", !freshBack.hasPosition);
Packets.WorldJoin world =
new Packets.WorldJoin(7, 1234567890123L, 0.5D, 64.0D, 0.5D, 8, 256);
Packets.WorldJoin worldBack =
Packets.WorldJoin.read(new Buf(world.write().toByteArray()));
eq("entity id", Integer.valueOf(7), Integer.valueOf(worldBack.entityId));
eq("seed", Long.valueOf(1234567890123L), Long.valueOf(worldBack.seed));
eq("view distance", Integer.valueOf(8), Integer.valueOf(worldBack.viewDistance));
eq("world height", Integer.valueOf(256), Integer.valueOf(worldBack.worldHeight));
eq("spawn x", Double.valueOf(0.5D), Double.valueOf(worldBack.spawnX));
Packets.BlockChange change = new Packets.BlockChange(-100, 70, 250, Blocks.STONE);
Packets.BlockChange changeBack =
Packets.BlockChange.read(new Buf(change.write().toByteArray()));
eq("block x", Integer.valueOf(-100), Integer.valueOf(changeBack.x));
eq("block y", Integer.valueOf(70), Integer.valueOf(changeBack.y));
eq("block z", Integer.valueOf(250), Integer.valueOf(changeBack.z));
eq("block id", Byte.valueOf(Blocks.STONE), Byte.valueOf(changeBack.blockId));
Packets.PlayerMove move =
new Packets.PlayerMove(1.5D, 64.0D, -2.25D, 45.0F, -45.0F, true);
Packets.PlayerMove moveBack =
Packets.PlayerMove.read(new Buf(move.write().toByteArray()));
eq("move x", Double.valueOf(1.5D), Double.valueOf(moveBack.x));
eq("move z", Double.valueOf(-2.25D), Double.valueOf(moveBack.z));
eq("move yaw", Float.valueOf(45.0F), Float.valueOf(moveBack.yaw));
yes("the player is on the ground", moveBack.onGround);
Packets.EntityMove entity =
new Packets.EntityMove(9, 8.0D, 9.0D, 10.0D, 1.0F, 2.0F);
Packets.EntityMove entityBack =
Packets.EntityMove.read(new Buf(entity.write().toByteArray()));
eq("entity id", Integer.valueOf(9), Integer.valueOf(entityBack.entityId));
eq("entity x", Double.valueOf(8.0D), Double.valueOf(entityBack.x));
eq("entity z", Double.valueOf(10.0D), Double.valueOf(entityBack.z));
Packets.Ping ping = new Packets.Ping(1234567890123L);
eq("ping", Long.valueOf(1234567890123L),
Long.valueOf(Packets.Ping.read(new Buf(ping.write().toByteArray())).id));
Packets.Pong pong = new Packets.Pong(99L);
eq("pong", Long.valueOf(99L),
Long.valueOf(Packets.Pong.read(new Buf(pong.write().toByteArray())).id));
Packets.EntitySpawn spawn = new Packets.EntitySpawn(4, 4, "Player",
1.0D, 65.0D, 2.0D, 30.0F, -60.0F);
Packets.EntitySpawn spawnBack =
Packets.EntitySpawn.read(new Buf(spawn.write().toByteArray()));
eq("spawn entity id", Integer.valueOf(4), Integer.valueOf(spawnBack.entityId));
eq("spawn player id", Integer.valueOf(4), Integer.valueOf(spawnBack.playerId));
eq("spawn name", "Player", spawnBack.name);
eq("spawn y", Double.valueOf(65.0D), Double.valueOf(spawnBack.y));
Packets.EntityRemove remove = new Packets.EntityRemove(11);
eq("remove", Integer.valueOf(11), Integer.valueOf(
Packets.EntityRemove.read(new Buf(remove.write().toByteArray())).entityId));
Packets.LauncherAuth launcher = new Packets.LauncherAuth("Player",
"00000000-0000-0000-0000-000000000000", "a.b.c");
Packets.LauncherAuth launcherBack =
Packets.LauncherAuth.read(new Buf(launcher.write().toByteArray()));
eq("launcher account", "Player", launcherBack.account);
eq("launcher uuid", "00000000-0000-0000-0000-000000000000", launcherBack.uuid);
eq("launcher token", "a.b.c", launcherBack.token);
}
private static void testChunkCompression() {
byte[] raw = new byte[ChunkFormat.VOLUME];
for (int z = 0; z < ChunkFormat.WIDTH; z++) {
for (int x = 0; x < ChunkFormat.WIDTH; x++) {
for (int y = 0; y < 64; y++) {
raw[ChunkFormat.index(x, y, z)] = y == 63 ? Blocks.GRASS : Blocks.STONE;
}
}
}
byte[] packed = ChunkFormat.compress(raw);
yes("a chunk of mostly air is small (" + packed.length + " bytes)",
packed.length < 4096);
eq("the chunk came back", raw, ChunkFormat.decompress(packed, ChunkFormat.VOLUME));
Packets.ChunkData data = new Packets.ChunkData(-3, 9, raw);
Packets.ChunkData back = Packets.ChunkData.read(new Buf(data.write().toByteArray()));
eq("chunk x", Integer.valueOf(-3), Integer.valueOf(back.chunkX));
eq("chunk z", Integer.valueOf(9), Integer.valueOf(back.chunkZ));
eq("the blocks came back", raw, back.blocks);
}
private static void testBadChunkData() {
// 0xFF is not a valid deflate block header, so this cannot decode
final byte[] rubbish = new byte[64];
java.util.Arrays.fill(rubbish, (byte) 0xFF);
refuses("a chunk that is not compressed data", new Runnable() {
public void run() { ChunkFormat.decompress(rubbish, ChunkFormat.VOLUME); }
});
}
private static void testShortChunk() {
byte[] raw = new byte[ChunkFormat.VOLUME];
byte[] packed = ChunkFormat.compress(raw);
final byte[] cut = java.util.Arrays.copyOf(packed, packed.length / 2);
refuses("a truncated chunk", new Runnable() {
public void run() { ChunkFormat.decompress(cut, ChunkFormat.VOLUME); }
});
}
private static void testPacketNames() {
java.util.Map<Integer, String> names = Packets.names();
int[] ids = {
Packets.HELLO, Packets.HELLO_ACK, Packets.AUTH, Packets.AUTH_ACK,
Packets.LOGIN, Packets.LOGIN_ACK, Packets.JOIN_WORLD,
Packets.WORLD_JOIN, Packets.CHUNK_DATA, Packets.BLOCK_CHANGE,
Packets.PLAYER_MOVE, Packets.ENTITY_MOVE, Packets.PING, Packets.PONG,
Packets.ENTITY_SPAWN, Packets.ENTITY_REMOVE, Packets.LAUNCHER_AUTH,
Packets.DISCONNECT,
};
for (int i = 0; i < ids.length; i++) {
yes("packet " + ids[i] + " has a name",
names.containsKey(Integer.valueOf(ids[i])));
}
eq("no id is used twice", Integer.valueOf(ids.length),
Integer.valueOf(names.size()));
}
private static void testLyingLength() {
// announces a large but legal body, sends nothing, and keeps quiet.
// The decoder must hold it without growing and without pretending the
// frame arrived.
FrameDecoder decoder = new FrameDecoder(64 * 1024);
Buf header = new Buf();
header.writeInt(Frame.MAGIC);
header.writeVarInt(Frame.VERSION);
header.writeVarInt(Packets.HELLO);
header.writeVarInt(60 * 1024);
byte[] bytes = header.toByteArray();
List<Frame> got = decoder.offer(bytes, 0, bytes.length);
yes("no frame yet", got.isEmpty());
yes("held the header", decoder.bufferedBytes() == bytes.length);
// and the rest arrives, so it completes
byte[] body = new byte[60 * 1024];
got = decoder.offer(body, 0, body.length);
eq("completes once the body arrives", Integer.valueOf(1), Integer.valueOf(got.size()));
}
}