diff --git a/README.md b/README.md index 0c46cb6..ff49770 100644 --- a/README.md +++ b/README.md @@ -47,18 +47,29 @@ The output and command fields provide additional actions through their context m The protocol and session layers are intentionally separated so richer MUD functionality can be added without coupling it to the accessible interface. +Automated regression coverage now exercises the streaming Telnet parser and text pipeline, including negotiation, fragmented input, ANSI filtering, character decoding, and newline edge cases. + Planned work, roughly in order: -1. Add automated regression tests to the repository and exercise more Telnet edge cases. -2. Add MCCP2 compressed-stream support. -3. Add GMCP negotiation, bounded JSON parsing, typed package events, and an accessible inspector. -4. Add opt-in MSP sound and music with safe URL handling, limits, caching, and independent playback controls. -5. Add aliases, triggers, timers, optional transcripts, and configurable command shortcuts. -6. Add multiple simultaneous sessions with independent output and speech queues. -7. Add localization catalogs and package/signing automation for releases. +1. Add MCCP2 compressed-stream support. +2. Add GMCP negotiation, bounded JSON parsing, typed package events, and an accessible inspector. +3. Add opt-in MSP sound and music with safe URL handling, limits, caching, and independent playback controls. +4. Add aliases, triggers, timers, optional transcripts, and configurable command shortcuts. +5. Add multiple simultaneous sessions with independent output and speech queues. +6. Add localization catalogs and package/signing automation for releases. + +## Running the tests + +The suite uses no external gems. Run it with a standard Ruby installation: + +```console +ruby test/run.rb +``` + +When developing inside Elten, load `test/run.rb` through the source program's evaluation context so the tests use Elten's bundled Ruby runtime. ## Development status -The current implementation has been checked with Elten's Ruby syntax checker and exercised with in-process protocol tests plus a loopback TCP server. Real MUDs differ considerably in their Telnet behavior, so interoperability reports and reproducible protocol captures are welcome. +The current implementation is checked with Elten's Ruby syntax checker and its automated in-process protocol suite, and has also been exercised with a loopback TCP server. Real MUDs differ considerably in their Telnet behavior, so interoperability reports and reproducible protocol captures are welcome. The application UUID is `51a92051-84e0-415f-84e3-98f08a320a15` and must remain stable across releases. diff --git a/lib/meow/text_pipeline.rb b/lib/meow/text_pipeline.rb index ff22fc6..19314de 100644 --- a/lib/meow/text_pipeline.rb +++ b/lib/meow/text_pipeline.rb @@ -145,16 +145,15 @@ module MeowClient text.each_char do |character| if @pending_cr if character == "\n" || character == "\0" - records << take_line @pending_cr = false next else - records << take_line @pending_cr = false end end case character when "\r" + records << take_line @pending_cr = true when "\n" records << take_line @@ -177,4 +176,3 @@ module MeowClient end end end - diff --git a/test/run.rb b/test/run.rb new file mode 100644 index 0000000..4faae6e --- /dev/null +++ b/test/run.rb @@ -0,0 +1,10 @@ +require_relative "test_helper" +require_relative "telnet_test" +require_relative "text_pipeline_test" + +begin + MeowTest.run! +rescue MeowTest::Failure + exit(1) if __FILE__ == $PROGRAM_NAME + raise +end diff --git a/test/telnet_test.rb b/test/telnet_test.rb new file mode 100644 index 0000000..8acd986 --- /dev/null +++ b/test/telnet_test.rb @@ -0,0 +1,97 @@ +# encoding: UTF-8 + +require_relative "test_helper" +require_relative "../lib/meow/telnet" + +module MeowTelnetTest + Telnet = MeowClient::Telnet + + def self.bytes(*values) + values.pack("C*") + end + + def self.collect(parser, chunks) + chunks.each_with_object({:data => +"".b, :replies => [], :events => []}) do |chunk, result| + parsed = parser.feed(chunk) + result[:data] << parsed.data + result[:replies].concat(parsed.replies) + result[:events].concat(parsed.events) + end + end + + MeowTest.test("Telnet preserves data and unescapes IAC") do + parser = Telnet::Parser.new + result = parser.feed("look".b + bytes(Telnet::IAC, Telnet::IAC) + "!".b) + MeowTest.assert_equal("look".b + bytes(Telnet::IAC) + "!".b, result.data) + MeowTest.assert_empty(result.replies) + MeowTest.assert_empty(result.events) + end + + MeowTest.test("Telnet negotiation survives every input boundary") do + packet = bytes(Telnet::IAC, Telnet::WILL, Telnet::ECHO) + whole = collect(Telnet::Parser.new, [packet]) + split = collect(Telnet::Parser.new, packet.bytes.map { |byte| bytes(byte) }) + MeowTest.assert_equal(whole, split) + MeowTest.assert_equal([bytes(Telnet::IAC, Telnet::DO, Telnet::ECHO)], split[:replies]) + MeowTest.assert_equal([[:remote_echo, true]], split[:events]) + end + + MeowTest.test("Telnet tracks echo transitions without duplicate replies") do + parser = Telnet::Parser.new + enabled = parser.feed(bytes(Telnet::IAC, Telnet::WILL, Telnet::ECHO)) + duplicate = parser.feed(bytes(Telnet::IAC, Telnet::WILL, Telnet::ECHO)) + disabled = parser.feed(bytes(Telnet::IAC, Telnet::WONT, Telnet::ECHO)) + disabled_duplicate = parser.feed(bytes(Telnet::IAC, Telnet::WONT, Telnet::ECHO)) + + MeowTest.assert_equal([bytes(Telnet::IAC, Telnet::DO, Telnet::ECHO)], enabled.replies) + MeowTest.assert_empty(duplicate.replies) + MeowTest.assert_equal([bytes(Telnet::IAC, Telnet::DONT, Telnet::ECHO)], disabled.replies) + MeowTest.assert_equal([[:remote_echo, false]], disabled.events) + MeowTest.assert_empty(disabled_duplicate.replies) + MeowTest.refute(parser.remote_echo) + end + + MeowTest.test("Telnet accepts supported options and rejects unknown options") do + parser = Telnet::Parser.new + remote = parser.feed(bytes(Telnet::IAC, Telnet::WILL, Telnet::SUPPRESS_GO_AHEAD)) + remote_unknown = parser.feed(bytes(Telnet::IAC, Telnet::WILL, 42)) + local = parser.feed(bytes(Telnet::IAC, Telnet::DO, Telnet::TERMINAL_TYPE)) + local_unknown = parser.feed(bytes(Telnet::IAC, Telnet::DO, 43)) + + MeowTest.assert_equal([bytes(Telnet::IAC, Telnet::DO, Telnet::SUPPRESS_GO_AHEAD)], remote.replies) + MeowTest.assert_equal([bytes(Telnet::IAC, Telnet::DONT, 42)], remote_unknown.replies) + MeowTest.assert_equal([bytes(Telnet::IAC, Telnet::WILL, Telnet::TERMINAL_TYPE)], local.replies) + MeowTest.assert_equal([bytes(Telnet::IAC, Telnet::WONT, 43)], local_unknown.replies) + end + + MeowTest.test("Telnet sends terminal type only after accepting the option") do + request = bytes(Telnet::IAC, Telnet::SB, Telnet::TERMINAL_TYPE, Telnet::TTYPE_SEND, Telnet::IAC, Telnet::SE) + parser = Telnet::Parser.new + before = parser.feed(request) + parser.feed(bytes(Telnet::IAC, Telnet::DO, Telnet::TERMINAL_TYPE)) + after = collect(parser, request.bytes.map { |byte| bytes(byte) }) + expected = bytes(Telnet::IAC, Telnet::SB, Telnet::TERMINAL_TYPE, Telnet::TTYPE_IS) + "MEOW".b + bytes(Telnet::IAC, Telnet::SE) + + MeowTest.assert_empty(before.replies) + MeowTest.assert_equal([[:subnegotiation, Telnet::TERMINAL_TYPE]], before.events) + MeowTest.assert_equal([expected], after[:replies]) + MeowTest.assert_equal([[:terminal_type, "MEOW"]], after[:events]) + end + + MeowTest.test("Telnet handles escaped IAC inside subnegotiation") do + parser = Telnet::Parser.new + packet = bytes(Telnet::IAC, Telnet::SB, 77, 1, Telnet::IAC, Telnet::IAC, 2, Telnet::IAC, Telnet::SE) + result = collect(parser, packet.bytes.map { |byte| bytes(byte) }) + MeowTest.assert_empty(result[:data]) + MeowTest.assert_empty(result[:replies]) + MeowTest.assert_equal([[:subnegotiation, 77]], result[:events]) + end + + MeowTest.test("Telnet ignores unknown commands and resumes ordinary data") do + parser = Telnet::Parser.new + result = parser.feed("a".b + bytes(Telnet::IAC, 241) + "b".b) + MeowTest.assert_equal("ab".b, result.data) + MeowTest.assert_empty(result.replies) + MeowTest.assert_empty(result.events) + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..67c183f --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,54 @@ +module MeowTest + Failure = Class.new(StandardError) + + @tests = [] + + class << self + attr_reader :tests, :last_result + + def test(name, &block) + @tests << [name, block] + end + + def assert(value, message = "Expected value to be truthy") + raise Failure, message unless value + end + + def refute(value, message = "Expected value to be falsey") + raise Failure, message if value + end + + def assert_equal(expected, actual, message = nil) + return if expected == actual + + detail = "Expected #{expected.inspect}, got #{actual.inspect}" + raise Failure, [message, detail].compact.join(": ") + end + + def assert_empty(value, message = nil) + assert(value.empty?, message || "Expected #{value.inspect} to be empty") + end + + def run! + failures = [] + @tests.each do |name, test| + test.call + puts "PASS #{name}" + rescue StandardError => error + failures << [name, error] + puts "FAIL #{name}: #{error.class}: #{error.message}" + end + + @last_result = { + :tests => @tests.length, + :passed => @tests.length - failures.length, + :failures => failures.length, + :failure_details => failures.map { |name, error| "#{name}: #{error.class}: #{error.message}" } + } + puts "#{@last_result[:passed]}/#{@last_result[:tests]} tests passed" + raise Failure, @last_result[:failure_details].join("\n") unless failures.empty? + + @last_result + end + end +end diff --git a/test/text_pipeline_test.rb b/test/text_pipeline_test.rb new file mode 100644 index 0000000..1805055 --- /dev/null +++ b/test/text_pipeline_test.rb @@ -0,0 +1,77 @@ +# encoding: UTF-8 + +require_relative "test_helper" +require_relative "../lib/meow/telnet" +require_relative "../lib/meow/text_pipeline" + +module MeowTextPipelineTest + Telnet = MeowClient::Telnet + + def self.bytes(*values) + values.pack("C*") + end + + MeowTest.test("ANSI CSI and OSC sequences are filtered across chunks") do + pipeline = MeowClient::TextPipeline.new("UTF-8") + records = [] + records.concat(pipeline.feed("red\e[3".b).records) + records.concat(pipeline.feed("1m text\e]0;title\e".b).records) + records.concat(pipeline.feed("\\tail\r\n".b).records) + MeowTest.assert_equal(["red texttail"], records) + end + + MeowTest.test("Line framing handles CRLF CR-NUL bare CR and LF across chunks") do + pipeline = MeowClient::TextPipeline.new("UTF-8") + MeowTest.assert_equal(["one"], pipeline.feed("one\r".b).records) + MeowTest.assert_equal(["two"], pipeline.feed("\ntwo\r".b).records) + MeowTest.assert_equal(["three"], pipeline.feed("\0three\n".b).records) + MeowTest.assert_equal(["four"], pipeline.feed("four\rfive".b).records) + MeowTest.assert_equal(["five"], pipeline.finish) + end + + MeowTest.test("UTF-8 characters survive arbitrary byte boundaries") do + encoded = "Price: €\n".encode("UTF-8").b + pipeline = MeowClient::TextPipeline.new("UTF-8") + records = [] + encoded.each_byte { |byte| records.concat(pipeline.feed(bytes(byte)).records) } + MeowTest.assert_equal(["Price: €"], records) + end + + MeowTest.test("Invalid and incomplete UTF-8 are replaced safely") do + pipeline = MeowClient::TextPipeline.new("UTF-8") + invalid = pipeline.feed("bad\xC0\n".b) + MeowTest.assert_equal(["bad\uFFFD"], invalid.records) + + incomplete = MeowClient::TextPipeline.new("UTF-8") + incomplete.feed("tail \xE2\x82".b) + MeowTest.assert_equal(["tail \uFFFD"], incomplete.finish) + end + + MeowTest.test("Legacy encodings are converted to UTF-8") do + windows = MeowClient::TextPipeline.new("Windows-1252") + latin = MeowClient::TextPipeline.new("ISO-8859-1") + MeowTest.assert_equal(["€"], windows.feed("\x80\n".b).records) + MeowTest.assert_equal(["é"], latin.feed("\xE9\n".b).records) + end + + MeowTest.test("Prompts can be detected flushed and finished") do + pipeline = MeowClient::TextPipeline.new("UTF-8") + MeowTest.assert_empty(pipeline.feed("Password: ".b).records) + MeowTest.assert(pipeline.prompt_due?(0)) + MeowTest.assert_equal("Password: ", pipeline.flush_prompt) + MeowTest.refute(pipeline.prompt_due?(0)) + + pipeline.feed("unfinished".b) + MeowTest.assert_equal(["unfinished"], pipeline.finish) + end + + MeowTest.test("Text pipeline propagates Telnet replies and echo state") do + pipeline = MeowClient::TextPipeline.new("UTF-8") + packet = "hello".b + bytes(Telnet::IAC, Telnet::WILL, Telnet::ECHO) + " world\r\n".b + result = pipeline.feed(packet) + MeowTest.assert_equal(["hello world"], result.records) + MeowTest.assert_equal([bytes(Telnet::IAC, Telnet::DO, Telnet::ECHO)], result.replies) + MeowTest.assert_equal([[:remote_echo, true]], result.events) + MeowTest.assert(pipeline.remote_echo?) + end +end