M PLAN.md => PLAN.md +12 -7
@@ 144,13 144,18 @@ fiber and bridges client ↔ handler via `fiber.channel`. All four flavors
Watchlists at `test/conformance/known_failures.txt` (main suite)
and `test/conformance/known_failures_text.txt` (text-format
suite). Current baseline (2026-05-16):
- - Binary+JSON suite: 1389 ✓ / 1313 skipped / 79 expected fails
- - Text-format suite: 0 ✓ / 430 skipped / 4 expected fails
- JSON output now runs end-to-end through the harness. The remaining
- expected fails are canonical-form edge cases (Duration/Timestamp
- formatting, double precision, NaN handling, JSON-input rejection
- rules). The `PB_CONFORMANCE_SKIP_JSON=1` env var still short-
- circuits JSON output if a new encoder bug crashes jsoncpp.
+ - Binary+JSON suite: 1478 ✓ / 1313 skipped / 15 expected fails
+ - Text-format suite: 2 ✓ / 426 skipped / 6 expected fails
+ JSON output runs end-to-end; remaining expected fails are
+ Recommended-only edge cases (FieldMask round-trip,
+ duplicate-field-name rejection, null-in-collection rejection,
+ unknown-enum-name rejection, NullValue oneof validator).
+ Text-format output is wired through `pb.text.encode`; the six
+ expected fails are all unknown-field cases (Group/Repeated parse
+ rejection, *_Print not rendering captured unknowns). Text-format
+ *input* parsing is still deferred. The `PB_CONFORMANCE_SKIP_JSON=1`
+ env var still short-circuits JSON output if a new encoder bug
+ crashes jsoncpp.
CI wire-up pending — the image build is the long pole (~10–15 min
on a clean cache).
- [x] Cross-impl interop: 18-fixture corpus in `test/interop/fixtures/`
M cmd/conformance/core.lua => cmd/conformance/core.lua +14 -4
@@ 57,8 57,11 @@ local function dispatch(req)
return {parse_error = 'json decode failed: ' .. tostring(decoded)}
end
msg = decoded
- elseif req.jspb_payload ~= nil or req.text_payload ~= nil then
- return {skipped = 'jspb/text input not supported'}
+ elseif req.jspb_payload ~= nil then
+ return {skipped = 'jspb input not supported'}
+ elseif req.text_payload ~= nil then
+ -- pb.text is encode-only; text-format input parsing is deferred.
+ return {skipped = 'text-format input not supported'}
else
return {runtime_error = 'no payload set in ConformanceRequest'}
end
@@ 92,8 95,15 @@ local function dispatch(req)
tostring(jbytes)}
end
return {json_payload = jbytes}
- elseif out_fmt == JSPB or out_fmt == TEXT_FORMAT then
- return {skipped = 'jspb/text output not supported'}
+ elseif out_fmt == TEXT_FORMAT then
+ local ok, tbytes = pcall(pb.text.encode, desc, msg)
+ if not ok then
+ return {serialize_error = 'text encode failed: ' ..
+ tostring(tbytes)}
+ end
+ return {text_payload = tbytes}
+ elseif out_fmt == JSPB then
+ return {skipped = 'jspb output not supported'}
else
return {runtime_error = 'unknown requested_output_format: ' ..
tostring(out_fmt)}
M test/conformance/known_failures_text.txt => test/conformance/known_failures_text.txt +20 -5
@@ 1,11 1,26 @@
# conformance_test_runner --text_format_failure_list
#
-# Text-format conformance is intentionally deferred — pb.text only does
-# encoding from a Lua table, not text-protobuf input parsing nor protobuf-
-# input→text-output, so the harness's text-format suite has nothing to
-# exercise yet. These tests fail because we can't produce text-format
-# output from the protobuf payloads they ship.
+# Text-format OUTPUT is now wired through cmd/conformance/core.lua to
+# pb.text.encode (protobuf/JSON input → text output). Input parsing is
+# still deferred — pb.text only encodes — so any test whose payload is
+# text_payload still returns `skipped`.
+#
+# Remaining expected failures all stem from unknown-field handling:
+#
+# * Group/Repeated *_Drop: our decoder rejects payloads carrying
+# wire types 3/4 (proto2 groups) and length-mismatched repeated
+# unknown bytes, so we never reach the encoder. Expected output is
+# an empty text body, which we'd produce trivially if the decoder
+# accepted the input. Fixing requires skip_field to tolerate groups.
+#
+# * *_Print: with `print_unknown_fields: true` the harness expects the
+# text output to include the original unknown fields. We capture
+# unknown bytes during decode but pb.text doesn't render them, so
+# output comes back empty. Fixing requires the text encoder to walk
+# the captured unknown set.
Recommended.Proto3.ProtobufInput.GroupUnknownFields_Drop.TextFormatOutput
Recommended.Proto3.ProtobufInput.GroupUnknownFields_Print.TextFormatOutput
+Recommended.Proto3.ProtobufInput.MessageUnknownFields_Print.TextFormatOutput
Recommended.Proto3.ProtobufInput.RepeatedUnknownFields_Drop.TextFormatOutput
Recommended.Proto3.ProtobufInput.RepeatedUnknownFields_Print.TextFormatOutput
+Recommended.Proto3.ProtobufInput.ScalarUnknownFields_Print.TextFormatOutput
M test/conformance_test.lua => test/conformance_test.lua +62 -3
@@ 128,14 128,73 @@ core_g.test_unsupported_message_type_skipped = function()
t.assert_str_contains(resp.skipped or '', 'unsupported message type')
end
-core_g.test_text_format_skipped = function()
- local input = proto3.TestAllTypesProto3_encode({optional_int32 = 1})
+core_g.test_pb_to_text = function()
+ -- protobuf input + TEXT_FORMAT output runs pb.text.encode on the
+ -- decoded Lua table. Result should look like `protoc --decode` output.
+ local input = proto3.TestAllTypesProto3_encode({
+ optional_int32 = 42,
+ optional_string = 'hello',
+ repeated_int32 = {1, 2, 3},
+ })
local resp = decode_resp(core.handle_request(encode_req({
protobuf_payload = input,
requested_output_format = TEXT,
message_type = PROTO3_NAME,
})))
- t.assert_str_contains(resp.skipped or '', 'jspb/text')
+ t.assert_not(resp.parse_error, resp.parse_error)
+ t.assert_not(resp.serialize_error, resp.serialize_error)
+ t.assert_not(resp.skipped, resp.skipped)
+ t.assert_str_contains(resp.text_payload, 'optional_int32: 42')
+ t.assert_str_contains(resp.text_payload, 'optional_string: "hello"')
+ -- Repeated fields emit once per element, snake_case, mainline form.
+ t.assert_str_contains(resp.text_payload, 'repeated_int32: 1')
+ t.assert_str_contains(resp.text_payload, 'repeated_int32: 2')
+ t.assert_str_contains(resp.text_payload, 'repeated_int32: 3')
+end
+
+core_g.test_json_to_text = function()
+ local resp = decode_resp(core.handle_request(encode_req({
+ json_payload = [[{"optionalInt32": 7, "optionalString": "abc"}]],
+ requested_output_format = TEXT,
+ message_type = PROTO3_NAME,
+ })))
+ t.assert_not(resp.parse_error, resp.parse_error)
+ t.assert_not(resp.serialize_error, resp.serialize_error)
+ t.assert_str_contains(resp.text_payload, 'optional_int32: 7')
+ t.assert_str_contains(resp.text_payload, 'optional_string: "abc"')
+end
+
+core_g.test_empty_message_to_text = function()
+ -- Empty proto3 message has no fields to print; text encoder produces
+ -- the empty string. The result field is still text_payload (empty
+ -- string), not skipped/runtime_error.
+ local resp = decode_resp(core.handle_request(encode_req({
+ protobuf_payload = '',
+ requested_output_format = TEXT,
+ message_type = PROTO3_NAME,
+ })))
+ t.assert_not(resp.parse_error, resp.parse_error)
+ t.assert_not(resp.serialize_error, resp.serialize_error)
+ t.assert_equals(resp.text_payload, '')
+end
+
+core_g.test_text_input_skipped = function()
+ -- Text-format input parsing is still deferred (pb.text is encode-only).
+ local resp = decode_resp(core.handle_request(encode_req({
+ text_payload = 'optional_int32: 1\n',
+ requested_output_format = PROTOBUF,
+ message_type = PROTO3_NAME,
+ })))
+ t.assert_str_contains(resp.skipped or '', 'text-format input')
+end
+
+core_g.test_jspb_output_skipped = function()
+ local resp = decode_resp(core.handle_request(encode_req({
+ protobuf_payload = '',
+ requested_output_format = conformance.WireFormat.JSPB,
+ message_type = PROTO3_NAME,
+ })))
+ t.assert_str_contains(resp.skipped or '', 'jspb')
end
core_g.test_empty_payload_decodes_as_empty_message = function()