// SPDX-FileCopyrightText: 2024 Contributors to Chatterino // // SPDX-License-Identifier: MIT #include "mocks/Helix.hpp" #ifdef CHATTERINO_HAVE_PLUGINS # include "Application.hpp" # include "common/Channel.hpp" # include "common/network/NetworkCommon.hpp" # include "controllers/accounts/AccountController.hpp" # include "controllers/commands/Command.hpp" // IWYU pragma: keep # include "controllers/commands/CommandController.hpp" # include "controllers/plugins/api/ChannelRef.hpp" # include "controllers/plugins/api/WebSocket.hpp" # include "controllers/plugins/Plugin.hpp" # include "controllers/plugins/PluginController.hpp" # include "controllers/plugins/PluginPermission.hpp" # include "controllers/plugins/SolTypes.hpp" // IWYU pragma: keep # include "lib/Snapshot.hpp" # include "messages/Message.hpp" # include "messages/MessageElement.hpp" # include "mocks/BaseApplication.hpp" # include "mocks/Channel.hpp" # include "mocks/EmoteController.hpp" # include "mocks/Logging.hpp" # include "mocks/TwitchIrcServer.hpp" # include "NetworkHelpers.hpp" # include "singletons/Logging.hpp" # include "Test.hpp" # include # include # include # include # include # include using namespace chatterino; using chatterino::mock::MockChannel; using namespace std::chrono_literals; namespace { constexpr bool UPDATE_SNAPSHOTS = false; const QString TEST_SETTINGS = R"( { "plugins": { "supportEnabled": true, "enabledPlugins": [ "test" ] }, "accounts": { "uid117166826": { "username": "testaccount_420", "userID": "117166826", "clientID": "abc", "oauthToken": "def" }, "current": "testaccount_420" } } )"; class MockTwitch : public mock::MockTwitchIrcServer { public: ChannelPtr mm2pl = std::make_shared("mm2pl"); ChannelPtr getChannelOrEmpty(const QString &dirtyChannelName) override { if (dirtyChannelName == "mm2pl") { return this->mm2pl; } return Channel::getEmpty(); } std::shared_ptr getChannelOrEmptyByID( const QString &channelID) override { if (channelID == "117691339") { return this->mm2pl; } return Channel::getEmpty(); } }; class MockApplication : public mock::BaseApplication { public: MockApplication() : mock::BaseApplication(TEST_SETTINGS) , plugins(this->paths_) , commands(this->paths_) { } PluginController *getPlugins() override { return &this->plugins; } CommandController *getCommands() override { return &this->commands; } EmoteController *getEmotes() override { return &this->emotes; } mock::MockTwitchIrcServer *getTwitch() override { return &this->twitch; } ILogging *getChatLogger() override { return &this->logging; } AccountController *getAccounts() override { return &this->accounts; } PluginController plugins; mock::Logging logging; CommandController commands; mock::EmoteController emotes; MockTwitch twitch; AccountController accounts; mock::Helix helix; }; QDir luaTestBaseDir(const QString &category) { QDir snapshotDir(QStringLiteral(__FILE__)); snapshotDir.cd("../../lua/"); snapshotDir.cd(category); return snapshotDir; } QStringList discoverLuaTests(const QString &category) { auto files = luaTestBaseDir(category).entryList(QDir::NoDotAndDotDot | QDir::Files); for (auto &file : files) { file.remove(".lua"); } return files; } std::string luaTestPath(const QString &category, const QString &entry) { return luaTestBaseDir(category).filePath(entry + ".lua").toStdString(); } bool runLuaTest(const QString &category, const QString &entry, sol::state_view lua) { auto loadResult = lua.load_file(luaTestPath(category, entry)); auto pfn = loadResult.get(); pfn.set_error_handler(lua["debug"]["traceback"]); auto pfr = pfn.call(); EXPECT_TRUE(pfr.valid()); if (!pfr.valid()) { qDebug().noquote() << "Test" << entry << "failed:"; sol::error err = pfr; qDebug().noquote() << err.what(); return false; } return true; } } // namespace namespace chatterino { class PluginControllerAccess { public: static bool tryLoadFromDir(const QDir &pluginDir) { return getApp()->getPlugins()->tryLoadFromDir(pluginDir); } static void openLibrariesFor(Plugin *plugin) { getApp()->getPlugins()->openLibrariesFor(plugin); } static std::map> &plugins() { return getApp()->getPlugins()->plugins_; } static lua_State *state(Plugin *pl) { return pl->state_; } }; } // namespace chatterino class PluginTest : public ::testing::Test { protected: void configure(std::vector permissions = {}) { this->app = std::make_unique(); auto &plugins = PluginControllerAccess::plugins(); { PluginMeta meta; meta.name = "Test"; meta.license = "MIT"; meta.homepage = "https://github.com/Chatterino/chatterino2"; meta.description = "Plugin for tests"; meta.permissions = std::move(permissions); QDir plugindir = QDir(app->paths_.pluginsDirectory).absoluteFilePath("test"); plugindir.mkpath("."); auto temp = std::make_unique("test", luaL_newstate(), meta, plugindir); this->rawpl = temp.get(); plugins.insert({"test", std::move(temp)}); } // XXX: this skips PluginController::load() PluginControllerAccess::openLibrariesFor(rawpl); this->lua = new sol::state_view(PluginControllerAccess::state(rawpl)); this->channel = app->twitch.mm2pl; this->rawpl->dataDirectory().mkpath("."); initializeHelix(&this->app->helix); this->app->accounts.load(); } void TearDown() override { // perform safe destruction of the plugin delete this->lua; this->lua = nullptr; PluginControllerAccess::plugins().clear(); this->rawpl = nullptr; this->app.reset(); } Plugin *rawpl = nullptr; std::unique_ptr app; sol::state_view *lua; ChannelPtr channel; }; TEST_F(PluginTest, testCommands) { configure(); lua->script(R"lua( _G.called = false _G.words = nil _G.channel = nil c2.register_command("/test", function(ctx) _G.called = true _G.words = ctx.words _G.channel = ctx.channel end) )lua"); EXPECT_EQ(app->commands.pluginCommands(), QStringList{"/test"}); app->commands.execCommand("/test with arguments", channel, false); bool called = (*lua)["called"]; EXPECT_EQ(called, true); EXPECT_NE((*lua)["words"], sol::nil); { sol::table tbl = (*lua)["words"]; std::vector words; for (auto &o : tbl) { words.push_back(o.second.as()); } EXPECT_EQ(words, std::vector({"/test", "with", "arguments"})); } sol::object chnobj = (*lua)["channel"]; EXPECT_EQ(chnobj.get_type(), sol::type::userdata); lua::api::ChannelRef ref = chnobj.as(); EXPECT_EQ(ref.get_name(), channel->getName()); } TEST_F(PluginTest, testCompletion) { configure(); lua->script(R"lua( _G.called = false _G.query = nil _G.full_text_content = nil _G.cursor_position = nil _G.is_first_word = nil c2.register_callback( c2.EventType.CompletionRequested, function(ev) _G.called = true _G.query = ev.query _G.full_text_content = ev.full_text_content _G.cursor_position = ev.cursor_position _G.is_first_word = ev.is_first_word if ev.query == "exclusive" then return { hide_others = true, values = {"Completion1", "Completion2"} } end return { hide_others = false, values = {"Completion"}, } end ) )lua"); bool done{}; QStringList results; std::tie(done, results) = app->plugins.updateCustomCompletions("foo", "foo", 3, true); ASSERT_EQ(done, false); ASSERT_EQ(results, QStringList{"Completion"}); ASSERT_EQ((*lua).get("query"), "foo"); ASSERT_EQ((*lua).get("full_text_content"), "foo"); ASSERT_EQ((*lua).get("cursor_position"), 3); ASSERT_EQ((*lua).get("is_first_word"), true); std::tie(done, results) = app->plugins.updateCustomCompletions( "exclusive", "foo exclusive", 13, false); ASSERT_EQ(done, true); ASSERT_EQ(results, QStringList({"Completion1", "Completion2"})); ASSERT_EQ((*lua).get("query"), "exclusive"); ASSERT_EQ((*lua).get("full_text_content"), "foo exclusive"); ASSERT_EQ((*lua).get("cursor_position"), 13); ASSERT_EQ((*lua).get("is_first_word"), false); } TEST_F(PluginTest, testChannel) { configure(); lua->script(R"lua( chn = c2.Channel.by_name("mm2pl") )lua"); ASSERT_EQ(lua->script(R"lua( return chn:get_name() )lua").get(0), "mm2pl"); ASSERT_EQ( lua->script(R"lua( return chn:get_type() )lua").get(0), Channel::Type::Twitch); ASSERT_EQ( lua->script(R"lua( return chn:get_display_name() )lua").get(0), "mm2pl"); // TODO: send_message, add_system_message ASSERT_EQ( lua->script(R"lua( return chn:is_twitch_channel() )lua").get(0), true); ASSERT_TRUE(lua->script(R"lua( assert(c2.Channel.by_name("mm2pl") == c2.Channel.by_name("mm2pl")) assert(not c2.Channel.by_name("mm2pl") ~= c2.Channel.by_name("mm2pl")) assert(c2.Channel.by_name("mm2pl") ~= c2.Channel.by_name("twitchdev")) assert(c2.Channel.by_name("twitchdev") == c2.Channel.by_name("twitchdev")) assert(c2.Channel.by_name("twitchdev") == c2.Channel.by_name("other")) -- empty channel )lua") .valid()); // this is not a TwitchChannel const auto *shouldThrow1 = R"lua( return chn:is_broadcaster() )lua"; EXPECT_ANY_THROW(lua->script(shouldThrow1)); const auto *shouldThrow2 = R"lua( return chn:is_mod() )lua"; EXPECT_ANY_THROW(lua->script(shouldThrow2)); const auto *shouldThrow3 = R"lua( return chn:is_vip() )lua"; EXPECT_ANY_THROW(lua->script(shouldThrow3)); const auto *shouldThrow4 = R"lua( return chn:get_twitch_id() )lua"; EXPECT_ANY_THROW(lua->script(shouldThrow4)); } TEST_F(PluginTest, testHttp) { { PluginPermission net; net.type = PluginPermission::Type::Network; configure({net}); } lua->script(R"lua( function DoReq(url, postdata) r = c2.HTTPRequest.create(method, url) r:on_success(function(res) status = res:status() data = res:data() error = res:error() success = true end) r:on_error(function(res) status = res:status() data = res:data() error = res:error() failure = true end) r:finally(function() finally = true done() end) if postdata ~= "" then r:set_payload(postdata) r:set_header("Content-Type", "text/plain") end r:set_timeout(1000) r:execute() end )lua"); struct RequestCase { QString url; bool success; bool failure; int status; QString error; NetworkRequestType meth = NetworkRequestType::Get; QByteArray data; // null means do not check }; std::vector cases{ {"/status/200", true, false, 200, "200"}, {"/delay/2", false, true, 0, "TimeoutError"}, {"/post", true, false, 200, "200", NetworkRequestType::Post, "Example data"}, }; for (const auto &c : cases) { lua->script(R"lua( success = false failure = false finally = false status = nil data = nil error = nil )lua"); RequestWaiter waiter; (*lua)["method"] = c.meth; (*lua)["done"] = [&waiter]() { waiter.requestDone(); }; (*lua)["DoReq"](HTTPBIN_BASE_URL + c.url, c.data); waiter.waitForRequest(); EXPECT_EQ(lua->get("success"), c.success); EXPECT_EQ(lua->get("failure"), c.failure); EXPECT_EQ(lua->get("finally"), true); if (c.status != 0) { EXPECT_EQ(lua->get("status"), c.status); } else { EXPECT_EQ((*lua)["status"], sol::nil); } EXPECT_EQ(lua->get("error"), c.error); if (!c.data.isNull()) { EXPECT_EQ(lua->get("data"), c.data); } } } const QByteArray TEST_FILE_DATA = "Test file data\nWith a new line.\n"; TEST_F(PluginTest, ioTest) { { PluginPermission ioread; PluginPermission iowrite; ioread.type = PluginPermission::Type::FilesystemRead; iowrite.type = PluginPermission::Type::FilesystemWrite; configure({ioread, iowrite}); } lua->set("TEST_DATA", TEST_FILE_DATA); lua->script(R"lua( f, err = io.open("testfile", "w") print(f, err) f:write(TEST_DATA) f:close() f, err = io.open("testfile", "r") out = f:read("a") f:close() )lua"); EXPECT_EQ(lua->get("out"), TEST_FILE_DATA); lua->script(R"lua( io.input("testfile") out = io.read("a") )lua"); EXPECT_EQ(lua->get("out"), TEST_FILE_DATA); const auto *shouldThrow1 = R"lua( io.popen("/bin/sh", "-c", "notify-send \"This should not execute.\"") )lua"; EXPECT_ANY_THROW(lua->script(shouldThrow1)); const auto *shouldThrow2 = R"lua( io.tmpfile() )lua"; EXPECT_ANY_THROW(lua->script(shouldThrow2)); } TEST_F(PluginTest, ioNoPerms) { configure(); auto file = rawpl->dataDirectory().filePath("testfile"); QFile f(file); EXPECT_TRUE(f.open(QFile::WriteOnly)); f.write(TEST_FILE_DATA); f.close(); EXPECT_EQ( // clang-format off lua->script(R"lua( f, err = io.open("testfile", "r") return err )lua").get(0), "Plugin does not have permissions to access given file." // clang-format on ); const auto *shouldThrow1 = R"lua( io.input("testfile") )lua"; EXPECT_ANY_THROW(lua->script(shouldThrow1)); EXPECT_EQ( // clang-format off lua->script(R"lua( f, err = io.open("testfile", "w") return err )lua").get(0), "Plugin does not have permissions to access given file." // clang-format on ); const auto *shouldThrow2 = R"lua( io.output("testfile") )lua"; EXPECT_ANY_THROW(lua->script(shouldThrow2)); const auto *shouldThrow3 = R"lua( io.lines("testfile") )lua"; EXPECT_ANY_THROW(lua->script(shouldThrow3)); } TEST_F(PluginTest, requireNoData) { { PluginPermission ioread; PluginPermission iowrite; ioread.type = PluginPermission::Type::FilesystemRead; iowrite.type = PluginPermission::Type::FilesystemWrite; configure({ioread, iowrite}); } auto file = rawpl->dataDirectory().filePath("thisiscode.lua"); QFile f(file); EXPECT_TRUE(f.open(QFile::WriteOnly)); f.write(R"lua(print("Data was executed"))lua"); f.close(); const auto *shouldThrow1 = R"lua( require("data.thisiscode") )lua"; EXPECT_ANY_THROW(lua->script(shouldThrow1)); } TEST_F(PluginTest, testTimerRec) { configure(); RequestWaiter waiter; lua->set("done", [&] { waiter.requestDone(); }); sol::protected_function fn = lua->script(R"lua( local i = 0 f = function() i = i + 1 c2.log(c2.LogLevel.Info, "cb", i) if i < 1024 then c2.later(f, 1) else done() end end c2.later(f, 1) )lua"); waiter.waitForRequest(1ms); } TEST_F(PluginTest, tryCallTest) { configure(); lua->script(R"lua( function return_table() return { a="b" } end function return_nothing() end function return_nil() return nil end function return_nothing_and_error() error("I failed :)") end )lua"); using func = sol::protected_function; func returnTable = lua->get("return_table"); func returnNil = lua->get("return_nil"); func returnNothing = lua->get("return_nothing"); func returnNothingAndError = lua->get("return_nothing_and_error"); // happy paths { auto res = lua::tryCall(returnTable); EXPECT_TRUE(res.has_value()); auto t = res.value(); EXPECT_EQ(t.get("a"), "b"); } { // valid void return auto res = lua::tryCall(returnNil); EXPECT_TRUE(res.has_value()); } { // valid void return auto res = lua::tryCall(returnNothing); EXPECT_TRUE(res.has_value()); } { auto res = lua::tryCall(returnNothingAndError); EXPECT_FALSE(res.has_value()); EXPECT_EQ(res.error(), "[string \"...\"]:13: I failed :)"); } { auto res = lua::tryCall>(returnNil); EXPECT_TRUE(res.has_value()); // no error auto opt = *res; EXPECT_FALSE(opt.has_value()); // but also no false } // unhappy paths { // wrong return type auto res = lua::tryCall(returnTable); EXPECT_FALSE(res.has_value()); EXPECT_EQ(res.error(), "Expected int to be returned but table was returned"); } { // optional but bad return type auto res = lua::tryCall>(returnTable); EXPECT_FALSE(res.has_value()); EXPECT_EQ(res.error(), "Expected std::optional to be returned but " "table was returned"); } { // no return auto res = lua::tryCall(returnNothing); EXPECT_FALSE(res.has_value()); EXPECT_EQ(res.error(), "Expected int to be returned but none was returned"); } { // nil return auto res = lua::tryCall(returnNil); EXPECT_FALSE(res.has_value()); EXPECT_EQ(res.error(), "Expected int to be returned but lua_nil was returned"); } } TEST_F(PluginTest, testTcpWebSocket) { configure({PluginPermission{{{"type", "Network"}}}}); RequestWaiter waiter; std::vector> messages; bool open = false; lua->set("done", [&] { waiter.requestDone(); }); lua->set("add", [&](bool isText, QByteArray data) { EXPECT_TRUE(open); messages.emplace_back(isText, std::move(data)); }); // On GCC in release mode, using set() would cause the done function to be called instead. lua->set_function("open", [&] { EXPECT_FALSE(open); open = true; }); std::shared_ptr ws = lua->script(R"lua( local ws = c2.WebSocket.new("ws://127.0.0.1:9052/echo") ws.on_text = function(data) add(true, data) end local any_msg = false ws.on_binary = function(data) if not any_msg then any_msg = true ws:send_text(string.rep("a", 1 << 15)) ws:send_binary("wow") ws:send_text("/HEADER user-agent") ws:send_binary("/CLOSE") end add(false, data) end ws.on_close = function() done() end ws.on_open = function() open() end ws:send_text("message1") ws:send_text("message2") ws:send_text("message3") ws:send_binary("message4") return ws )lua"); std::weak_ptr weakWs{ws}; ws.reset(); waiter.waitForRequest(); ASSERT_TRUE(open); ASSERT_EQ(messages.size(), 7); ASSERT_EQ(messages[0].first, true); ASSERT_EQ(messages[0].second, "message1"); ASSERT_EQ(messages[1].first, true); ASSERT_EQ(messages[1].second, "message2"); ASSERT_EQ(messages[2].first, true); ASSERT_EQ(messages[2].second, "message3"); ASSERT_EQ(messages[3].first, false); ASSERT_EQ(messages[3].second, "message4"); ASSERT_EQ(messages[4].first, true); ASSERT_EQ(messages[4].second, QByteArray(1 << 15, 'a')); ASSERT_EQ(messages[5].first, false); ASSERT_EQ(messages[5].second, "wow"); ASSERT_EQ(messages[6].first, true); ASSERT_TRUE(messages[6].second.startsWith("Chatterino")); ASSERT_FALSE(weakWs.expired()); lua->collect_garbage(); ASSERT_TRUE(weakWs.expired()); } TEST_F(PluginTest, testTlsWebSocket) { configure({PluginPermission{{{"type", "Network"}}}}); RequestWaiter waiter; bool open = false; std::vector> messages; lua->set("done", [&] { waiter.requestDone(); }); lua->set("add", [&](bool isText, QByteArray data) { EXPECT_TRUE(open); messages.emplace_back(isText, std::move(data)); }); // On GCC in release mode, using set() would cause the done function to be called instead. lua->set_function("open", [&] { EXPECT_FALSE(open); open = true; }); std::shared_ptr ws = lua->script(R"lua( local ws = c2.WebSocket.new("wss://127.0.0.1:9050/echo", { headers = { ["User-Agent"] = "Lua", ["A-Header"] = "A value", ["Referer"] = "https://chatterino.com", }, }) ws.on_text = function(data) add(true, data) end local any_msg = false ws.on_binary = function(data) if not any_msg then any_msg = true ws:send_text(string.rep("a", 1 << 15)) ws:send_binary("wow") ws:send_text("/HEADER user-agent") ws:send_text("/HEADER a-header") ws:send_text("/HEADER referer") ws:send_binary("/CLOSE") end add(false, data) end ws.on_close = function() done() end ws.on_open = function() open() end ws:send_text("message1") ws:send_text("message2") ws:send_text("message3") ws:send_binary("message4") return ws )lua"); std::weak_ptr weakWs{ws}; ws.reset(); waiter.waitForRequest(); ASSERT_TRUE(open); ASSERT_EQ(messages.size(), 9); ASSERT_EQ(messages[0].first, true); ASSERT_EQ(messages[0].second, "message1"); ASSERT_EQ(messages[1].first, true); ASSERT_EQ(messages[1].second, "message2"); ASSERT_EQ(messages[2].first, true); ASSERT_EQ(messages[2].second, "message3"); ASSERT_EQ(messages[3].first, false); ASSERT_EQ(messages[3].second, "message4"); ASSERT_EQ(messages[4].first, true); ASSERT_EQ(messages[4].second, QByteArray(1 << 15, 'a')); ASSERT_EQ(messages[5].first, false); ASSERT_EQ(messages[5].second, "wow"); ASSERT_EQ(messages[6].first, true); ASSERT_EQ(messages[6].second, "Lua"); ASSERT_EQ(messages[7].first, true); ASSERT_EQ(messages[7].second, "A value"); ASSERT_EQ(messages[8].first, true); ASSERT_EQ(messages[8].second, "https://chatterino.com"); ASSERT_FALSE(weakWs.expired()); lua->collect_garbage(); ASSERT_TRUE(weakWs.expired()); } TEST_F(PluginTest, testWebSocketNoPerms) { configure(); bool res = lua->script(R"lua( return c2["WebSocket"] ~= nil )lua"); ASSERT_TRUE(res); const char *shouldThrow = R"lua( return c2.WebSocket.new('wss://127.0.0.1:9050/echo') )lua"; EXPECT_ANY_THROW(lua->script(shouldThrow)); } TEST_F(PluginTest, testWebSocketApi) { configure({PluginPermission{{{"type", "Network"}}}}); bool ok = lua->script(R"lua( local t = function () end local b = function () end local c = function () end local ws = c2.WebSocket.new("wss://127.0.0.1:9050/echo", { on_text = t, on_binary = b, on_close = c, }) return ws.on_text == t and ws.on_binary == b and ws.on_close == c )lua"); ASSERT_TRUE(ok); } TEST_F(PluginTest, testWebSocketUnsetFns) { configure({PluginPermission{{{"type", "Network"}}}}); RequestWaiter waiter; lua->set("done", [&] { waiter.requestDone(); }); lua->script(R"lua( local ws = c2.WebSocket.new("wss://127.0.0.1:9050/echo") ws.on_close = function() done() end ws:send_text("message1") ws:send_text("message2") ws:send_binary("message3") ws:send_binary("/CLOSE") )lua"); waiter.waitForRequest(); } TEST_F(PluginTest, MessageElementFlag) { configure(); lua->script(R"lua( values = {} for k, v in pairs(c2.MessageElementFlag) do table.insert(values, ("%s=0x%x"):format(k, v)) end table.sort(values, function(a, b) return a:lower() < b:lower() end) out = table.concat(values, ",") )lua"); const char *VALUES = "AlwaysShow=0x2000000," "BadgeBttv=0x40," "BadgeChannelAuthority=0x8000," "BadgeChatterino=0x40000," "BadgeFfz=0x80000," "BadgeGlobalAuthority=0x2000," "BadgePredictions=0x4000," "BadgeSevenTV=0x1000000000," "BadgeSharedChannel=0x2000000000," "BadgeSubscription=0x10000," "BadgeVanity=0x20000," "BitsAmount=0x200000," "BitsAnimated=0x1000," "BitsStatic=0x800," "ChannelName=0x100000," "ChannelPointReward=0x100," "Collapsed=0x4000000," "EmojiImage=0x800000," "EmojiText=0x1000000," "EmoteImage=0x10," "EmoteText=0x20," "LowercaseLinks=0x20000000," "Mention=0x8000000," "Misc=0x1," "ModeratorTools=0x400000," "RepliedMessage=0x100000000," "ReplyButton=0x200000000," "Text=0x2," "Timestamp=0x8," "Username=0x4"; std::string got = (*lua)["out"]; ASSERT_EQ(got, VALUES); } TEST_F(PluginTest, ChannelAddMessage) { configure(); lua->script(R"lua( function do_it(chan) local Repost = c2.MessageContext.Repost local Original = c2.MessageContext.Original chan:add_message(c2.Message.new({ id = "1" })) chan:add_message(c2.Message.new({ id = "2" }), Repost) chan:add_message(c2.Message.new({ id = "3" }), Original, nil) chan:add_message(c2.Message.new({ id = "4" }), Repost, c2.MessageFlag.DoNotLog) chan:add_message(c2.Message.new({ id = "5" }), Original, c2.MessageFlag.DoNotLog) chan:add_message(c2.Message.new({ id = "6" }), Original, c2.MessageFlag.System) end )lua"); auto chan = std::make_shared("mock"); std::vector logged; EXPECT_CALL(this->app->logging, addMessage) .Times(3) .WillRepeatedly( [&](const auto &, const auto &msg, const auto &, const auto &) { logged.emplace_back(msg); }); std::vector>> added; std::ignore = chan->messageAppended.connect([&](auto &&...args) { added.emplace_back(std::forward(args)...); }); (*lua)["do_it"](lua::api::ChannelRef(chan)); ASSERT_EQ(added.size(), 6); ASSERT_EQ(added[0].first->id, "1"); ASSERT_FALSE(added[0].second.has_value()); ASSERT_EQ(added[1].first->id, "2"); ASSERT_FALSE(added[1].second.has_value()); ASSERT_EQ(added[2].first->id, "3"); ASSERT_FALSE(added[2].second.has_value()); ASSERT_EQ(added[3].first->id, "4"); ASSERT_EQ(added[3].second, MessageFlags{MessageFlag::DoNotLog}); ASSERT_EQ(added[4].first->id, "5"); ASSERT_EQ(added[4].second, MessageFlags{MessageFlag::DoNotLog}); ASSERT_EQ(added[5].first->id, "6"); ASSERT_EQ(added[5].second, MessageFlags{MessageFlag::System}); ASSERT_EQ(logged.size(), 3); ASSERT_EQ(added[0].first, logged[0]); ASSERT_EQ(added[2].first, logged[1]); ASSERT_EQ(added[5].first, logged[2]); } TEST_F(PluginTest, MessageFrozenFlag) { configure(); sol::protected_function isFrozenFn = lua->script(R"lua( return function(msg) return msg.frozen end )lua"); sol::protected_function setFrozenFn = lua->script(R"lua( return function(msg, val) msg.frozen = val end )lua"); auto liquid = std::make_shared(); auto res = isFrozenFn(liquid); ASSERT_TRUE(res.valid()); ASSERT_FALSE(res.get()); auto frozen = std::make_shared(); frozen->freeze(); res = isFrozenFn(frozen); ASSERT_TRUE(res.valid()); ASSERT_TRUE(res.get()); // we shouldn't be able to modify the flag ASSERT_FALSE(setFrozenFn(liquid, true).valid()); ASSERT_FALSE(setFrozenFn(liquid, false).valid()); ASSERT_FALSE(setFrozenFn(frozen, true).valid()); ASSERT_FALSE(setFrozenFn(frozen, false).valid()); } TEST_F(PluginTest, MessageFlagModification) { configure(); sol::protected_function pfn = lua->script(R"lua( return function(msg) assert(msg.flags == c2.MessageFlag.Debug) msg.flags = c2.MessageFlag.System assert(msg.flags == c2.MessageFlag.System) end )lua"); sol::protected_function isFrozenFn = lua->script(R"lua( return function(msg) return msg.frozen end )lua"); auto liquid = std::make_shared(); liquid->flags = MessageFlag::Debug; auto res = pfn(liquid); ASSERT_TRUE(res.valid()); // for the flags, it shouldn't matter if the message is frozen auto frozen = std::make_shared(); frozen->flags = MessageFlag::Debug; frozen->freeze(); res = pfn(frozen); ASSERT_TRUE(res.valid()); } TEST_F(PluginTest, MessageModification) { configure(); // Test that we can modify properties and that Lua sees the modification sol::table tests = lua->script(R"lua( return { function(msg) msg.parse_time = 1234567 end, function(msg) assert(msg.id == "abc") msg.id = "1234" assert(msg.id == "1234") end, function(msg) assert(msg.search_text == "search") msg.search_text = "query" assert(msg.search_text == "query") end, function(msg) assert(msg.message_text == "msg") msg.message_text = "text" assert(msg.message_text == "text") end, function(msg) assert(msg.login_name == "login") msg.login_name = "name" assert(msg.login_name == "name") end, function(msg) assert(msg.display_name == "display") msg.display_name = "name" assert(msg.display_name == "name") end, function(msg) assert(msg.localized_name == "localized") msg.localized_name = "name" assert(msg.localized_name == "name") end, function(msg) assert(msg.user_id == "id") msg.user_id = "id" assert(msg.user_id == "id") end, function(msg) assert(msg.channel_name == "channel") msg.channel_name = "name" assert(msg.channel_name == "name") end, function(msg) assert(msg.username_color == "#ffaabbcc") msg.username_color = "#ccbbaaff" assert(msg.username_color == "#ccbbaaff") end, function(msg) assert(msg.server_received_time == 1230000) msg.server_received_time = 1240000 assert(msg.server_received_time == 1240000) end, function(msg) print(msg.highlight_color) assert(msg.highlight_color == "#ff223344") msg.highlight_color = "#44332211" assert(msg.highlight_color == "#44332211") end, function(msg) assert(#msg:elements() == 2) msg:append_element({ type = "linebreak" }) assert(#msg:elements() == 3) assert(msg:elements()[3].type == "linebreak") end, } )lua"); auto makeMsg = [] { auto msg = std::make_shared(); msg->flags = MessageFlag::Debug; msg->id = "abc"; msg->searchText = "search"; msg->messageText = "msg"; msg->loginName = "login"; msg->displayName = "display"; msg->localizedName = "localized"; msg->userID = "id"; msg->channelName = "channel"; msg->usernameColor = QColor(0xaabbcc); msg->serverReceivedTime = QDateTime::fromMSecsSinceEpoch(1230000); msg->highlightColor = std::make_shared(0x223344); msg->elements.push_back( std::make_unique("lol", MessageElementFlag::Text)); msg->elements.push_back( std::make_unique("wow", MessageElementFlag::Text)); return msg; }; auto liquid = makeMsg(); ASSERT_TRUE(tests.valid()); for (const auto &[_key, cb] : tests) { sol::protected_function pf = cb; auto res = pf(liquid); if (!res.valid()) { sol::error err = res; ASSERT_TRUE(false) << err.what(); } } // If the message is frozen, all modifications should fail with an error auto frozen = makeMsg(); frozen->freeze(); for (const auto &[key, cb] : tests) { sol::protected_function pf = cb; auto res = pf(frozen); ASSERT_FALSE(res.valid()); sol::error err = res; ASSERT_EQ(std::string_view(err.what()), "Message is frozen"); } } TEST_F(PluginTest, MessageConstness) { configure(); sol::protected_function pfn = lua->script(R"lua( return function(msg) assert(msg.login_name == "hello") msg.login_name = "alien" end )lua"); auto msg = std::make_shared(); msg->loginName = "hello"; MessagePtr cmsg = msg; auto res = pfn(cmsg); ASSERT_TRUE(res.valid()); cmsg->freeze(); res = pfn(cmsg); ASSERT_FALSE(res.valid()); } // Test that we can access properties of message elements TEST_F(PluginTest, MessageElementAccess) { configure(); sol::protected_function pfn = lua->script(R"lua( return function(msg, idx, prop) return msg:elements()[idx][prop] end )lua"); auto msg = std::make_shared(); msg->elements.emplace_back( std::make_unique("my text", MessageElementFlag::Text)); msg->elements.emplace_back(std::make_unique( "single line", MessageElementFlag::Text)); msg->elements.emplace_back(std::make_unique( ImagePtr{}, 2, QColor(0xabcdef), MessageElementFlag::ReplyButton)); msg->elements.emplace_back(std::make_unique( "display", "login", MessageColor::Text, MessageColor::System)); msg->elements[1]->setTooltip("tooltip"); msg->elements[2]->setTrailingSpace(false); msg->freeze(); auto getAll = [&](std::string_view key) { return std::array{ pfn(msg, 1, key).get(), pfn(msg, 2, key).get(), pfn(msg, 3, key).get(), pfn(msg, 4, key).get(), }; }; auto types = getAll("type"); ASSERT_EQ(types[0].as(), "text"); ASSERT_EQ(types[1].as(), "single-line-text"); ASSERT_EQ(types[2].as(), "circular-image"); ASSERT_EQ(types[3].as(), "mention"); auto flags = getAll("flags"); ASSERT_EQ(flags[0].as(), MessageElementFlag::Text); ASSERT_EQ(flags[1].as(), MessageElementFlag::Text); ASSERT_EQ(flags[2].as(), MessageElementFlag::ReplyButton); ASSERT_EQ(flags[3].as(), (MessageElementFlags(MessageElementFlag::Text, MessageElementFlag::Mention))); auto tooltips = getAll("tooltip"); ASSERT_EQ(tooltips[0].as(), ""); ASSERT_EQ(tooltips[1].as(), "tooltip"); ASSERT_EQ(tooltips[2].as(), ""); ASSERT_EQ(tooltips[3].as(), ""); auto spaces = getAll("trailing_space"); ASSERT_TRUE(spaces[0].as()); ASSERT_TRUE(spaces[1].as()); ASSERT_FALSE(spaces[2].as()); ASSERT_TRUE(spaces[3].as()); // Properties only found on _some_ elements should not error // (like non existent properties) auto paddings = getAll("padding"); ASSERT_TRUE(paddings[0].is()); ASSERT_TRUE(paddings[1].is()); ASSERT_EQ(paddings[2].as(), 2); ASSERT_TRUE(paddings[3].is()); auto words = getAll("words"); ASSERT_EQ(words[0].as>(), (std::vector{"my", "text"})); ASSERT_EQ(words[1].as>(), (std::vector{"single", "line"})); ASSERT_TRUE(words[2].is()); // mention elements are also text elements ASSERT_EQ(words[3].as>(), (std::vector{"display"})); auto userLogins = getAll("user_login_name"); ASSERT_TRUE(userLogins[0].is()); ASSERT_TRUE(userLogins[1].is()); ASSERT_TRUE(userLogins[2].is()); ASSERT_EQ(userLogins[3].as(), "login"); auto times = getAll("time"); ASSERT_TRUE(times[0].is()); ASSERT_TRUE(times[1].is()); ASSERT_TRUE(times[2].is()); ASSERT_TRUE(times[3].is()); auto nonExistent = getAll("non_existent"); ASSERT_TRUE(nonExistent[0].is()); ASSERT_TRUE(nonExistent[1].is()); ASSERT_TRUE(nonExistent[2].is()); ASSERT_TRUE(nonExistent[3].is()); auto links = getAll("link"); ASSERT_TRUE(links[0].is()); ASSERT_TRUE(links[1].is()); ASSERT_TRUE(links[2].is()); ASSERT_TRUE(links[3].is()); // test that accessing anything outside the elements vector causes an error auto res = pfn(msg, 0, "flags"); ASSERT_FALSE(res.valid()); res = pfn(msg, 42, "flags"); ASSERT_FALSE(res.valid()); } // Test that we can modify properties of message elements TEST_F(PluginTest, MessageElementModification) { configure(); sol::protected_function pfn = lua->script(R"lua( return function(msg, idx, prop, val) msg:elements()[idx][prop] = val end )lua"); // same as MessageElementAccess... auto msg = std::make_shared(); msg->elements.emplace_back( std::make_unique("my text", MessageElementFlag::Text)); msg->elements.emplace_back(std::make_unique( "single line", MessageElementFlag::Text)); msg->elements.emplace_back(std::make_unique( ImagePtr{}, 2, QColor(0xabcdef), MessageElementFlag::ReplyButton)); msg->elements.emplace_back(std::make_unique( "display", "login", MessageColor::Text, MessageColor::System)); msg->elements[1]->setTooltip("tooltip"); msg->elements[2]->setTrailingSpace(false); // ...but we don't freeze the message here auto setAll = [&](std::string_view key, auto value) { for (size_t i = 1; i <= 4; i++) { EXPECT_TRUE(pfn(msg, i, key, value).valid()); } }; setAll("tooltip", "tool"); ASSERT_EQ(msg->elements[0]->getTooltip(), "tool"); ASSERT_EQ(msg->elements[1]->getTooltip(), "tool"); ASSERT_EQ(msg->elements[2]->getTooltip(), "tool"); ASSERT_EQ(msg->elements[3]->getTooltip(), "tool"); setAll("trailing_space", false); ASSERT_FALSE(msg->elements[0]->hasTrailingSpace()); ASSERT_FALSE(msg->elements[1]->hasTrailingSpace()); ASSERT_FALSE(msg->elements[2]->hasTrailingSpace()); ASSERT_FALSE(msg->elements[3]->hasTrailingSpace()); pfn(msg, 1, "link", Link{Link::CopyToClipboard, "foo"}); pfn(msg, 2, "link", Link{Link::CopyToClipboard, "foo"}); pfn(msg, 3, "link", Link{Link::CopyToClipboard, "foo"}); // can't modify links of mention elements ASSERT_FALSE( pfn(msg, 4, "link", Link{Link::CopyToClipboard, "foo"}).valid()); ASSERT_EQ(msg->elements[0]->getLink().type, Link::CopyToClipboard); ASSERT_EQ(msg->elements[0]->getLink().value, "foo"); ASSERT_EQ(msg->elements[0]->getTooltip(), "Copy to clipboard"); ASSERT_EQ(msg->elements[1]->getLink().type, Link::CopyToClipboard); ASSERT_EQ(msg->elements[1]->getLink().value, "foo"); ASSERT_EQ(msg->elements[2]->getLink().type, Link::CopyToClipboard); ASSERT_EQ(msg->elements[2]->getLink().value, "foo"); auto expectErr = [&](std::string_view key, auto value) { for (size_t i = 1; i <= 4; i++) { auto result = pfn(msg, i, key, value); EXPECT_FALSE(result.valid()) << key; } }; expectErr("type", "something"); expectErr("trailing_space", "something"); // can't set these types expectErr("link", Link{Link::ViewThread, "foo"}); expectErr("link", Link{Link::AutoModAllow, "foo"}); // We can't modify these yet expectErr("padding", 1); expectErr("background", 0xabcdef12); expectErr("words", QStringList{"a", "b"}); expectErr("color", 0x1234); expectErr("style", FontStyle::ChatMedium); expectErr("lowercase", "abc"); expectErr("original", "or"); expectErr("fallback_color", "system"); expectErr("user_color", "system"); expectErr("user_login_name", "system"); expectErr("time", 42); // test that accessing anything outside the elements vector causes an error auto res = pfn(msg, 0, "trailing_space", true); ASSERT_FALSE(res.valid()); res = pfn(msg, 42, "trailing_space", true); ASSERT_FALSE(res.valid()); // we can't modify anything on frozen messages msg->freeze(); expectErr("tooltip", "tool"); expectErr("trailing_space", false); expectErr("padding", 1); } /// Test that both C++ exceptions and luaL_error properly unwind the stack. TEST_F(PluginTest, LuaUnwind) { configure(); size_t i = 0; lua->set_function( "do_something", [&](sol::this_state state, bool should_error, bool use_lua_error) { auto g = qScopeGuard([&] { ++i; }); if (should_error) { if (use_lua_error) { luaL_error(state.lua_state(), "My message"); } else { throw std::runtime_error("My message"); } } }); ASSERT_EQ(i, 0); ASSERT_TRUE(lua->do_string("do_something(false, false)").valid()); ASSERT_EQ(i, 1); ASSERT_TRUE(lua->do_string("do_something(false, true)").valid()); ASSERT_EQ(i, 2); ASSERT_FALSE(lua->do_string("do_something(true, false)").valid()); ASSERT_EQ(i, 3); ASSERT_FALSE(lua->do_string("do_something(true, true)").valid()); ASSERT_EQ(i, 4); } /// Test that we're running with the Lua version we're compiled against. TEST_F(PluginTest, LuaVersion) { configure(); lua->set_function("check_it", [](sol::this_state state) { luaL_checkversion(state.lua_state()); }); ASSERT_TRUE(lua->script("check_it()").valid()); static_assert(LUA_VERSION_NUM >= 504); } TEST_F(PluginTest, ChannelOnDisplayNameChanged) { this->configure(); bool gotEvent = false; this->lua->set_function("on_test_event", [&] { gotEvent = true; }); auto chan = std::make_shared("mock"); this->lua->set("chan", lua::api::ChannelRef(chan)); sol::protected_function init = this->lua->script(R"lua( hdl = nil return function(chan) hdl = chan:on_display_name_changed(on_test_event) end )lua"); ASSERT_TRUE(init(lua::api::ChannelRef(chan)).valid()); ASSERT_TRUE(this->lua->script("assert(hdl ~= nil)").valid()); // regular delivery chan->displayNameChanged.invoke(); ASSERT_TRUE(gotEvent); // blocked connection ASSERT_TRUE(this->lua->script("hdl:block()").valid()); ASSERT_TRUE(this->lua->script("assert(hdl:is_blocked())").valid()); gotEvent = false; chan->displayNameChanged.invoke(); ASSERT_FALSE(gotEvent); // unblocked connection ASSERT_TRUE(this->lua->script("hdl:unblock()").valid()); ASSERT_TRUE(this->lua->script("assert(not hdl:is_blocked())").valid()); gotEvent = false; chan->displayNameChanged.invoke(); ASSERT_TRUE(gotEvent); // disconnect ASSERT_TRUE(this->lua->script("hdl:disconnect()").valid()); ASSERT_TRUE(this->lua->script("assert(not hdl:is_connected())").valid()); gotEvent = false; ASSERT_FALSE(gotEvent); } class PluginMessageConstructionTest : public PluginTest, public ::testing::WithParamInterface { }; TEST_P(PluginMessageConstructionTest, Run) { auto fixture = testlib::Snapshot::read("PluginMessageCtor", GetParam()); configure(); std::string script; if (fixture->input().isArray()) { for (auto line : fixture->input().toArray()) { script += line.toString().toStdString() + '\n'; } } else { script = fixture->inputString().toStdString() + '\n'; } script += "out = c2.Message.new(msg)"; lua->script(script); Message *got = (*lua)["out"]; ASSERT_TRUE(fixture->run(got->toJson(), UPDATE_SNAPSHOTS)); } INSTANTIATE_TEST_SUITE_P( PluginMessageConstruction, PluginMessageConstructionTest, testing::ValuesIn(testlib::Snapshot::discover("PluginMessageCtor"))); class PluginJsonTest : public PluginTest, public ::testing::WithParamInterface { }; TEST_P(PluginJsonTest, Run) { configure(); auto reg = lua->registry().size(); auto globals = lua->globals().size(); runLuaTest("json", GetParam(), *this->lua); for (size_t i = 0; i < 5; i++) { lua->collect_garbage(); } // make sure we don't leak anything to globals or the registry // but give the registry some room of 3 slots (two from getting the debug library) EXPECT_LE(lua->registry().size(), reg + 3); EXPECT_EQ(lua->globals().size(), globals); } INSTANTIATE_TEST_SUITE_P(PluginJson, PluginJsonTest, testing::ValuesIn(discoverLuaTests("json"))); class PluginMessageTest : public PluginTest, public ::testing::WithParamInterface { }; TEST_P(PluginMessageTest, Run) { this->configure(); runLuaTest("message", GetParam(), *this->lua); } INSTANTIATE_TEST_SUITE_P(PluginMessage, PluginMessageTest, testing::ValuesIn(discoverLuaTests("message"))); class PluginChannelTest : public PluginTest, public ::testing::WithParamInterface { }; TEST_P(PluginChannelTest, Run) { this->configure(); runLuaTest("channel", GetParam(), *this->lua); } INSTANTIATE_TEST_SUITE_P(PluginChannel, PluginChannelTest, testing::ValuesIn(discoverLuaTests("channel"))); // verify that all snapshots are included TEST(PluginMessageConstructionTest, Integrity) { ASSERT_FALSE(UPDATE_SNAPSHOTS); // make sure fixtures are actually tested } TEST_F(PluginTest, testAccounts) { configure(); auto res = lua->script(R"lua( local current = c2.current_account() assert(current:login() == "testaccount_420") assert(current:id() == "117166826") assert(current:color() == nil) -- unset assert(not current:is_anon()) )lua"); ASSERT_TRUE(res.valid()) << res.get().what(); } TEST_F(PluginTest, debugTraceback) { configure(); QString traceback = lua->script(R"lua( local function other() error("oh no") end local function main() local function inner() other() end inner() end local ok, res = xpcall(main, debug.traceback) return res )lua") .get(); ASSERT_TRUE(traceback.contains("[C]: in function 'error'")); ASSERT_TRUE(traceback.contains("[string \"...\"]:3: in upvalue 'other'")); ASSERT_TRUE(traceback.contains("[string \"...\"]:7: in local 'inner'")); ASSERT_TRUE(traceback.contains( "[string \"...\"]:9: in function <[string \"...\"]:5>")); ASSERT_TRUE(traceback.contains("[C]: in function 'xpcall'")); ASSERT_TRUE(traceback.contains("[string \"...\"]:12: in main chunk")); traceback = lua->script(R"lua( return debug.traceback() )lua") .get(); ASSERT_TRUE(traceback.contains("[string \"...\"]:2: in main chunk")); traceback = lua->script(R"lua( return debug.traceback("my message") )lua") .get(); ASSERT_TRUE(traceback.contains("my message")); ASSERT_TRUE(traceback.contains("[string \"...\"]:2: in main chunk")); traceback = lua->script(R"lua( local coro = coroutine.create(function () coroutine.yield() end) coroutine.resume(coro) return debug.traceback(coro) )lua") .get(); ASSERT_TRUE(traceback.contains("[C]: in function 'coroutine.yield'")); ASSERT_TRUE(traceback.contains( "[string \"...\"]:3: in function <[string \"...\"]:2>")); auto msg = lua->script(R"lua( return debug.traceback(c2.Message.new({id = "who would do this"})) )lua") .get>(); ASSERT_EQ(msg->id, "who would do this"); } #endif