mudlet/test/functional_tests/DialogTeardownTest.cpp
Vadim Peretokin c594d83c25
infrastructure: leak checking now gates the functional tests (#9694)
#### Brief overview of PR changes/additions

- 43 of 45 functional tests now run with `detect_leaks=1` and fail CI if
they leak (the suite leaked 11.83MB before this PR). The LSan
suppression hooks move into an OBJECT library so they actually bind into
the test binaries.
- Fixes the three production leaks behind nearly all of it: the
unparented `QSettings` (now application-parented, since the Updater
holds the pointer past window close), edbee re-initialisation orphaning
its managers on every repeat `mudlet::init()`, and the preferences
dialog's menu `QAction`s (`QMenu::addAction()` does not take ownership).
- New `EdbeeReinitTest` and a preferences-reopen test lock the fixes in.
Still excluded: `dlgTriggerEditorUndoRedoTest` (fixed in #9700) and
`UpdaterTeardownTest` (Qt's one-time CA-store load).

#### Motivation for adding to Mudlet

Leaks in code covered by the functional suite now fail the build instead
of accumulating silently, and two of the three fixes stop real leaks in
production.

#### Other info (issues closed, discussion etc)

Test case: full serial suite passes twice, 45/45; settings persistence
verified end-to-end under Xvfb. Startup unchanged (median 213ms vs
214ms); the suite's +62s is LSan's at-exit scan, no individual test
regressed.

Assisted-by: Claude:claude-fable-5
2026-08-07 06:12:54 +02:00

379 lines
17 KiB
C++

/***************************************************************************
* 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. *
***************************************************************************/
/*
* Functional tests for windows that are destroyed while one of their own
* editing widgets still has the keyboard focus (#9574).
*
* A visible window is taken off the screen while its base-class destructors
* unwind (~QDialog hides it, ~QWidget closes any other window class), which
* moves the focus off the widget that holds it; a QLineEdit (QAbstractSpinBox
* and QKeySequenceEdit behave the same) answers that by emitting
* editingFinished() into a slot of a window whose derived part has already
* been destroyed.
*
* A debug build ends the whole run there, on Qt's "Called object is not of the
* correct type (class destructor may have already run)". A release build has
* that assert compiled out and runs the slot against the destroyed object
* instead, so each test also checks that the edit in the focused field was not
* acted on - the same assertion holds whichever way the build was configured.
*
* Run with: ctest -R DialogTeardownTest -V
*/
#include <QtTest/QtTest>
#include <chrono>
#include <QAction>
#include <QKeySequenceEdit>
#include <QLineEdit>
#include <QScopeGuard>
#include "Host.h"
#include "MudletInstanceCoordinator.h"
#include "TelnetServerStub.h"
#include "TriggerUnit.h"
#include "dlgConnectionProfiles.h"
#include "dlgProfilePreferences.h"
#include "dlgTriggerEditor.h"
#include "mudlet.h"
#if defined(INCLUDE_UPDATER)
#include "updater.h"
#endif
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 initializeQRCResourcesForDialogTeardownTest();
class DialogTeardownTest : public QObject
{
Q_OBJECT
private:
TelnetServerStub* mpServer = nullptr;
Host* mpHost = nullptr;
const QString mProfileName = qsl("DialogTeardown-Test");
QString mPort; // assigned the stub's actual ephemeral port in initTestCase()
const QString mLocalhost = qsl("localhost");
void deleteProfileDirectory(const QString& profileName)
{
const QString path = mudlet::getMudletPath(enums::profileHomePath, profileName);
QDir dir(path);
if (dir.exists()) {
dir.removeRecursively();
}
}
void startProfile(const QString& profileName, const QString& address, const QString& port)
{
QTimer::singleShot(0ms, qApp, [profileName, address, port]() {
mudlet::self()->startAutoLogin({});
QTest::qWait(100ms);
QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton);
QTest::qWait(100ms);
QTest::keyClicks(QApplication::focusWidget(), profileName);
QTest::qWait(100ms);
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
QTest::qWait(100ms);
QTest::keyClicks(QApplication::focusWidget(), address);
QTest::qWait(100ms);
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
QTest::qWait(100ms);
QTest::keyClicks(QApplication::focusWidget(), port);
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.");
}
}
// Gives the widget the keyboard focus and something to report: a QLineEdit
// only emits editingFinished() on focus-out once its text has been touched,
// and the setText() here is what arms that - a field nothing has written to
// stays quiet and would make this test prove nothing.
void focusWithText(QLineEdit* lineEdit, const QString& text)
{
QVERIFY2(lineEdit->isVisible(), "Field has to be on screen to be able to take the focus");
lineEdit->setText(text);
lineEdit->setFocus();
QCoreApplication::processEvents();
QCOMPARE(QApplication::focusWidget(), lineEdit);
}
private slots:
void initTestCase()
{
initializeQRCResourcesForDialogTeardownTest();
mpServer = new TelnetServerStub(qApp);
mpServer->start(mLocalhost, 0); // ephemeral OS-assigned port avoids collisions across concurrent test runs
mPort = QString::number(mpServer->serverPort());
mudlet::start();
mudlet::self()->setupConfig();
mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>(qsl("MudletInstanceCoordinator")));
mudlet::self()->init();
mudlet::self()->setStorePasswordsSecurely(false);
deleteProfileDirectory(mProfileName);
startProfile(mProfileName, mLocalhost, mPort);
QVERIFY2(mpHost, "No active host after profile creation");
}
void cleanupTestCase()
{
mpHost = nullptr;
delete mpServer;
mpServer = nullptr;
deleteProfileDirectory(mProfileName);
delete mudlet::self();
}
// Everything below rests on Qt still emitting the focus-out signals while a
// window is being destroyed. If a future Qt stops doing that the other tests
// would keep passing while testing nothing, so pin the mechanism itself on
// widgets of our own - the receiver here outlives them, which is exactly what
// the windows under test cannot manage.
void test_teardownEmitsTheSignalsThisIsAllAbout()
{
auto* dialog = new QDialog(mudlet::self());
auto* layout = new QVBoxLayout(dialog);
auto* lineEdit = new QLineEdit(dialog);
layout->addWidget(lineEdit);
dialog->show();
lineEdit->setText(qsl("some text"));
lineEdit->setFocus();
QCoreApplication::processEvents();
QCOMPARE(QApplication::focusWidget(), lineEdit);
QSignalSpy lineEditSpy(lineEdit, &QLineEdit::editingFinished);
delete dialog;
QVERIFY2(lineEditSpy.count() == 1,
"A focused QLineEdit no longer reports editingFinished() when its "
"window is destroyed - the rest of this file now proves nothing");
// the same for the shortcut editors the preferences are full of
auto* keySequenceDialog = new QDialog(mudlet::self());
auto* keySequenceLayout = new QVBoxLayout(keySequenceDialog);
auto* secondKeySequenceEdit = new QKeySequenceEdit(keySequenceDialog);
keySequenceLayout->addWidget(secondKeySequenceEdit);
keySequenceDialog->show();
secondKeySequenceEdit->setKeySequence(QKeySequence(qsl("Ctrl+K")));
secondKeySequenceEdit->setFocus();
QCoreApplication::processEvents();
// it focus-proxies to an inner line edit, so ask the wrapper itself
QVERIFY2(secondKeySequenceEdit->hasFocus(), "Shortcut editor did not take the focus");
QSignalSpy secondSpy(secondKeySequenceEdit, &QKeySequenceEdit::editingFinished);
delete keySequenceDialog;
QVERIFY2(secondSpy.count() == 1,
"A focused QKeySequenceEdit no longer reports editingFinished() "
"when its window is destroyed");
}
// #9574: the reported crash - the profile name field is connected to
// slot_saveName() and the dialog is torn down while that field has the focus
void test_connectionDialogDestroyedWithFocusedNameField()
{
// built directly rather than through mudlet::slot_showConnectionDialog()
// so that the profile this test suite loaded does not have the dialog
// connect it straight back and close it
QPointer<dlgConnectionProfiles> dialog = new dlgConnectionProfiles(mudlet::self());
dialog->fillout_form();
dialog->show();
QTest::qWait(100ms);
QVERIFY2(dialog, "Connection dialog closed itself");
// pick our own profile, so that the name field is editing something whose
// renaming can be checked for afterwards
const auto items = dialog->findData(*dialog->listWidget_profiles, mProfileName, dlgConnectionProfiles::csmNameRole);
QVERIFY2(!items.isEmpty(), "Test profile is not listed in the dialog");
dialog->listWidget_profiles->setCurrentItem(items.first());
QTest::qWait(100ms);
const QString renamedTo = qsl("DialogTeardown-Renamed");
focusWithText(dialog->profile_name_entry, renamedTo);
delete dialog;
QVERIFY2(dialog.isNull(), "Connection dialog should have been destroyed");
// slot_saveName() renames the profile's directory, so it running on the way
// down leaves a trace even in a build where the assert is compiled out
QVERIFY2(!QDir(mudlet::getMudletPath(enums::profileHomePath, renamedTo)).exists(), "Being destroyed made the dialog rename the profile");
QVERIFY2(QDir(mudlet::getMudletPath(enums::profileHomePath, mProfileName)).exists(), "The profile lost its directory while the dialog was destroyed");
}
// The same exposure through the preferences' chat name field, which is
// connected to slot_mmcpChatNameChanged()
void test_preferencesDestroyedWithFocusedChatNameField()
{
mudlet::self()->showOptionsDialog(qsl("tab_chat"), mpHost);
QTest::qWait(100ms);
auto* preferences = mpHost->mpDlgProfilePreferences.data();
QVERIFY2(preferences, "Preferences dialog was not created");
const QString chatNameBefore = mpHost->getMMCPChatName();
const QString typedChatName = qsl("DialogTeardownChatName");
QVERIFY2(chatNameBefore != typedChatName, "Test needs to type a chat name that is not the current one");
focusWithText(preferences->lineEdit_mmcpChatName, typedChatName);
delete preferences;
QVERIFY2(mpHost->mpDlgProfilePreferences.isNull(), "Preferences dialog should have been destroyed");
QCOMPARE(mpHost->getMMCPChatName(), chatNameBefore);
}
// Opening the preferences at all used to be enough to end the run: the
// dialog asks the updater whether it downloads updates by itself, which on
// macOS reaches into Sparkle - and Sparkle is only created by
// checkUpdatesOnStart(), which no test calls. Development builds skip that
// whole branch, so only PTB and release builds ever crashed and CI stayed
// green until the nightly PTB. DEV_UPDATER puts this build on the same path.
void test_preferencesOpensBeforeTheUpdaterIsSetUp()
{
qputenv("DEV_UPDATER", "1");
auto restoreEnvironment = qScopeGuard([]() {
qunsetenv("DEV_UPDATER");
});
mudlet::self()->showOptionsDialog(qsl("tab_specialOptions"), mpHost);
QTest::qWait(100ms);
auto* preferences = mpHost->mpDlgProfilePreferences.data();
QVERIFY2(preferences, "Preferences dialog was not created");
#if defined(INCLUDE_UPDATER)
auto* updater = mudlet::self()->pUpdater;
QVERIFY2(updater, "An updater-enabled build has no updater");
// the dev-build branch disables the checkbox and touches no updater, so
// this is what says the test is on the crashing path at all
QVERIFY2(preferences->checkbox_noAutomaticUpdates->isEnabled(), "DEV_UPDATER no longer moves a development build onto the release update path - this test covers nothing now");
// isHidden() rather than isVisible(): the group box sits on a tab page,
// and only an explicit hide() should count here
QCOMPARE(preferences->groupBox_updates->isHidden(), !updater->ready());
if (!updater->ready()) {
// the accessors the dialog and the Help menu reach for have to be
// safe to call in this state, not merely avoidable
QVERIFY2(!updater->updateAutomatically(), "An updater with no platform updater claimed it auto-updates");
updater->setAutomaticUpdates(true);
updater->manuallyCheckUpdates();
QVERIFY2(!updater->updateAutomatically(), "An updater with no platform updater took a setting it cannot store");
}
#endif
delete preferences;
QVERIFY2(mpHost->mpDlgProfilePreferences.isNull(), "Preferences dialog should have been destroyed");
}
// ...and through the editor, where the item name field is connected to
// slot_saveProperty_TriggerName(). The editor is a QMainWindow rather than a
// QDialog, which makes no difference: it hides itself on the way down too
void test_triggerEditorDestroyedWithFocusedNameField()
{
mudlet::self()->slot_showScriptDialog();
QTest::qWait(100ms);
auto* editor = mpHost->mpEditorDialog.data();
QVERIFY2(editor, "Editor was not created");
// the item fields only appear once an item is being edited
editor->slot_showTriggers();
editor->slot_addNewItem();
QTest::qWait(100ms);
auto* nameField = editor->findChild<QLineEdit*>(qsl("lineEdit_trigger_name"));
QVERIFY2(nameField, "Trigger name field not found in the editor");
const QString nameBefore = nameField->text();
QVERIFY2(mpHost->getTriggerUnit()->findTrigger(nameBefore), "The new trigger is not registered under the name in the field");
const QString typedName = qsl("DialogTeardown trigger");
focusWithText(nameField, typedName);
delete editor;
QVERIFY2(mpHost->mpEditorDialog.isNull(), "Editor should have been destroyed");
// slot_saveProperty_TriggerName() renames the trigger itself, so the item
// shows whether it ran while the editor was being destroyed
QVERIFY2(!mpHost->getTriggerUnit()->findTrigger(typedName), "Being destroyed made the editor rename the trigger");
QVERIFY2(mpHost->getTriggerUnit()->findTrigger(nameBefore), "The trigger lost its name while the editor was destroyed");
}
void test_protocolActionsFireAfterPreferencesReopen()
{
mudlet::self()->showOptionsDialog(qsl("tab_general"), mpHost);
QTest::qWait(100ms);
auto* first = mpHost->mpDlgProfilePreferences.data();
QVERIFY2(first, "Preferences dialog was not created");
delete first;
QVERIFY2(mpHost->mpDlgProfilePreferences.isNull(), "Preferences dialog should have been destroyed");
mudlet::self()->showOptionsDialog(qsl("tab_general"), mpHost);
QTest::qWait(100ms);
auto* preferences = mpHost->mpDlgProfilePreferences.data();
QVERIFY2(preferences, "Preferences dialog was not recreated");
QAction* gmcpAction = nullptr;
for (auto* action : preferences->findChildren<QAction*>()) {
if (action->text().startsWith(qsl("GMCP"))) {
gmcpAction = action;
break;
}
}
QVERIFY2(gmcpAction, "GMCP protocol action not found under the reopened dialog - parenting to the menu broke discovery or population");
// initWithHost() wires GMCP's toggled() to this button's setEnabled(),
// so the button flipping proves the fresh action is connected
const bool enabledBefore = preferences->pushButton_forgetSavedSignIn->isEnabled();
QCOMPARE(enabledBefore, gmcpAction->isChecked());
gmcpAction->toggle();
QCOMPARE(preferences->pushButton_forgetSavedSignIn->isEnabled(), !enabledBefore);
gmcpAction->toggle();
QCOMPARE(preferences->pushButton_forgetSavedSignIn->isEnabled(), enabledBefore);
delete preferences;
}
};
void initializeQRCResourcesForDialogTeardownTest()
{
#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 "DialogTeardownTest.moc"
QTEST_MAIN(DialogTeardownTest)