diff --git a/tox.ini b/tox.ini
index c7a5b7e..cb7abad 100644
--- a/tox.ini
+++ b/tox.ini
@@ -3,7 +3,7 @@ envlist = py27, py35, py36, lint, docs
 
 [testenv]
 deps = -r{toxinidir}/test_requirements.txt
-commands = pytest --cov {envsitepackagesdir}/wsproto --cov-config {toxinidir}/.coveragerc {toxinidir}/test/
+commands = pytest --cov {envsitepackagesdir}/wsproto --cov-report term-missing --cov-config {toxinidir}/.coveragerc {toxinidir}/test/
 
 [testenv:lint]
 basepython = python3.6
diff --git a/wsproto/compat.py b/wsproto/compat.py
index 9e8b7b0..267fa78 100644
--- a/wsproto/compat.py
+++ b/wsproto/compat.py
@@ -1,4 +1,17 @@
+# flake8: noqa
+
 import sys
 
+
 PY2 = sys.version_info.major == 2
 PY3 = sys.version_info.major == 3
+
+
+if PY3:
+    unicode = str
+
+    def Utf8Validator():
+        return None
+else:
+    unicode = unicode
+    from .utf8validator import Utf8Validator
diff --git a/wsproto/frame_protocol.py b/wsproto/frame_protocol.py
index 8c5e23f..9426826 100644
--- a/wsproto/frame_protocol.py
+++ b/wsproto/frame_protocol.py
@@ -14,8 +14,7 @@ from collections import namedtuple
 
 from enum import Enum, IntEnum
 
-from .compat import PY2, PY3
-from .utf8validator import Utf8Validator
+from .compat import unicode, Utf8Validator
 
 try:
     from wsaccel.xormask import XorMaskerSimple
@@ -34,10 +33,6 @@ class XorMaskerNull:
         return data
 
 
-if PY3:
-    unicode = str
-
-
 # RFC6455, Section 5.2 - Base Framing Protocol
 
 # Payload length constants
@@ -202,8 +197,7 @@ class MessageDecoder(object):
             raise ParseFailed("expected CONTINUATION, got %r" % frame.opcode)
 
         if frame.opcode is Opcode.TEXT:
-            if PY2:
-                self.validator = Utf8Validator()
+            self.validator = Utf8Validator()
             self.decoder = getincrementaldecoder("utf-8")()
 
         finished = frame.frame_finished and frame.message_finished
@@ -223,7 +217,7 @@ class MessageDecoder(object):
 
     def decode_payload(self, data, finished):
         if self.validator is not None:
-            results = self.validator.validate(str(data))
+            results = self.validator.validate(bytes(data))
             if not results[0] or (finished and not results[1]):
                 raise ParseFailed(u'encountered invalid UTF-8 while processing'
                                   ' text message at payload octet index %d' %
@@ -433,8 +427,9 @@ class FrameProtocol(object):
                code <= MAX_PROTOCOL_CLOSE_REASON:
                 raise ParseFailed(
                     "CLOSE with unknown reserved code")
-            if PY2:
-                results = Utf8Validator().validate(str(data[2:]))
+            validator = Utf8Validator()
+            if validator is not None:
+                results = validator.validate(bytes(data[2:]))
                 if not (results[0] and results[1]):
                     raise ParseFailed(u'encountered invalid UTF-8 while'
                                       ' processing close message at payload'
@@ -497,7 +492,7 @@ class FrameProtocol(object):
 
         return self._serialize_frame(Opcode.CLOSE, payload)
 
-    def pong(self, payload=None):
+    def pong(self, payload=b''):
         return self._serialize_frame(Opcode.PONG, payload)
 
     def send_data(self, payload=b'', fin=True):
@@ -506,6 +501,8 @@ class FrameProtocol(object):
         elif isinstance(payload, unicode):
             opcode = Opcode.TEXT
             payload = payload.encode('utf-8')
+        else:
+            raise ValueError('Must provide bytes or text')
 
         if self._outbound_opcode is None:
             self._outbound_opcode = opcode
@@ -522,9 +519,6 @@ class FrameProtocol(object):
     def _serialize_frame(self, opcode, payload=b'', fin=True):
         rsv = RsvBits(False, False, False)
         for extension in reversed(self.extensions):
-            if not extension.enabled():
-                continue
-
             rsv, payload = extension.frame_outbound(self, opcode, rsv, payload,
                                                     fin)
 

We need more send-side tests.
With the recent rewrite we now have fairly good coverage on the receive side but we need a lot more coverage on the send side.
diff --git a/test/test_frame_protocol.py b/test/test_frame_protocol.py
index d4555af..0c25c07 100644
--- a/test/test_frame_protocol.py
+++ b/test/test_frame_protocol.py
@@ -1,14 +1,31 @@
 # -*- coding: utf-8 -*-
 
-import pytest
+import itertools
 from binascii import unhexlify
 from codecs import getincrementaldecoder
 import struct
 
+import pytest
+
 import wsproto.frame_protocol as fp
 import wsproto.extensions as wpext
 
 
+class FakeValidator(object):
+    def __init__(self):
+        self.validated = b''
+
+        self.valid = True
+        self.ends_on_complete = True
+        self.octet = 0
+        self.code_point = 0
+
+    def validate(self, data):
+        self.validated += data
+        return (self.valid, self.ends_on_complete, self.octet,
+                self.code_point)
+
+
 class TestBuffer(object):
     def test_consume_at_most_zero_bytes(self):
         buf = fp.Buffer(b'xxyyy')
@@ -320,6 +337,65 @@ class TestMessageDecoder(object):
         assert frame.message_finished is True
         assert frame.payload == text_payload[(split // 3):]
 
+    def send_frame_to_validator(self, payload, finished):
+        decoder = fp.MessageDecoder()
+        frame = fp.Frame(
+            opcode=fp.Opcode.TEXT,
+            payload=payload,
+            frame_finished=finished,
+            message_finished=True,
+        )
+        frame = decoder.process_frame(frame)
+
+    def test_text_message_hits_validator(self, monkeypatch):
+        validator = FakeValidator()
+        monkeypatch.setattr(fp, 'Utf8Validator', lambda: validator)
+
+        text_payload = u'fñör∂'
+        binary_payload = text_payload.encode('utf8')
+        self.send_frame_to_validator(binary_payload, True)
+
+        assert validator.validated == binary_payload
+
+    def test_message_validation_failure_fails_properly(self, monkeypatch):
+        validator = FakeValidator()
+        validator.valid = False
+
+        monkeypatch.setattr(fp, 'Utf8Validator', lambda: validator)
+
+        with pytest.raises(fp.ParseFailed):
+            self.send_frame_to_validator(b'', True)
+
+    def test_message_validation_finish_on_incomplete(self, monkeypatch):
+        validator = FakeValidator()
+        validator.valid = True
+        validator.ends_on_complete = False
+
+        monkeypatch.setattr(fp, 'Utf8Validator', lambda: validator)
+
+        with pytest.raises(fp.ParseFailed):
+            self.send_frame_to_validator(b'', True)
+
+    def test_message_validation_unfinished_on_incomplete(self, monkeypatch):
+        validator = FakeValidator()
+        validator.valid = True
+        validator.ends_on_complete = False
+
+        monkeypatch.setattr(fp, 'Utf8Validator', lambda: validator)
+
+        self.send_frame_to_validator(b'', False)
+
+    def test_message_no_validation_can_still_fail(self, monkeypatch):
+        validator = FakeValidator()
+        monkeypatch.setattr(fp, 'Utf8Validator', lambda: validator)
+
+        payload = u'fñörd'
+        payload = payload.encode('iso-8859-1')
+
+        with pytest.raises(fp.ParseFailed) as exc:
+            self.send_frame_to_validator(payload, True)
+        assert exc.value.code == fp.CloseReason.INVALID_FRAME_PAYLOAD_DATA
+
 
 class TestFrameDecoder(object):
     def _single_frame_test(self, client, frame_bytes, opcode, payload,
@@ -552,6 +628,15 @@ class TestFrameDecoder(object):
             split=7,
         )
 
+    def test_eight_byte_length_with_msb_set(self):
+        frame_bytes = b'\x81\x7f\x80\x80\x80\x80\x80\x80\x80\x80'
+
+        self._parse_failure_test(
+            client=True,
+            frame_bytes=frame_bytes,
+            close_reason=fp.CloseReason.PROTOCOL_ERROR,
+        )
+
     def test_not_enough_for_mask(self):
         payload = bytearray(b'xy')
         mask = bytearray(b'abcd')
@@ -631,6 +716,17 @@ class TestFrameDecoder(object):
             split=65535,
         )
 
+    def test_overly_long_control_frame(self):
+        payload = b'x' * 128
+        payload_len = struct.pack('!H', len(payload))
+        frame_bytes = b'\x89\x7e' + payload_len + payload
+
+        self._parse_failure_test(
+            client=True,
+            frame_bytes=frame_bytes,
+            close_reason=fp.CloseReason.PROTOCOL_ERROR,
+        )
+
 
 class TestFrameDecoderExtensions(object):
     class FakeExtension(wpext.Extension):
@@ -638,10 +734,11 @@ class TestFrameDecoderExtensions(object):
 
         def __init__(self):
             self._inbound_header_called = False
-            self._rsv_bit_set = False
+            self._inbound_rsv_bit_set = False
             self._inbound_payload_data_called = False
             self._inbound_complete_called = False
             self._fail_inbound_complete = False
+            self._outbound_rsv_bit_set = False
 
         def enabled(self):
             return True
@@ -650,7 +747,7 @@ class TestFrameDecoderExtensions(object):
             self._inbound_header_called = True
             if opcode is fp.Opcode.PONG:
                 return fp.CloseReason.MANDATORY_EXT
-            self._rsv_bit_set = rsv[2]
+            self._inbound_rsv_bit_set = rsv.rsv3
             return fp.RsvBits(False, False, True)
 
         def frame_inbound_payload_data(self, proto, data):
@@ -659,7 +756,7 @@ class TestFrameDecoderExtensions(object):
                 return fp.CloseReason.POLICY_VIOLATION
             elif data == b'ragequit':
                 self._fail_inbound_complete = True
-            if self._rsv_bit_set:
+            if self._inbound_rsv_bit_set:
                 data = data.decode('utf-8').upper().encode('utf-8')
             return data
 
@@ -667,9 +764,18 @@ class TestFrameDecoderExtensions(object):
             self._inbound_complete_called = True
             if self._fail_inbound_complete:
                 return fp.CloseReason.ABNORMAL_CLOSURE
-            if fin and self._rsv_bit_set:
+            if fin and self._inbound_rsv_bit_set:
                 return u'™'.encode('utf-8')
 
+        def frame_outbound(self, proto, opcode, rsv, data, fin):
+            if opcode is fp.Opcode.TEXT:
+                rsv = fp.RsvBits(rsv.rsv1, rsv.rsv2, True)
+                self._outbound_rsv_bit_set = True
+            if fin and self._outbound_rsv_bit_set:
+                data += u'®'.encode('utf-8')
+                self._outbound_rsv_bit_set = False
+            return rsv, data
+
     def test_rsv_bit(self):
         ext = self.FakeExtension()
         decoder = fp.FrameDecoder(client=True, extensions=[ext])
@@ -680,7 +786,7 @@ class TestFrameDecoderExtensions(object):
         frame = decoder.process_buffer()
         assert frame is not None
         assert ext._inbound_header_called
-        assert ext._rsv_bit_set
+        assert ext._inbound_rsv_bit_set
 
     def test_wrong_rsv_bit(self):
         ext = self.FakeExtension()
@@ -719,7 +825,7 @@ class TestFrameDecoderExtensions(object):
         frame = decoder.process_buffer()
         assert frame is not None
         assert ext._inbound_header_called
-        assert ext._rsv_bit_set
+        assert ext._inbound_rsv_bit_set
         assert ext._inbound_payload_data_called
         assert frame.payload == expected_payload
 
@@ -736,7 +842,7 @@ class TestFrameDecoderExtensions(object):
         frame = decoder.process_buffer()
         assert frame is not None
         assert ext._inbound_header_called
-        assert not ext._rsv_bit_set
+        assert not ext._inbound_rsv_bit_set
         assert ext._inbound_payload_data_called
         assert frame.payload == expected_payload
 
@@ -766,7 +872,7 @@ class TestFrameDecoderExtensions(object):
         frame = decoder.process_buffer()
         assert frame is not None
         assert ext._inbound_header_called
-        assert ext._rsv_bit_set
+        assert ext._inbound_rsv_bit_set
         assert ext._inbound_payload_data_called
         assert ext._inbound_complete_called
         assert frame.payload == expected_payload
@@ -784,7 +890,7 @@ class TestFrameDecoderExtensions(object):
         frame = decoder.process_buffer()
         assert frame is not None
         assert ext._inbound_header_called
-        assert not ext._rsv_bit_set
+        assert not ext._inbound_rsv_bit_set
         assert ext._inbound_payload_data_called
         assert ext._inbound_complete_called
         assert frame.payload == expected_payload
@@ -802,6 +908,27 @@ class TestFrameDecoderExtensions(object):
             decoder.process_buffer()
         assert excinfo.value.code is fp.CloseReason.ABNORMAL_CLOSURE
 
+    def test_outbound_handling_single_frame(self):
+        ext = self.FakeExtension()
+        proto = fp.FrameProtocol(client=False, extensions=[ext])
+        payload = u'😃😄🙃😉'
+        data = proto.send_data(payload, fin=True)
+        payload = (payload + u'®').encode('utf8')
+        assert data == b'\x91' + bytearray([len(payload)]) + payload
+
+    def test_outbound_handling_multiple_frames(self):
+        ext = self.FakeExtension()
+        proto = fp.FrameProtocol(client=False, extensions=[ext])
+        payload = u'😃😄🙃😉'
+        data = proto.send_data(payload, fin=False)
+        payload = payload.encode('utf8')
+        assert data == b'\x11' + bytearray([len(payload)]) + payload
+
+        payload = u'¯\_(ツ)_/¯'
+        data = proto.send_data(payload, fin=True)
+        payload = (payload + u'®').encode('utf8')
+        assert data == b'\x80' + bytearray([len(payload)]) + payload
+
 
 class TestFrameProtocolReceive(object):
     def test_long_text_message(self):
@@ -820,7 +947,9 @@ class TestFrameProtocolReceive(object):
         assert frame.payload == payload
 
     def _close_test(self, code, reason=None, reason_bytes=None):
-        payload = struct.pack('!H', code)
+        payload = b''
+        if code:
+            payload += struct.pack('!H', code)
         if reason:
             payload += reason.encode('utf8')
         elif reason_bytes:
@@ -834,12 +963,39 @@ class TestFrameProtocolReceive(object):
         assert len(frames) == 1
         frame = frames[0]
         assert frame.opcode == fp.Opcode.CLOSE
-        assert frame.payload[0] == code
+        assert frame.payload[0] == code or fp.CloseReason.NO_STATUS_RCVD
         if reason:
             assert frame.payload[1] == reason
         else:
             assert not frame.payload[1]
 
+    def test_close_no_code(self):
+        self._close_test(None)
+
+    def test_close_one_byte_code(self):
+        frame_bytes = b'\x88\x01\x0e'
+        protocol = fp.FrameProtocol(client=True, extensions=[])
+
+        with pytest.raises(fp.ParseFailed) as exc:
+            protocol.receive_bytes(frame_bytes)
+            list(protocol.received_frames())
+        assert exc.value.code == fp.CloseReason.PROTOCOL_ERROR
+
+    def test_close_bad_code(self):
+        with pytest.raises(fp.ParseFailed) as exc:
+            self._close_test(123)
+        assert exc.value.code == fp.CloseReason.PROTOCOL_ERROR
+
+    def test_close_unknown_code(self):
+        with pytest.raises(fp.ParseFailed) as exc:
+            self._close_test(2998)
+        assert exc.value.code == fp.CloseReason.PROTOCOL_ERROR
+
+    def test_close_local_only_code(self):
+        with pytest.raises(fp.ParseFailed) as exc:
+            self._close_test(fp.CloseReason.NO_STATUS_RCVD)
+        assert exc.value.code == fp.CloseReason.PROTOCOL_ERROR
+
     def test_close_no_payload(self):
         self._close_test(fp.CloseReason.NORMAL_CLOSURE)
 
@@ -863,66 +1019,240 @@ class TestFrameProtocolReceive(object):
                              reason_bytes=payload)
         assert exc.value.code == fp.CloseReason.INVALID_FRAME_PAYLOAD_DATA
 
+    def test_random_control_frame(self):
+        payload = b'give me one ping vasily'
+        frame_bytes = b'\x89' + bytearray([len(payload)]) + payload
 
-def test_close_with_long_reason():
-    # Long close reasons get silently truncated
-    proto = fp.FrameProtocol(client=False, extensions=[])
-    data = proto.close(code=fp.CloseReason.NORMAL_CLOSURE,
-                       reason="x" * 200)
-    assert data == bytearray(unhexlify("887d03e8")) + b"x" * 123
-
-    # While preserving valid utf-8
-    proto = fp.FrameProtocol(client=False, extensions=[])
-    # pound sign is 2 bytes in utf-8, so naive truncation to 123 bytes will
-    # cut it in half. Instead we truncate to 122 bytes.
-    data = proto.close(code=fp.CloseReason.NORMAL_CLOSURE,
-                       reason=u"£" * 100)
-    assert data == unhexlify("887c03e8") + u"£".encode("utf-8") * 61
-
-
-def test_payload_length_decode():
-    # "the minimal number of bytes MUST be used to encode the length, for
-    # example, the length of a 124-byte-long string can't be encoded as the
-    # sequence 126, 0, 124" -- RFC 6455
-
-    def make_header(encoding_bytes, payload_len):
-        if encoding_bytes == 1:
-            assert payload_len <= 125
-            return unhexlify("81") + bytes([payload_len])
-        elif encoding_bytes == 2:
-            assert payload_len < 2**16
-            return unhexlify("81" "7e") + struct.pack("!H", payload_len)
-        elif encoding_bytes == 8:
-            return unhexlify("81" "7f") + struct.pack("!Q", payload_len)
-        else:
-            assert False
+        protocol = fp.FrameProtocol(client=True, extensions=[])
+        protocol.receive_bytes(frame_bytes)
+        frames = list(protocol.received_frames())
+        assert len(frames) == 1
+        frame = frames[0]
+        assert frame.opcode == fp.Opcode.PING
+        assert len(frame.payload) == len(payload)
+        assert frame.payload == payload
 
-    def make_and_parse(encoding_bytes, payload_len):
-        proto = fp.FrameProtocol(client=True, extensions=[])
-        proto.receive_bytes(make_header(encoding_bytes, payload_len))
-        list(proto.received_frames())
-
-    # Valid lengths for 1 byte
-    for payload_len in [0, 1, 2, 123, 124, 125]:
-        make_and_parse(1, payload_len)
-        for encoding_bytes in [2, 8]:
-            with pytest.raises(fp.ParseFailed) as excinfo:
-                make_and_parse(encoding_bytes, payload_len)
-            assert "used {} bytes".format(encoding_bytes) in str(excinfo.value)
-
-    # Valid lengths for 2 bytes
-    for payload_len in [126, 127, 1000, 2**16 - 1]:
-        make_and_parse(2, payload_len)
-        with pytest.raises(fp.ParseFailed) as excinfo:
-            make_and_parse(8, payload_len)
-        assert "used 8 bytes" in str(excinfo.value)
+    def test_close_reasons_get_utf8_validated(self, monkeypatch):
+        validator = FakeValidator()
+        reason = u'ƒñø®∂'
+        monkeypatch.setattr(fp, 'Utf8Validator', lambda: validator)
 
-    # Valid lengths for 8 bytes
-    for payload_len in [2**16, 2**16 + 1, 2**32, 2**63 - 1]:
-        make_and_parse(8, payload_len)
+        self._close_test(fp.CloseReason.NORMAL_CLOSURE, reason=reason)
 
-    # Invalid lengths for 8 bytes
-    for payload_len in [2**63, 2**63 + 1]:
-        with pytest.raises(fp.ParseFailed) as excinfo:
-            make_and_parse(8, payload_len)
-        assert "non-zero MSB" in str(excinfo.value)
+        assert validator.validated == reason.encode('utf8')
+
+    def test_close_reason_failing_validation_fails(self, monkeypatch):
+        validator = FakeValidator()
+        validator.valid = False
+        reason = u'ƒñø®∂'
+        monkeypatch.setattr(fp, 'Utf8Validator', lambda: validator)
+
+        with pytest.raises(fp.ParseFailed) as exc:
+            self._close_test(fp.CloseReason.NORMAL_CLOSURE, reason=reason)
+        assert exc.value.code == fp.CloseReason.INVALID_FRAME_PAYLOAD_DATA
+
+    def test_close_reason_with_incomplete_utf8_fails(self, monkeypatch):
+        validator = FakeValidator()
+        validator.ends_on_complete = False
+        reason = u'ƒñø®∂'
+        monkeypatch.setattr(fp, 'Utf8Validator', lambda: validator)
+
+        with pytest.raises(fp.ParseFailed) as exc:
+            self._close_test(fp.CloseReason.NORMAL_CLOSURE, reason=reason)
+        assert exc.value.code == fp.CloseReason.INVALID_FRAME_PAYLOAD_DATA
+
+    def test_close_no_validation(self, monkeypatch):
+        monkeypatch.setattr(fp, 'Utf8Validator', lambda: None)
+        reason = u'ƒñø®∂'
+        self._close_test(fp.CloseReason.NORMAL_CLOSURE, reason=reason)
+
+    def test_close_no_validation_can_still_fail(self, monkeypatch):
+        validator = FakeValidator()
+        monkeypatch.setattr(fp, 'Utf8Validator', lambda: validator)
+
+        reason = u'fñörd'
+        reason = reason.encode('iso-8859-1')
+
+        with pytest.raises(fp.ParseFailed) as exc:
+            self._close_test(fp.CloseReason.NORMAL_CLOSURE,
+                             reason_bytes=reason)
+        assert exc.value.code == fp.CloseReason.INVALID_FRAME_PAYLOAD_DATA
+
+
+class TestFrameProtocolSend(object):
+    def test_simplest_possible_close(self):
+        proto = fp.FrameProtocol(client=False, extensions=[])
+        data = proto.close()
+        assert data == b'\x88\x00'
+
+    def test_unreasoning_close(self):
+        proto = fp.FrameProtocol(client=False, extensions=[])
+        data = proto.close(code=fp.CloseReason.NORMAL_CLOSURE)
+        assert data == b'\x88\x02\x03\xe8'
+
+    def test_reasoned_close(self):
+        proto = fp.FrameProtocol(client=False, extensions=[])
+        reason = u'¯\_(ツ)_/¯'
+        expected_payload = struct.pack('!H', fp.CloseReason.NORMAL_CLOSURE) + \
+            reason.encode('utf8')
+        data = proto.close(code=fp.CloseReason.NORMAL_CLOSURE, reason=reason)
+        assert data == b'\x88' + bytearray([len(expected_payload)]) + \
+            expected_payload
+
+    def test_overly_reasoned_close(self):
+        proto = fp.FrameProtocol(client=False, extensions=[])
+        reason = u'¯\_(ツ)_/¯' * 10
+        data = proto.close(code=fp.CloseReason.NORMAL_CLOSURE, reason=reason)
+        assert bytes(data[0:1]) == b'\x88'
+        assert len(data) <= 127
+        assert data[4:].decode('utf8')
+
+    def test_reasoned_but_uncoded_close(self):
+        proto = fp.FrameProtocol(client=False, extensions=[])
+        with pytest.raises(TypeError):
+            proto.close(reason='termites')
+
+    def test_local_only_close_reason(self):
+        proto = fp.FrameProtocol(client=False, extensions=[])
+        data = proto.close(code=fp.CloseReason.NO_STATUS_RCVD)
+        assert data == b'\x88\x02\x03\xe8'
+
+    def test_pong_without_payload(self):
+        proto = fp.FrameProtocol(client=False, extensions=[])
+        data = proto.pong()
+        assert data == b'\x8a\x00'
+
+    def test_pong_with_payload(self):
+        proto = fp.FrameProtocol(client=False, extensions=[])
+        payload = u'¯\_(ツ)_/¯'.encode('utf8')
+        data = proto.pong(payload)
+        assert data == b'\x8a' + bytearray([len(payload)]) + payload
+
+    def test_single_short_binary_data(self):
+        proto = fp.FrameProtocol(client=False, extensions=[])
+        payload = b"it's all just ascii, right?"
+        data = proto.send_data(payload, fin=True)
+        assert data == b'\x82' + bytearray([len(payload)]) + payload
+
+    def test_single_short_text_data(self):
+        proto = fp.FrameProtocol(client=False, extensions=[])
+        payload = u'😃😄🙃😉'
+        data = proto.send_data(payload, fin=True)
+        payload = payload.encode('utf8')
+        assert data == b'\x81' + bytearray([len(payload)]) + payload
+
+    def test_multiple_short_binary_data(self):
+        proto = fp.FrameProtocol(client=False, extensions=[])
+        payload = b"it's all just ascii, right?"
+        data = proto.send_data(payload, fin=False)
+        assert data == b'\x02' + bytearray([len(payload)]) + payload
+
+        payload = b'sure no worries'
+        data = proto.send_data(payload, fin=True)
+        assert data == b'\x80' + bytearray([len(payload)]) + payload
+
+    def test_multiple_short_text_data(self):
+        proto = fp.FrameProtocol(client=False, extensions=[])
+        payload = u'😃😄🙃😉'
+        data = proto.send_data(payload, fin=False)
+        payload = payload.encode('utf8')
+        assert data == b'\x01' + bytearray([len(payload)]) + payload
+
+        payload = u'🙈🙉🙊'
+        data = proto.send_data(payload, fin=True)
+        payload = payload.encode('utf8')
+        assert data == b'\x80' + bytearray([len(payload)]) + payload
+
+    def test_mismatched_data_messages1(self):
+        proto = fp.FrameProtocol(client=False, extensions=[])
+        payload = u'😃😄🙃😉'
+        data = proto.send_data(payload, fin=False)
+        payload = payload.encode('utf8')
+        assert data == b'\x01' + bytearray([len(payload)]) + payload
+
+        payload = b'seriously, all ascii'
+        with pytest.raises(TypeError):
+            proto.send_data(payload)
+
+    def test_mismatched_data_messages2(self):
+        proto = fp.FrameProtocol(client=False, extensions=[])
+        payload = b"it's all just ascii, right?"
+        data = proto.send_data(payload, fin=False)
+        assert data == b'\x02' + bytearray([len(payload)]) + payload
+
+        payload = u'✔️☑️✅✔︎☑'
+        with pytest.raises(TypeError):
+            proto.send_data(payload)
+
+    def test_message_length_max_short(self):
+        proto = fp.FrameProtocol(client=False, extensions=[])
+        payload = b'x' * 125
+        data = proto.send_data(payload, fin=True)
+        assert data == b'\x82' + bytearray([len(payload)]) + payload
+
+    def test_message_length_min_two_byte(self):
+        proto = fp.FrameProtocol(client=False, extensions=[])
+        payload = b'x' * 126
+        data = proto.send_data(payload, fin=True)
+        assert data == b'\x82\x7e' + struct.pack('!H', len(payload)) + payload
+
+    def test_message_length_max_two_byte(self):
+        proto = fp.FrameProtocol(client=False, extensions=[])
+        payload = b'x' * (2 ** 16 - 1)
+        data = proto.send_data(payload, fin=True)
+        assert data == b'\x82\x7e' + struct.pack('!H', len(payload)) + payload
+
+    def test_message_length_min_eight_byte(self):
+        proto = fp.FrameProtocol(client=False, extensions=[])
+        payload = b'x' * (2 ** 16)
+        data = proto.send_data(payload, fin=True)
+        assert data == b'\x82\x7f' + struct.pack('!Q', len(payload)) + payload
+
+    def test_client_side_masking_short_frame(self):
+        proto = fp.FrameProtocol(client=True, extensions=[])
+        payload = b'x' * 125
+        data = proto.send_data(payload, fin=True)
+        assert data[0] == 0x82
+        assert struct.unpack('!B', data[1:2])[0] == len(payload) | 0x80
+        masking_key = data[2:6]
+        maskbytes = itertools.cycle(masking_key)
+        assert data[6:] == \
+            bytearray(b ^ next(maskbytes) for b in bytearray(payload))
+
+    def test_client_side_masking_two_byte_frame(self):
+        proto = fp.FrameProtocol(client=True, extensions=[])
+        payload = b'x' * 126
+        data = proto.send_data(payload, fin=True)
+        assert data[0] == 0x82
+        assert data[1] == 0xfe
+        assert struct.unpack('!H', data[2:4])[0] == len(payload)
+        masking_key = data[4:8]
+        maskbytes = itertools.cycle(masking_key)
+        assert data[8:] == \
+            bytearray(b ^ next(maskbytes) for b in bytearray(payload))
+
+    def test_client_side_masking_eight_byte_frame(self):
+        proto = fp.FrameProtocol(client=True, extensions=[])
+        payload = b'x' * 65536
+        data = proto.send_data(payload, fin=True)
+        assert data[0] == 0x82
+        assert data[1] == 0xff
+        assert struct.unpack('!Q', data[2:10])[0] == len(payload)
+        masking_key = data[10:14]
+        maskbytes = itertools.cycle(masking_key)
+        assert data[14:] == \
+            bytearray(b ^ next(maskbytes) for b in bytearray(payload))
+
+    def test_control_frame_with_overly_long_payload(self):
+        proto = fp.FrameProtocol(client=False, extensions=[])
+        payload = b'x' * 126
+
+        with pytest.raises(ValueError):
+            proto.pong(payload)
+
+    def test_data_we_have_no_idea_what_to_do_with(self):
+        proto = fp.FrameProtocol(client=False, extensions=[])
+        payload = dict()
+
+        with pytest.raises(ValueError):
+            proto.send_data(payload)

pip install -e .[dev]
parse_log_pytest
pytest pytest-cov
apt-get update apt-get install -y gcc
3.9
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
coverage==7.8.0
exceptiongroup==1.2.2
h11==0.7.0
iniconfig==2.1.0
packaging==24.2
pluggy==1.5.0
pytest==8.3.5
pytest-cov==6.0.0
tomli==2.2.1
-e git+https://github.com/python-hyper/wsproto.git@f74bd34194d496b690246dfa78dcf133b6f8be34#egg=wsproto

name: wsproto
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - coverage==7.8.0
      - exceptiongroup==1.2.2
      - h11==0.7.0
      - iniconfig==2.1.0
      - packaging==24.2
      - pluggy==1.5.0
      - pytest==8.3.5
      - pytest-cov==6.0.0
      - tomli==2.2.1
prefix: /opt/conda/envs/wsproto

test/test_frame_protocol.py::TestMessageDecoder::test_text_message_hits_validator test/test_frame_protocol.py::TestMessageDecoder::test_message_validation_failure_fails_properly test/test_frame_protocol.py::TestMessageDecoder::test_message_validation_finish_on_incomplete test/test_frame_protocol.py::TestFrameProtocolReceive::test_close_reasons_get_utf8_validated test/test_frame_protocol.py::TestFrameProtocolReceive::test_close_reason_failing_validation_fails test/test_frame_protocol.py::TestFrameProtocolReceive::test_close_reason_with_incomplete_utf8_fails test/test_frame_protocol.py::TestFrameProtocolSend::test_pong_without_payload test/test_frame_protocol.py::TestFrameProtocolSend::test_data_we_have_no_idea_what_to_do_with
test/test_frame_protocol.py::TestBuffer::test_consume_at_most_zero_bytes test/test_frame_protocol.py::TestBuffer::test_consume_at_most_with_no_data test/test_frame_protocol.py::TestBuffer::test_consume_at_most_with_sufficient_data test/test_frame_protocol.py::TestBuffer::test_consume_at_most_with_more_than_sufficient_data test/test_frame_protocol.py::TestBuffer::test_consume_at_most_with_insufficient_data test/test_frame_protocol.py::TestBuffer::test_consume_exactly_with_sufficient_data test/test_frame_protocol.py::TestBuffer::test_consume_exactly_with_more_than_sufficient_data test/test_frame_protocol.py::TestBuffer::test_consume_exactly_with_insufficient_data test/test_frame_protocol.py::TestBuffer::test_feed test/test_frame_protocol.py::TestBuffer::test_rollback test/test_frame_protocol.py::TestBuffer::test_commit test/test_frame_protocol.py::TestBuffer::test_length test/test_frame_protocol.py::TestMessageDecoder::test_single_binary_frame test/test_frame_protocol.py::TestMessageDecoder::test_follow_on_binary_frame test/test_frame_protocol.py::TestMessageDecoder::test_single_text_frame test/test_frame_protocol.py::TestMessageDecoder::test_follow_on_text_frame test/test_frame_protocol.py::TestMessageDecoder::test_final_text_frame test/test_frame_protocol.py::TestMessageDecoder::test_start_with_continuation test/test_frame_protocol.py::TestMessageDecoder::test_missing_continuation_1 test/test_frame_protocol.py::TestMessageDecoder::test_missing_continuation_2 test/test_frame_protocol.py::TestMessageDecoder::test_incomplete_unicode test/test_frame_protocol.py::TestMessageDecoder::test_not_even_unicode test/test_frame_protocol.py::TestMessageDecoder::test_bad_unicode test/test_frame_protocol.py::TestMessageDecoder::test_split_message test/test_frame_protocol.py::TestMessageDecoder::test_split_unicode_message test/test_frame_protocol.py::TestMessageDecoder::test_message_validation_unfinished_on_incomplete test/test_frame_protocol.py::TestMessageDecoder::test_message_no_validation_can_still_fail test/test_frame_protocol.py::TestFrameDecoder::test_zero_length_message test/test_frame_protocol.py::TestFrameDecoder::test_short_server_message_frame test/test_frame_protocol.py::TestFrameDecoder::test_short_client_message_frame test/test_frame_protocol.py::TestFrameDecoder::test_reject_masked_server_frame test/test_frame_protocol.py::TestFrameDecoder::test_reject_unmasked_client_frame test/test_frame_protocol.py::TestFrameDecoder::test_reject_bad_opcode test/test_frame_protocol.py::TestFrameDecoder::test_reject_unfinished_control_frame test/test_frame_protocol.py::TestFrameDecoder::test_reject_reserved_bits test/test_frame_protocol.py::TestFrameDecoder::test_long_message_frame test/test_frame_protocol.py::TestFrameDecoder::test_very_long_message_frame test/test_frame_protocol.py::TestFrameDecoder::test_insufficiently_long_message_frame test/test_frame_protocol.py::TestFrameDecoder::test_insufficiently_very_long_message_frame test/test_frame_protocol.py::TestFrameDecoder::test_very_insufficiently_very_long_message_frame test/test_frame_protocol.py::TestFrameDecoder::test_not_enough_for_header test/test_frame_protocol.py::TestFrameDecoder::test_not_enough_for_long_length test/test_frame_protocol.py::TestFrameDecoder::test_not_enough_for_very_long_length test/test_frame_protocol.py::TestFrameDecoder::test_eight_byte_length_with_msb_set test/test_frame_protocol.py::TestFrameDecoder::test_not_enough_for_mask test/test_frame_protocol.py::TestFrameDecoder::test_partial_message_frames test/test_frame_protocol.py::TestFrameDecoder::test_partial_control_frame test/test_frame_protocol.py::TestFrameDecoder::test_long_message_sliced test/test_frame_protocol.py::TestFrameDecoder::test_overly_long_control_frame test/test_frame_protocol.py::TestFrameDecoderExtensions::test_rsv_bit test/test_frame_protocol.py::TestFrameDecoderExtensions::test_wrong_rsv_bit test/test_frame_protocol.py::TestFrameDecoderExtensions::test_header_error_handling test/test_frame_protocol.py::TestFrameDecoderExtensions::test_payload_processing test/test_frame_protocol.py::TestFrameDecoderExtensions::test_no_payload_processing_when_not_wanted test/test_frame_protocol.py::TestFrameDecoderExtensions::test_payload_error_handling test/test_frame_protocol.py::TestFrameDecoderExtensions::test_frame_completion test/test_frame_protocol.py::TestFrameDecoderExtensions::test_no_frame_completion_when_not_wanted test/test_frame_protocol.py::TestFrameDecoderExtensions::test_completion_error_handling test/test_frame_protocol.py::TestFrameDecoderExtensions::test_outbound_handling_single_frame test/test_frame_protocol.py::TestFrameDecoderExtensions::test_outbound_handling_multiple_frames test/test_frame_protocol.py::TestFrameProtocolReceive::test_long_text_message test/test_frame_protocol.py::TestFrameProtocolReceive::test_close_no_code test/test_frame_protocol.py::TestFrameProtocolReceive::test_close_one_byte_code test/test_frame_protocol.py::TestFrameProtocolReceive::test_close_bad_code test/test_frame_protocol.py::TestFrameProtocolReceive::test_close_unknown_code test/test_frame_protocol.py::TestFrameProtocolReceive::test_close_local_only_code test/test_frame_protocol.py::TestFrameProtocolReceive::test_close_no_payload test/test_frame_protocol.py::TestFrameProtocolReceive::test_close_easy_payload test/test_frame_protocol.py::TestFrameProtocolReceive::test_close_utf8_payload test/test_frame_protocol.py::TestFrameProtocolReceive::test_close_bad_utf8_payload test/test_frame_protocol.py::TestFrameProtocolReceive::test_close_incomplete_utf8_payload test/test_frame_protocol.py::TestFrameProtocolReceive::test_random_control_frame test/test_frame_protocol.py::TestFrameProtocolReceive::test_close_no_validation test/test_frame_protocol.py::TestFrameProtocolReceive::test_close_no_validation_can_still_fail test/test_frame_protocol.py::TestFrameProtocolSend::test_simplest_possible_close test/test_frame_protocol.py::TestFrameProtocolSend::test_unreasoning_close test/test_frame_protocol.py::TestFrameProtocolSend::test_reasoned_close test/test_frame_protocol.py::TestFrameProtocolSend::test_overly_reasoned_close test/test_frame_protocol.py::TestFrameProtocolSend::test_reasoned_but_uncoded_close test/test_frame_protocol.py::TestFrameProtocolSend::test_local_only_close_reason test/test_frame_protocol.py::TestFrameProtocolSend::test_pong_with_payload test/test_frame_protocol.py::TestFrameProtocolSend::test_single_short_binary_data test/test_frame_protocol.py::TestFrameProtocolSend::test_single_short_text_data test/test_frame_protocol.py::TestFrameProtocolSend::test_multiple_short_binary_data test/test_frame_protocol.py::TestFrameProtocolSend::test_multiple_short_text_data test/test_frame_protocol.py::TestFrameProtocolSend::test_mismatched_data_messages1 test/test_frame_protocol.py::TestFrameProtocolSend::test_mismatched_data_messages2 test/test_frame_protocol.py::TestFrameProtocolSend::test_message_length_max_short test/test_frame_protocol.py::TestFrameProtocolSend::test_message_length_min_two_byte test/test_frame_protocol.py::TestFrameProtocolSend::test_message_length_max_two_byte test/test_frame_protocol.py::TestFrameProtocolSend::test_message_length_min_eight_byte test/test_frame_protocol.py::TestFrameProtocolSend::test_client_side_masking_short_frame test/test_frame_protocol.py::TestFrameProtocolSend::test_client_side_masking_two_byte_frame test/test_frame_protocol.py::TestFrameProtocolSend::test_client_side_masking_eight_byte_frame test/test_frame_protocol.py::TestFrameProtocolSend::test_control_frame_with_overly_long_payload
wsproto/compat.py wsproto/frame_protocol.py tox.ini
wsproto/compat.py wsproto/frame_protocol.py tox.ini