Add Telnet regression test suite

This commit is contained in:
2026-09-17 13:33:45 +02:00
parent 5e6d9138c4
commit da967f4d56
6 changed files with 258 additions and 11 deletions
+19 -8
View File
@@ -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.
+1 -3
View File
@@ -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
+10
View File
@@ -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
+97
View File
@@ -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
+54
View File
@@ -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
+77
View File
@@ -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