mudlet/test/functional_tests/TelnetStringSequenceRecoveryTest.cpp

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

567 lines
28 KiB
C++
Raw Permalink Normal View History

fix: a stray escape code no longer silences your game for the rest of the session (#9765) #### Brief overview of PR changes/additions - A DCS/SOS/PM/APC/OSC sequence (`ESC` followed by `P`, `X`, `^`, `_` or `]`) now ends at the end of the line it started on if the game never sends a terminator, instead of discarding everything that follows it for the rest of the session. - Half of a sequence left over from a dead connection is dropped when the next one starts, and the 4096-byte cap no longer leaves the parser stuck with no way out. - Well-formed sequences are untouched: Sixel images and Kitty graphics are still swallowed whole, including ones larger than the cap or split across packets. Their idle-flush carriage returns no longer hide a String Terminator either. #### Motivation for adding to Mudlet One stray escape byte from a game could black out every line it sent afterwards - across line breaks, `clear()` and a full reconnect - with only a BEL able to restore output. #### Other info (issues closed, discussion etc) Fixes #9757. The `ESC ]` half of this is not a 5.0 regression, it behaves identically in 4.22.0, so the bound covers the whole `mGotOSC`/`mGotString` path rather than just the four introducers 5.0 added. Answering the open question in the issue: text lost to the blackout is missing from the log as well, because it never reaches the buffer the log is written from - `logFollowsTheDisplayThroughAnUnterminatedSequence` covers that. New `TelnetStringSequenceRecoveryTest` - 46 cases over a real TCP socket via `TelnetServerStub`. With the fix reverted, 24 of them fail. **Test case:** connect to any game and have it send `PRE<ESC>Ppayload` followed by a newline and a few more lines. Every line after it must still be displayed. Repeat with `X`, `^`, `_` and `]`.
2026-08-12 11:18:14 +02:00
/***************************************************************************
* Copyright (C) 2026 by Vadim Peretokin - vadim.peretokin@mudlet.org *
* *
* This program is free software; you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation; either version 2 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program; if not, write to the *
* Free Software Foundation, Inc., *
* 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *
***************************************************************************/
/*
* Tests that an unterminated ANSI string sequence cannot silence the game -
* https://github.com/Mudlet/Mudlet/issues/9757
*
* ESC followed by 'P', 'X', '^', '_' or ']' starts a DCS, SOS, PM, APC or OSC
* sequence, whose payload is consumed rather than displayed. A game that emits
* one of those introducers by accident must not blacken the output that
* follows it: the sequence ends at the first BEL, ESC \ or line ending, and a
* connection boundary drops it outright. Well-formed sequences must still be
* consumed in full, including ones that are larger than the buffering cap or
* split over several packets.
*
* Everything is driven over a real TCP socket via TelnetServerStub so that the
* bytes travel the same path as a game's output.
*
* Run with: ctest -R TelnetStringSequenceRecoveryTest -V
*/
#include <QRegularExpression>
#include <QtTest/QtTest>
#include <chrono>
#include "Host.h"
#include "MudletInstanceCoordinator.h"
#include "TMainConsole.h"
#include "TelnetServerStub.h"
#include "ctelnet.h"
#include "dlgConnectionProfiles.h"
#include "mudlet.h"
using namespace std::chrono_literals;
extern void qInitResources_mudlet();
extern void qInitResources_qm();
extern void qInitResources_additional_splash_screens();
extern void qInitResources_mudlet_fonts_common();
extern void qInitResources_mudlet_fonts_posix();
void initializeQRCResourcesForStringSequenceRecoveryTest();
namespace {
const QByteArray csBel = QByteArrayLiteral("\x07");
const QByteArray csStringTerminator = QByteArrayLiteral("\x1b") + QByteArrayLiteral("\\");
const QByteArray csCrLf = QByteArrayLiteral("\r\n");
// Distinctive enough that finding it anywhere in the buffer means a payload
// that should have been consumed was displayed instead:
const QByteArray csPayload = QByteArrayLiteral("0;SEQPAYLOAD");
} // namespace
class TelnetStringSequenceRecoveryTest : public QObject
{
Q_OBJECT
private:
TelnetServerStub* mpServer = nullptr;
Host* mpHost = nullptr;
const QString mHostname = qsl("String-Sequence-Recovery-Test-Host");
QString mPort; // assigned the stub's actual ephemeral port in initTestCase()
const QString mLocalhost = qsl("localhost");
// Every introducer that starts a sequence whose payload is consumed
// instead of displayed.
static void addIntroducerColumn()
{
QTest::addColumn<QByteArray>("introducer");
QTest::newRow("DCS (ESC P)") << QByteArrayLiteral("\x1b") + QByteArrayLiteral("P");
QTest::newRow("SOS (ESC X)") << QByteArrayLiteral("\x1b") + QByteArrayLiteral("X");
QTest::newRow("PM (ESC ^)") << QByteArrayLiteral("\x1b") + QByteArrayLiteral("^");
QTest::newRow("APC (ESC _)") << QByteArrayLiteral("\x1b") + QByteArrayLiteral("_");
QTest::newRow("OSC (ESC ])") << QByteArrayLiteral("\x1b") + QByteArrayLiteral("]");
}
void sendFromServer(const QByteArray& data) { mpServer->sendRaw(data); }
// Sends data that does not end a line and waits out cTelnet's 300ms idle
// flush, so the parser has seen every byte of it - including the carriage
// return that flush puts through the parser - by the time this returns.
void sendFromServerAndSettle(const QByteArray& data)
{
mpServer->sendRaw(data);
QTest::qWait(500ms);
}
QString joinedBuffer() const
{
QStringList lines;
for (int i = 0; i <= mpHost->mpConsole->buffer.getLastLineNumber(); ++i) {
lines << mpHost->mpConsole->buffer.line(i);
}
return lines.join(QChar::LineFeed);
}
bool waitForBufferText(const QString& needle, int timeoutMs = 5000)
{
return QTest::qWaitFor(
[this, &needle]() {
return joinedBuffer().contains(needle);
},
timeoutMs);
}
// Several times MAX_OSC_SEQUENCE_LENGTH, and position-dependent so that a
// failure says where the payload started leaking.
static QByteArray oversizedPayload()
{
QByteArray payload;
for (int i = 0; payload.size() < 20000; ++i) {
payload += QByteArrayLiteral("payload-") + QByteArray::number(i).rightJustified(4, '0') + QByteArrayLiteral(" ");
}
return payload;
}
// Takes the profile offline and back on with the stub greeting the new
// connection with welcomeMessage, leaving an empty display behind so that
// greeting is the first thing in it.
bool reconnectWithWelcomeMessage(const QString& welcomeMessage)
{
mpServer->setWelcomeMessage(welcomeMessage);
mpHost->mTelnet.disconnectIt();
const bool wentOffline = QTest::qWaitFor(
[this]() {
return mpHost->mTelnet.getConnectionState() == QAbstractSocket::UnconnectedState;
},
5000);
mpHost->mpConsole->buffer.clear();
QSignalSpy connectedSpy(&(mpHost->mTelnet), &cTelnet::signal_connected);
mpHost->mTelnet.connectIt(mLocalhost, mPort.toInt());
const bool reconnected = connectedSpy.wait(5000);
const bool arrived = waitForBufferText(welcomeMessage);
mpServer->setWelcomeMessage(QString());
return wentOffline && reconnected && arrived;
}
bool bufferHasLine(const QString& text) const
{
for (int i = 0; i <= mpHost->mpConsole->buffer.getLastLineNumber(); ++i) {
if (mpHost->mpConsole->buffer.line(i) == text) {
return true;
}
}
return false;
}
private slots:
// Start mudlet and create a profile once for all tests.
void initTestCase()
{
initializeQRCResourcesForStringSequenceRecoveryTest();
mpServer = new TelnetServerStub(qApp);
mpServer->start(mLocalhost, 0); // ephemeral OS-assigned port avoids collisions across concurrent test runs
QVERIFY2(mpServer->isListening(), "TelnetServerStub failed to bind a loopback port");
mPort = QString::number(mpServer->serverPort());
mudlet::start();
mudlet::self()->setupConfig();
mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator"));
mudlet::self()->init();
mudlet::self()->setStorePasswordsSecurely(false);
const QString path = mudlet::getMudletPath(enums::profileHomePath, mHostname);
QDir(path).removeRecursively();
QTimer::singleShot(0ms, qApp, [this]() {
mudlet::self()->startAutoLogin({});
QTest::qWait(100ms);
QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton);
QTest::qWait(100ms);
QTest::keyClicks(QApplication::focusWidget(), mHostname);
QTest::qWait(100ms);
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
QTest::qWait(100ms);
QTest::keyClicks(QApplication::focusWidget(), mLocalhost);
QTest::qWait(100ms);
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
QTest::qWait(100ms);
QTest::keyClicks(QApplication::focusWidget(), mPort);
QTest::qWait(100ms);
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return);
});
QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded);
if (!spy.wait(5000)) {
QFAIL("Profile took too long to load.");
}
mpHost = mudlet::self()->getActiveHost();
if (!mpHost) {
QFAIL("No active host available for the test.");
}
QSignalSpy spy2(&(mpHost->mTelnet), &cTelnet::signal_connected);
if (!spy2.wait(2000)) {
QFAIL("Could not connect with the host.");
}
}
// Each test starts from an empty display and a parser that is not part way
// through a sequence, so a failure cannot cascade into the tests after it.
void init()
{
QVERIFY(mpHost);
QVERIFY(mpHost->mpConsole);
mpHost->mpConsole->buffer.clear();
mpHost->mpConsole->buffer.resetSequenceParserState();
}
// A BEL ends the sequence: the payload is consumed and the text around it
// is displayed.
void recoveryByBel_data() { addIntroducerColumn(); }
void recoveryByBel()
{
QFETCH(QByteArray, introducer);
sendFromServer(QByteArrayLiteral("PRE") + introducer + csPayload + csBel + QByteArrayLiteral("POST") + csCrLf);
QVERIFY2(waitForBufferText(qsl("PREPOST")), qPrintable(qsl("Text around the sequence went missing, buffer holds: '%1'").arg(joinedBuffer())));
QVERIFY2(!joinedBuffer().contains(qsl("SEQPAYLOAD")), qPrintable(qsl("Sequence payload was displayed: '%1'").arg(joinedBuffer())));
}
// ESC \ (the String Terminator) ends the sequence just as a BEL does.
void recoveryByStringTerminator_data() { addIntroducerColumn(); }
void recoveryByStringTerminator()
{
QFETCH(QByteArray, introducer);
sendFromServer(QByteArrayLiteral("PRE") + introducer + csPayload + csStringTerminator + QByteArrayLiteral("POST") + csCrLf);
QVERIFY2(waitForBufferText(qsl("PREPOST")), qPrintable(qsl("Text around the sequence went missing, buffer holds: '%1'").arg(joinedBuffer())));
QVERIFY2(!joinedBuffer().contains(qsl("SEQPAYLOAD")), qPrintable(qsl("Sequence payload was displayed: '%1'").arg(joinedBuffer())));
}
// The reported bug: with no terminator at all the sequence must end with
// the line it started on, so the lines after it are still displayed.
void recoveryByEndOfLine_data() { addIntroducerColumn(); }
void recoveryByEndOfLine()
{
QFETCH(QByteArray, introducer);
sendFromServer(QByteArrayLiteral("PRE") + introducer + csPayload + csCrLf + QByteArrayLiteral("RESUMED-ONE") + csCrLf + QByteArrayLiteral("RESUMED-TWO") + csCrLf
+ QByteArrayLiteral("RESUMED-THREE") + csCrLf);
QVERIFY2(waitForBufferText(qsl("RESUMED-THREE")), qPrintable(qsl("Output stayed dark after an unterminated sequence, buffer holds: '%1'").arg(joinedBuffer())));
const QString displayed = joinedBuffer();
QVERIFY2(displayed.contains(qsl("RESUMED-ONE")), qPrintable(qsl("First line after the sequence went missing: '%1'").arg(displayed)));
QVERIFY2(displayed.contains(qsl("RESUMED-TWO")), qPrintable(qsl("Second line after the sequence went missing: '%1'").arg(displayed)));
QVERIFY2(bufferHasLine(qsl("PRE")), qPrintable(qsl("Text before the introducer went missing: '%1'").arg(displayed)));
QVERIFY2(!displayed.contains(qsl("SEQPAYLOAD")), qPrintable(qsl("Sequence payload was displayed: '%1'").arg(displayed)));
}
// A game that keeps talking after an unterminated sequence has every line
// of it displayed, not just the first.
void volumeAfterUnterminatedSequence_data() { addIntroducerColumn(); }
void volumeAfterUnterminatedSequence()
{
QFETCH(QByteArray, introducer);
QByteArray data = QByteArrayLiteral("The chest is locked.") + introducer + csPayload + csCrLf;
for (int i = 0; i < 50; ++i) {
data += QByteArrayLiteral("bulk line ") + QByteArray::number(i) + csCrLf;
}
sendFromServer(data);
QVERIFY2(waitForBufferText(qsl("bulk line 49")), qPrintable(qsl("Bulk output after an unterminated sequence was lost, buffer holds: '%1'").arg(joinedBuffer())));
for (int i = 0; i < 50; ++i) {
QVERIFY2(bufferHasLine(qsl("bulk line %1").arg(i)), qPrintable(qsl("Line %1 was lost, buffer holds: '%2'").arg(QString::number(i), joinedBuffer())));
}
}
// A sequence still waiting for its terminator when the display is cleared
// must not blacken the output that follows the clear.
void recoveryAfterClear_data() { addIntroducerColumn(); }
void recoveryAfterClear()
{
QFETCH(QByteArray, introducer);
// No line ending, so the parser is legitimately still inside the
// sequence when the display is cleared:
sendFromServerAndSettle(QByteArrayLiteral("BEFORE-CLEAR") + introducer + csPayload);
mpHost->mpConsole->buffer.clear();
// clear() deliberately leaves the parser alone, so the sequence runs
// on to the next line ending the game sends - which costs the first of
// these lines, unless the game happened to end a line in between:
sendFromServer(QByteArrayLiteral("AFTER-CLEAR-ONE") + csCrLf + QByteArrayLiteral("AFTER-CLEAR-TWO") + csCrLf);
QVERIFY2(waitForBufferText(qsl("AFTER-CLEAR-TWO")), qPrintable(qsl("Output stayed dark after clear(), buffer holds: '%1'").arg(joinedBuffer())));
}
// Bytes that a dead connection never terminated cannot be completed by the
// next one, so the very first line of a new session is displayed.
void recoveryAfterReconnect_data() { addIntroducerColumn(); }
void recoveryAfterReconnect()
{
QFETCH(QByteArray, introducer);
sendFromServerAndSettle(QByteArrayLiteral("BEFORE-RECONNECT") + introducer + csPayload);
QVERIFY2(reconnectWithWelcomeMessage(qsl("NEW-SESSION-FIRST-LINE")),
qPrintable(qsl("The new connection's first line was eaten by the old one's sequence, buffer holds: '%1'").arg(joinedBuffer())));
}
// The other parser latches are dropped at the same boundary: half of a CSI
// would otherwise swallow the head of the new session's first line.
void halfReceivedCsiDoesNotSurviveReconnect()
{
sendFromServerAndSettle(QByteArrayLiteral("BEFORE-RECONNECT") + QByteArrayLiteral("\x1b") + QByteArrayLiteral("[3"));
// A carried-over "CSI 3" would swallow the 'C' as its final byte:
QVERIFY2(reconnectWithWelcomeMessage(qsl("CSI-RESET-CHECK")), qPrintable(qsl("A half-received CSI ate the head of the new session's first line, buffer holds: '%1'").arg(joinedBuffer())));
}
// A Sixel image is a well-formed DCS: its payload must be consumed in full
// rather than spilled onto the screen.
void wellFormedSixelIsConsumed()
{
sendFromServer(QByteArrayLiteral("BEFORE-SIXEL") + QByteArrayLiteral("\x1b") + QByteArrayLiteral("P0;1;0q\"1;1;6;6#0;2;0;0;0#0!6~-") + csStringTerminator + QByteArrayLiteral("AFTER-SIXEL")
+ csCrLf);
QVERIFY2(waitForBufferText(qsl("BEFORE-SIXELAFTER-SIXEL")), qPrintable(qsl("Text around the Sixel image went missing, buffer holds: '%1'").arg(joinedBuffer())));
QVERIFY2(!joinedBuffer().contains(qsl("#0;2;0;0;0")), qPrintable(qsl("Sixel payload was displayed: '%1'").arg(joinedBuffer())));
}
// The Kitty graphics protocol's APC payloads are consumed the same way.
void wellFormedKittyGraphicIsConsumed()
{
sendFromServer(QByteArrayLiteral("BEFORE-KITTY") + QByteArrayLiteral("\x1b") + QByteArrayLiteral("_Ga=T,f=100,s=1,v=1;iVBORw0KGgoAAAANSUhEUg==") + csStringTerminator
+ QByteArrayLiteral("AFTER-KITTY") + csCrLf);
QVERIFY2(waitForBufferText(qsl("BEFORE-KITTYAFTER-KITTY")), qPrintable(qsl("Text around the Kitty graphic went missing, buffer holds: '%1'").arg(joinedBuffer())));
QVERIFY2(!joinedBuffer().contains(qsl("iVBORw0KGgo")), qPrintable(qsl("Kitty payload was displayed: '%1'").arg(joinedBuffer())));
}
// Real images run to far more than the buffering cap, so a well-formed
// sequence that is longer than it still has to be consumed in full.
void wellFormedSequenceLargerThanTheCapIsConsumed()
{
sendFromServer(QByteArrayLiteral("BEFORE-HUGE") + QByteArrayLiteral("\x1b") + QByteArrayLiteral("P") + oversizedPayload() + csStringTerminator + QByteArrayLiteral("AFTER-HUGE") + csCrLf);
QVERIFY2(waitForBufferText(qsl("AFTER-HUGE")), qPrintable(qsl("Text after an oversized sequence went missing, buffer holds: '%1'").arg(joinedBuffer())));
QVERIFY2(joinedBuffer().contains(qsl("BEFORE-HUGE")), qPrintable(qsl("Text before an oversized sequence went missing, buffer holds: '%1'").arg(joinedBuffer())));
QVERIFY2(!joinedBuffer().contains(qsl("payload-0000")), qPrintable(qsl("Payload of the oversized sequence was displayed: '%1'").arg(joinedBuffer())));
QVERIFY2(!joinedBuffer().contains(qsl("payload-1000")), qPrintable(qsl("Payload of the oversized sequence was displayed past the cap: '%1'").arg(joinedBuffer())));
}
// An OSC is the one string sequence whose payload Mudlet acts on, so one
// that outgrows the cap loses its command - which has to be reported, not
// swallowed, and must not take the rest of the line with it.
void oversizedOscIsReportedAndItsCommandIgnored()
{
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(qsl("an OSC sequence passed 4096 bytes, so its command will be ignored")));
sendFromServer(QByteArrayLiteral("\x1b") + QByteArrayLiteral("]") + oversizedPayload() + csBel + QByteArrayLiteral("AFTER-OVERSIZED-OSC") + csCrLf);
QVERIFY2(waitForBufferText(qsl("AFTER-OVERSIZED-OSC")), qPrintable(qsl("Text after an oversized OSC went missing, buffer holds: '%1'").arg(joinedBuffer())));
QVERIFY2(!joinedBuffer().contains(qsl("payload-1000")), qPrintable(qsl("Payload of the oversized OSC was displayed: '%1'").arg(joinedBuffer())));
}
// Giving up on one sequence must not leave the parser refusing to decode
// every OSC that follows it.
void oscStillDecodesAfterAnUnterminatedSequence()
{
const bool savedMayRedefine = mpHost->getMayRedefineColors();
const QColor savedRed = mpHost->mRed;
mpHost->setMayRedefineColors(true);
sendFromServer(QByteArrayLiteral("BEFORE-ABORT") + QByteArrayLiteral("\x1b") + QByteArrayLiteral("]") + csPayload + csCrLf);
const bool aborted = waitForBufferText(qsl("BEFORE-ABORT"));
sendFromServer(QByteArrayLiteral("\x1b") + QByteArrayLiteral("]P1223344") + csBel + QByteArrayLiteral("OSC-AFTER-ABORT") + csCrLf);
const bool arrived = waitForBufferText(qsl("OSC-AFTER-ABORT"));
const QColor redAfter = mpHost->mRed;
mpHost->mRed = savedRed;
mpHost->setMayRedefineColors(savedMayRedefine);
QVERIFY2(aborted, "The line carrying the unterminated sequence never arrived");
QVERIFY2(arrived, qPrintable(qsl("Text after the second OSC went missing, buffer holds: '%1'").arg(joinedBuffer())));
QVERIFY2(redAfter == QColor(0x22, 0x33, 0x44), qPrintable(qsl("An OSC sent after an abandoned sequence was consumed but not decoded, red is %1").arg(redAfter.name())));
}
// Bounding the sequence at the end of a line must not break a well-formed
// one that arrives in several packets - including one that pauses for
// longer than cTelnet's 300ms idle flush, which puts a carriage return
// through the parser part way through the payload.
void wellFormedSequenceSplitAcrossReads()
{
sendFromServerAndSettle(QByteArrayLiteral("PRE-SPLIT") + QByteArrayLiteral("\x1b") + QByteArrayLiteral("P") + QByteArrayLiteral("SEQ"));
sendFromServer(QByteArrayLiteral("PAYLOAD") + csStringTerminator + QByteArrayLiteral("POST-SPLIT") + csCrLf);
QVERIFY2(waitForBufferText(qsl("PRE-SPLITPOST-SPLIT")), qPrintable(qsl("A sequence split across two reads lost the text around it: '%1'").arg(joinedBuffer())));
QVERIFY2(!joinedBuffer().contains(qsl("SEQ")), qPrintable(qsl("Payload of the split sequence was displayed: '%1'").arg(joinedBuffer())));
}
// ...and an unterminated one that is split still ends at the line ending
// that arrives in the later packet.
void unterminatedSequenceSplitAcrossReads()
{
sendFromServerAndSettle(QByteArrayLiteral("PRE-SPLIT-BOUND") + QByteArrayLiteral("\x1b") + QByteArrayLiteral("X") + csPayload);
sendFromServer(csCrLf + QByteArrayLiteral("RESUMED-AFTER-SPLIT") + csCrLf);
QVERIFY2(waitForBufferText(qsl("RESUMED-AFTER-SPLIT")), qPrintable(qsl("Output stayed dark after a split unterminated sequence: '%1'").arg(joinedBuffer())));
}
// The worst place for the idle flush's carriage return to land is between
// the ESC and the backslash of a String Terminator: taken as payload it
// would hide the terminator and cost the rest of that line.
void stringTerminatorSplitByTheIdleFlush()
{
sendFromServerAndSettle(QByteArrayLiteral("PRE-ST-SPLIT") + QByteArrayLiteral("\x1b") + QByteArrayLiteral("P") + QByteArrayLiteral("PAYLOAD") + QByteArrayLiteral("\x1b"));
sendFromServer(QByteArrayLiteral("\\") + QByteArrayLiteral("AFTER-ST-SPLIT") + csCrLf);
QVERIFY2(waitForBufferText(qsl("AFTER-ST-SPLIT")), qPrintable(qsl("The String Terminator was missed and the rest of the line was discarded: '%1'").arg(joinedBuffer())));
QVERIFY2(!joinedBuffer().contains(qsl("PAYLOAD")), qPrintable(qsl("Payload of the split sequence was displayed: '%1'").arg(joinedBuffer())));
}
// The line endings that are not a line feed: a prompt marker (the game's
// IAC GA, or an escaped 0xff byte) and an end of transmission.
void recoveryByOtherLineEndings_data()
{
QTest::addColumn<QByteArray>("lineEnding");
QTest::newRow("end of transmission (0x04)") << QByteArrayLiteral("\x04");
// Doubled so that the telnet layer delivers one literal 0xff byte
// rather than reading it as the start of a command:
QTest::newRow("prompt marker (0xff)") << QByteArrayLiteral("\xff\xff");
}
void recoveryByOtherLineEndings()
{
QFETCH(QByteArray, lineEnding);
sendFromServer(QByteArrayLiteral("\x1b") + QByteArrayLiteral("P") + csPayload + lineEnding + QByteArrayLiteral("RESUMED-AFTER-ENDING") + csCrLf);
QVERIFY2(waitForBufferText(qsl("RESUMED-AFTER-ENDING")), qPrintable(qsl("Output stayed dark after the line ending, buffer holds: '%1'").arg(joinedBuffer())));
QVERIFY2(!joinedBuffer().contains(qsl("SEQPAYLOAD")), qPrintable(qsl("Sequence payload was displayed: '%1'").arg(joinedBuffer())));
}
// Locally generated text (feedTriggers(), MMCP chat, MXP insertions) runs
// through the same parser but keeps its own copy of the latches, so it
// needs the same bound.
void unterminatedSequenceInALocalFeedIsBounded()
{
std::string stuck{"BEFORE-LOCAL\x1bP0;SEQPAYLOAD"};
mpHost->mpConsole->printOnDisplay(stuck, false);
// As on the Game Server channel, the line ending of this one is what
// ends the sequence, so this feed is the one that is lost:
std::string eaten{"EATEN-LOCAL\n"};
mpHost->mpConsole->printOnDisplay(eaten, false);
std::string resumed{"VISIBLE-LOCAL\n"};
mpHost->mpConsole->printOnDisplay(resumed, false);
QVERIFY2(waitForBufferText(qsl("VISIBLE-LOCAL")), qPrintable(qsl("Locally fed text stayed dark after an unterminated sequence: '%1'").arg(joinedBuffer())));
QVERIFY2(!joinedBuffer().contains(qsl("SEQPAYLOAD")), qPrintable(qsl("Sequence payload was displayed: '%1'").arg(joinedBuffer())));
}
// Bounding the sequence must not stop a complete OSC from being acted on.
void wellFormedOscIsStillDecoded()
{
const bool savedMayRedefine = mpHost->getMayRedefineColors();
const QColor savedRed = mpHost->mRed;
mpHost->setMayRedefineColors(true);
sendFromServer(QByteArrayLiteral("\x1b") + QByteArrayLiteral("]P1223344") + csBel + QByteArrayLiteral("OSC-DECODED") + csCrLf);
const bool arrived = waitForBufferText(qsl("OSC-DECODED"));
const QColor redAfter = mpHost->mRed;
mpHost->mRed = savedRed;
mpHost->setMayRedefineColors(savedMayRedefine);
QVERIFY2(arrived, qPrintable(qsl("Text after the OSC went missing, buffer holds: '%1'").arg(joinedBuffer())));
QVERIFY2(redAfter == QColor(0x22, 0x33, 0x44), qPrintable(qsl("The OSC colour redefinition was not decoded, red is %1").arg(redAfter.name())));
}
// The open question from the bug report: text the parser discards never
// reaches the buffer, so it is missing from the log as well - but the
// output that resumes after the sequence is logged as usual.
void logFollowsTheDisplayThroughAnUnterminatedSequence()
{
const QString savedLogDir = mpHost->mLogDir;
const QString savedLogFileName = mpHost->mLogFileName;
const QString savedLogFileNameFormat = mpHost->mLogFileNameFormat;
const bool savedHtmlFormat = mpHost->mIsNextLogFileInHtmlFormat;
auto restoreLoggingSettings = qScopeGuard([&]() {
mpHost->mLogDir = savedLogDir;
mpHost->mLogFileName = savedLogFileName;
mpHost->mLogFileNameFormat = savedLogFileNameFormat;
mpHost->mIsNextLogFileInHtmlFormat = savedHtmlFormat;
});
mpHost->mLogDir.clear();
mpHost->mLogFileNameFormat.clear();
mpHost->mLogFileName = qsl("string-sequence-recovery-test");
mpHost->mIsNextLogFileInHtmlFormat = false;
mpHost->mpConsole->toggleLogging(false);
QVERIFY(mpHost->mpConsole->mLogToLogFile);
const QString logFileName = mpHost->mpConsole->mLogFileName;
sendFromServer(QByteArrayLiteral("PRE-LOG") + QByteArrayLiteral("\x1b") + QByteArrayLiteral("P") + csPayload + csCrLf + QByteArrayLiteral("LOGGED-AFTER-BLACKOUT") + csCrLf
+ QByteArrayLiteral("LOGGED-LAST-LINE") + csCrLf);
QVERIFY2(waitForBufferText(qsl("LOGGED-LAST-LINE")), qPrintable(qsl("Output stayed dark, buffer holds: '%1'").arg(joinedBuffer())));
mpHost->mpConsole->toggleLogging(false);
QFile logFile(logFileName);
QVERIFY2(logFile.open(QIODevice::ReadOnly | QIODevice::Text), qPrintable(qsl("Could not read back the log at %1").arg(logFileName)));
const QString log = QString::fromUtf8(logFile.readAll());
logFile.close();
logFile.remove();
QVERIFY2(log.contains(qsl("PRE-LOG")), qPrintable(qsl("Text before the introducer is missing from the log: '%1'").arg(log)));
QVERIFY2(log.contains(qsl("LOGGED-AFTER-BLACKOUT")), qPrintable(qsl("The line after the unterminated sequence is missing from the log: '%1'").arg(log)));
QVERIFY2(log.contains(qsl("LOGGED-LAST-LINE")), qPrintable(qsl("The last line is missing from the log: '%1'").arg(log)));
QVERIFY2(!log.contains(qsl("SEQPAYLOAD")), qPrintable(qsl("Sequence payload reached the log: '%1'").arg(log)));
}
void cleanupTestCase()
{
delete mpServer;
mpServer = nullptr;
mpHost = nullptr;
const QString path = mudlet::getMudletPath(enums::profileHomePath, mHostname);
QDir(path).removeRecursively();
delete mudlet::self();
}
};
void initializeQRCResourcesForStringSequenceRecoveryTest()
{
#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN
qInitResources_additional_splash_screens();
#endif
#ifdef INCLUDE_FONTS
qInitResources_mudlet_fonts_common();
#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD)
qInitResources_mudlet_fonts_posix();
#endif
#endif
qInitResources_mudlet();
qInitResources_qm();
}
#include "TelnetStringSequenceRecoveryTest.moc"
QTEST_MAIN(TelnetStringSequenceRecoveryTest)