// SPDX-FileCopyrightText: 2026 Reion Wong // SPDX-License-Identifier: GPL-3.0-or-later #include #include #include #include #include #include "base/event_loop.h" #include "base/file.h" #include "base/timer.h" #include "ui/application.h" #include "ui/glyph_run.h" #include "ui/keys.h" #include "ui/painter.h" #include "ui/test_input.h" #include "ui/view.h" #include "ui/window.h" #include "TerminalFont.h" #include "TerminalSession.h" #include "TerminalView.h" #include "Vt102Emulation.h" #include "tools.h" #include "terminalpage.h" #include "terminalsettings.h" #include "terminalwindow.h" #include "windows.h" using namespace cutefish; // Runs the loop until the condition holds, for at most five seconds. static bool waitFor(const std::function& condition) { base::EventLoop& loop = *base::EventLoop::current(); const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); while (!condition()) { if (std::chrono::steady_clock::now() > deadline) return false; base::Timer tick; tick.setSingleShot(true); tick.onTimeout = [] {}; tick.start(std::chrono::milliseconds(10)); loop.dispatch(); } return true; } class WindowTest : public testing::Test { protected: void SetUp() override { set_kb_layout_dir(KB_LAYOUTS_DIR); ASSERT_TRUE(m_directory.isValid()); } TerminalWindow::Options shell() const { TerminalWindow::Options options; options.startDirectory = m_directory.path(); options.command = {"/bin/sh"}; options.restoresSize = false; return options; } base::file::TemporaryDirectory m_directory{"/tmp", "terminal-window-test"}; TerminalSettings m_settings{m_directory.path() + "/terminal.conf"}; }; TEST_F(WindowTest, TabLeavingAWindowIsNoLongerHeard) { Windows windows(m_settings); TerminalWindow* first = windows.create(shell()); TerminalWindow* second = windows.create(shell()); first->openTab(m_directory.path(), {"/bin/sh"}); ASSERT_EQ(first->tabCount(), 2); std::unique_ptr page = first->takeTab(1); ASSERT_TRUE(page); EXPECT_TRUE(page->titleChanged.isEmpty()); EXPECT_TRUE(page->terminalClosed.isEmpty()); TerminalPage* moved = page.get(); second->insertTab(std::move(page), 1); EXPECT_FALSE(moved->titleChanged.isEmpty()); EXPECT_FALSE(moved->terminalClosed.isEmpty()); EXPECT_EQ(second->tabCount(), 2); } TEST_F(WindowTest, ManyTabsOpenAndClose) { Windows windows(m_settings); TerminalWindow* window = windows.create(shell()); for (int i = 0; i < 50; ++i) { window->openTab(m_directory.path(), {"/bin/sh"}); window->closeTab(1); } EXPECT_EQ(window->tabCount(), 1); } TEST_F(WindowTest, ShellExitClosesItsTab) { Windows windows(m_settings); TerminalWindow* window = windows.create(shell()); window->openTab(m_directory.path(), {"/bin/sh", "-c", "exit 0"}); EXPECT_TRUE(waitFor([&] { return window->tabCount() == 1; })); } // The confirmation the window shows last, as a key press answers it. static void confirmLastDialog(TerminalWindow* window) { ui::Window* dialog = ui::Application::instance()->windows().back(); ASSERT_NE(dialog, &window->window()); dialog->grab(); ui::TestInput::setKeyboardFocus(*dialog, true); ui::TestInput::typeKey(*dialog, ui::key::Return); } TEST_F(WindowTest, CloseOtherTabsKeepsTheChosenTab) { Windows windows(m_settings); TerminalWindow* window = windows.create(shell()); TerminalPage* busy = window->currentPage(); window->openTab(m_directory.path(), {"/bin/sh"}); ASSERT_EQ(window->tabCount(), 2); busy->session().sendText("sleep 30\n"); ASSERT_TRUE(waitFor([&] { return busy->session().hasActiveProcess(); })); window->closeOtherTabs(1); confirmLastDialog(window); ASSERT_EQ(window->tabCount(), 1); ASSERT_NE(window->currentPage(), nullptr); EXPECT_TRUE(window->currentPage()->isVisible()); } TEST_F(WindowTest, CloseOtherTabsAfterTheKeptTabEnded) { Windows windows(m_settings); TerminalWindow* window = windows.create(shell()); TerminalPage* busy = window->currentPage(); window->openTab(m_directory.path(), {"/bin/sh", "-c", "sleep 1"}); ASSERT_EQ(window->tabCount(), 2); busy->session().sendText("sleep 30\n"); ASSERT_TRUE(waitFor([&] { return busy->session().hasActiveProcess(); })); window->closeOtherTabs(1); ASSERT_TRUE(waitFor([&] { return window->tabCount() == 1; })); confirmLastDialog(window); ASSERT_EQ(window->tabCount(), 1); EXPECT_EQ(window->currentPage(), busy); EXPECT_TRUE(busy->isVisible()); } TEST_F(WindowTest, CursorBlinksOnlyWithTheKeyboard) { Windows windows(m_settings); TerminalWindow* window = windows.create(shell()); ui::Window& surface = window->window(); Konsole::TerminalView* terminal = window->currentPage()->terminal(); ASSERT_TRUE(terminal->hasFocus()); ui::TestInput::setKeyboardFocus(surface, true); surface.grab(); EXPECT_TRUE(terminal->isCursorBlinking()); ui::TestInput::setKeyboardFocus(surface, false); surface.grab(); EXPECT_FALSE(terminal->isCursorBlinking()); ui::TestInput::setKeyboardFocus(surface, true); surface.grab(); EXPECT_TRUE(terminal->isCursorBlinking()); // Without a frame drawn, the next blink stops the timer. ui::TestInput::setKeyboardFocus(surface, false); EXPECT_TRUE(waitFor([&] { return !terminal->isCursorBlinking(); })); } TEST_F(WindowTest, CursorWithoutBlinkingNeverStartsTheTimer) { m_settings.setBlinkingCursor(false); Windows windows(m_settings); TerminalWindow* window = windows.create(shell()); ui::Window& surface = window->window(); Konsole::TerminalView* terminal = window->currentPage()->terminal(); ui::TestInput::setKeyboardFocus(surface, true); surface.grab(); EXPECT_FALSE(terminal->isCursorBlinking()); ui::TestInput::setKeyboardFocus(surface, false); surface.grab(); EXPECT_FALSE(terminal->isCursorBlinking()); } // A tab whose program takes the keyboard raw, asks for bracketed paste and // writes what it receives to `path`. static TerminalWindow::Options recorder(const std::string& directory, const std::string& path) { TerminalWindow::Options options; options.startDirectory = directory; options.command = {"/bin/sh", "-c", "stty raw -echo; printf '\\033[?2004h'; exec cat > '" + path + "'"}; options.restoresSize = false; return options; } static std::string contents(const std::string& path) { return base::file::read(path).value_or(std::string()); } TEST_F(WindowTest, PasteCannotEndTheBracketEarly) { const std::string path = m_directory.path() + "/received"; Windows windows(m_settings); TerminalWindow* window = windows.create(recorder(m_directory.path(), path)); TerminalPage* page = window->currentPage(); ASSERT_TRUE(waitFor([&] { return page->session().emulation()->programBracketedPasteMode(); })); page->terminal()->pasteText("echo \033[201~touch pwned"); const std::string expected = "\033[200~echo touch pwned\033[201~"; EXPECT_TRUE(waitFor([&] { return contents(path) == expected; })) << contents(path); } TEST_F(WindowTest, DroppedFilesArriveAsAPaste) { const std::string path = m_directory.path() + "/received"; Windows windows(m_settings); TerminalWindow* window = windows.create(recorder(m_directory.path(), path)); TerminalPage* page = window->currentPage(); ASSERT_TRUE(waitFor([&] { return page->session().emulation()->programBracketedPasteMode(); })); // Ctrl+U in a name would erase the opening quote at a prompt. page->dropUrls({"file:///tmp/a%15b%20c"}); const std::string expected = "\033[200~'/tmp/a\x15" "b c' \033[201~"; EXPECT_TRUE(waitFor([&] { return contents(path) == expected; })) << contents(path); } TEST_F(WindowTest, DroppedFileUrlsWithAHostBecomePaths) { const std::string path = m_directory.path() + "/received"; Windows windows(m_settings); TerminalWindow* window = windows.create(recorder(m_directory.path(), path)); TerminalPage* page = window->currentPage(); ASSERT_TRUE(waitFor([&] { return page->session().emulation()->programBracketedPasteMode(); })); page->dropUrls({"file://localhost/tmp/a%23b", "file://elsewhere/tmp/c", "https://example.com/d"}); const std::string expected = "\033[200~'/tmp/a#b' file://elsewhere/tmp/c https://example.com/d \033[201~"; EXPECT_TRUE(waitFor([&] { return contents(path) == expected; })) << contents(path); } namespace { class Canvas : public ui::View { public: explicit Canvas(std::function draw) : m_draw(std::move(draw)) { } protected: void paint(ui::Painter& painter) override { m_draw(painter); } private: std::function m_draw; }; } TEST(TerminalFontTest, ShapedClustersStayBounded) { Konsole::TerminalFont font; font.setFont("Monospace", 10); // Distinct ideographs, a hundred a call, as output of every code point would draw them. auto draw = [&font](ui::Painter& painter) { char32_t character = 0x4E00; std::vector texts; for (int call = 0; call < 200; ++call) { texts.clear(); for (int i = 0; i < 100; ++i) texts.emplace_back(1, character++); std::vector clusters; for (const std::u32string& text : texts) clusters.push_back({text, 0}); font.draw(painter, Konsole::TerminalFont::Regular, ui::Color::rgb(0), 1, 12, clusters, {1, 1}, {}); } }; ui::Window window; window.resize({100, 60}); window.setContentView(std::make_unique(draw)); window.show(); window.grab(); EXPECT_LE(font.cachedClusters(), 8192u + 100u); }