Files
InstanceBrick--nightcap/tests/discord_rpc_test.cc
T
vii 9e8afb233a feat(discord-rpc): show the Roblox badge as the default small image
Defaults images.small to the roblox_small asset on the bundled Discord
application, with "Roblox" as its hover text.
2026-09-18 18:54:12 -05:00

484 lines
19 KiB
C++

#include "runtime/discord_rpc.h"
#include <gtest/gtest.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
#include <array>
#include <chrono>
#include <condition_variable>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <mutex>
#include <optional>
#include <string>
#include <string_view>
#include <thread>
#include <vector>
namespace mocktail {
namespace runtime {
namespace {
RobloxExperienceLaunchRequest PublicServer() {
RobloxExperienceLaunchRequest request;
request.place_id = 189707;
request.game_instance_id = "job/a+b";
return request;
}
void Encode32(uint32_t value, unsigned char* output) {
output[0] = static_cast<unsigned char>(value & 0xffU);
output[1] = static_cast<unsigned char>((value >> 8U) & 0xffU);
output[2] = static_cast<unsigned char>((value >> 16U) & 0xffU);
output[3] = static_cast<unsigned char>((value >> 24U) & 0xffU);
}
uint32_t Decode32(const unsigned char* input) {
return static_cast<uint32_t>(input[0]) |
(static_cast<uint32_t>(input[1]) << 8U) |
(static_cast<uint32_t>(input[2]) << 16U) |
(static_cast<uint32_t>(input[3]) << 24U);
}
bool ReadBytes(int descriptor, void* output, std::size_t size) {
auto* next = static_cast<unsigned char*>(output);
while (size != 0) {
const ssize_t received = recv(descriptor, next, size, 0);
if (received <= 0) return false;
next += received;
size -= static_cast<std::size_t>(received);
}
return true;
}
bool ReadTestFrame(int descriptor, uint32_t* opcode, std::string* payload) {
std::array<unsigned char, 8> header{};
if (!ReadBytes(descriptor, header.data(), header.size())) return false;
*opcode = Decode32(header.data());
const uint32_t size = Decode32(header.data() + 4);
if (size > 1024 * 1024) return false;
payload->resize(size);
return size == 0 || ReadBytes(descriptor, payload->data(), payload->size());
}
bool WriteTestFrame(int descriptor, uint32_t opcode, std::string_view payload) {
std::array<unsigned char, 8> header{};
Encode32(opcode, header.data());
Encode32(static_cast<uint32_t>(payload.size()), header.data() + 4);
return send(descriptor, header.data(), header.size(), MSG_NOSIGNAL) ==
static_cast<ssize_t>(header.size()) &&
send(descriptor, payload.data(), payload.size(), MSG_NOSIGNAL) ==
static_cast<ssize_t>(payload.size());
}
TEST(DiscordRpcTest, BuildsEnglishBrowsingAndJoiningActivities) {
const DiscordRpcConfig config;
const DiscordRpcActivity browsing = BuildDiscordRpcActivity(
config, RobloxExperiencePresencePhase::kBrowsing, nullptr, {}, 0);
EXPECT_EQ(browsing.name, "Roblox");
EXPECT_EQ(browsing.details, "Browsing experiences");
EXPECT_TRUE(browsing.state.empty());
EXPECT_FALSE(browsing.start_timestamp.has_value());
EXPECT_TRUE(browsing.button_url.empty());
const RobloxExperienceLaunchRequest request = PublicServer();
const DiscordRpcActivity joining = BuildDiscordRpcActivity(
config, RobloxExperiencePresencePhase::kJoining, &request, {}, 0);
EXPECT_EQ(joining.details, "Joining an experience");
EXPECT_TRUE(joining.state.empty());
EXPECT_TRUE(joining.button_url.empty());
}
TEST(DiscordRpcTest, RequiresAReleaseApplicationIdWhenEnabled) {
DiscordRpcConfig config;
config.enabled = true;
DiscordRpcSession session(std::move(config));
std::string detail;
EXPECT_FALSE(session.Start(&detail));
EXPECT_NE(detail.find("application ID"), std::string::npos);
}
TEST(DiscordRpcTest, ShowsPlaceTimerAndPublicJoinWhenPlaying) {
const DiscordRpcConfig config;
const RobloxExperienceLaunchRequest request = PublicServer();
const DiscordRpcActivity activity = BuildDiscordRpcActivity(
config, RobloxExperiencePresencePhase::kPlaying, &request,
"Natural Disaster Survival", 1770000000, {}, "Stickmasterluke");
EXPECT_EQ(activity.name, "Roblox");
EXPECT_EQ(activity.details, "Playing Natural Disaster Survival");
EXPECT_EQ(activity.state, "by Stickmasterluke");
ASSERT_TRUE(activity.start_timestamp.has_value());
EXPECT_EQ(*activity.start_timestamp, 1770000000);
EXPECT_EQ(activity.button_label, "Join Server");
EXPECT_EQ(activity.button_url,
"https://komaruworld.github.io/mocktail/join.html#placeId="
"189707&gameInstanceId=job%2Fa%2Bb");
}
TEST(DiscordRpcTest, LeavesOutTextThatExpandsAnEmptyCreator) {
const RobloxExperienceLaunchRequest request = PublicServer();
const DiscordRpcActivity unknown_creator = BuildDiscordRpcActivity(
DiscordRpcConfig(), RobloxExperiencePresencePhase::kPlaying, &request,
"Natural Disaster Survival", 1);
EXPECT_EQ(unknown_creator.details, "Playing Natural Disaster Survival");
EXPECT_TRUE(unknown_creator.state.empty());
DiscordRpcConfig custom;
custom.text.state = "by {creator_name} on Linux";
EXPECT_TRUE(BuildDiscordRpcActivity(custom,
RobloxExperiencePresencePhase::kPlaying,
&request, "Natural Disaster Survival",
1)
.state.empty());
EXPECT_EQ(BuildDiscordRpcActivity(custom,
RobloxExperiencePresencePhase::kPlaying,
&request, "Natural Disaster Survival", 1,
{}, "Stickmasterluke")
.state,
"by Stickmasterluke on Linux");
}
TEST(DiscordRpcTest, ShowsExternalPlaceThumbnailWhenAvailable) {
const DiscordRpcConfig config;
const RobloxExperienceLaunchRequest request = PublicServer();
const DiscordRpcActivity activity = BuildDiscordRpcActivity(
config, RobloxExperiencePresencePhase::kPlaying, &request,
"Natural Disaster Survival", 1,
"https://tr.rbxcdn.com/example/512/512/Image/Png");
EXPECT_EQ(activity.large_image,
"https://tr.rbxcdn.com/example/512/512/Image/Png");
EXPECT_EQ(activity.large_text, "Natural Disaster Survival");
EXPECT_EQ(activity.small_image, "roblox_small");
EXPECT_EQ(activity.small_text, "Roblox");
}
TEST(DiscordRpcTest, ShowsPlaceJoinWhenServerIdIsUnavailable) {
const DiscordRpcConfig config;
RobloxExperienceLaunchRequest request = PublicServer();
request.game_instance_id.clear();
const DiscordRpcActivity activity = BuildDiscordRpcActivity(
config, RobloxExperiencePresencePhase::kPlaying, &request,
"Natural Disaster Survival", 1);
EXPECT_EQ(activity.button_label, "Join Server");
EXPECT_EQ(activity.button_url,
"https://komaruworld.github.io/mocktail/join.html#placeId="
"189707");
}
TEST(DiscordRpcTest, RejectsUnsafePlaceThumbnailUrl) {
const DiscordRpcConfig config;
const RobloxExperienceLaunchRequest request = PublicServer();
const DiscordRpcActivity activity = BuildDiscordRpcActivity(
config, RobloxExperiencePresencePhase::kPlaying, &request,
"Natural Disaster Survival", 1, "file:///tmp/not-an-image");
EXPECT_TRUE(activity.large_image.empty());
EXPECT_TRUE(activity.large_text.empty());
}
TEST(DiscordRpcTest, DoesNotPublishPrivateJoinMaterial) {
for (const auto private_field : {0, 1, 2}) {
RobloxExperienceLaunchRequest request = PublicServer();
if (private_field == 0) request.reserved_server_access_code = "secret";
if (private_field == 1) request.access_code = "secret";
if (private_field == 2) request.link_code = "secret";
EXPECT_FALSE(IsPublicDiscordJoin(request));
EXPECT_EQ(BuildDiscordJoinUrl(request).find("secret"), std::string::npos);
const DiscordRpcActivity activity = BuildDiscordRpcActivity(
DiscordRpcConfig(), RobloxExperiencePresencePhase::kPlaying, &request,
"Private experience", 1);
EXPECT_TRUE(activity.button_url.empty());
}
DiscordRpcConfig opted_in;
opted_in.public_servers_only = false;
RobloxExperienceLaunchRequest private_request = PublicServer();
private_request.reserved_server_access_code = "never-publish-this";
const DiscordRpcActivity opted_in_activity =
BuildDiscordRpcActivity(opted_in, RobloxExperiencePresencePhase::kPlaying,
&private_request, "Private experience", 1);
EXPECT_FALSE(opted_in_activity.button_url.empty());
EXPECT_EQ(opted_in_activity.button_url.find("never-publish-this"),
std::string::npos);
}
TEST(DiscordRpcTest, HidesPlaceNameAndElapsedTimeByPolicy) {
DiscordRpcConfig config;
config.show_place_name = false;
config.show_elapsed_time = false;
config.join_enabled = false;
const RobloxExperienceLaunchRequest request = PublicServer();
const DiscordRpcActivity activity = BuildDiscordRpcActivity(
config, RobloxExperiencePresencePhase::kPlaying, &request,
"Secret place name", 1770000000, {}, "Secret creator");
// The creator hides with the place, so the default state renders empty.
EXPECT_TRUE(activity.details.empty());
EXPECT_TRUE(activity.state.empty());
config.text.state = "Playing Roblox";
const DiscordRpcActivity fixed_state = BuildDiscordRpcActivity(
config, RobloxExperiencePresencePhase::kPlaying, &request,
"Secret place name", 1770000000, {}, "Secret creator");
EXPECT_EQ(fixed_state.details, "Playing Roblox");
EXPECT_TRUE(fixed_state.state.empty());
EXPECT_FALSE(activity.start_timestamp.has_value());
EXPECT_TRUE(activity.button_url.empty());
}
TEST(DiscordRpcTest, ExpandsCustomPlaceTemplateAndUsesFallback) {
DiscordRpcConfig config;
config.text.playing = "Playing {place_name} with Mocktail";
config.text.unknown_place = "Unknown world";
const RobloxExperienceLaunchRequest request = PublicServer();
EXPECT_EQ(
BuildDiscordRpcActivity(config, RobloxExperiencePresencePhase::kPlaying,
&request, "Natural Disaster Survival", 1)
.details,
"Playing Natural Disaster Survival with Mocktail");
EXPECT_EQ(
BuildDiscordRpcActivity(config, RobloxExperiencePresencePhase::kPlaying,
&request, {}, 1)
.details,
"Playing Unknown world with Mocktail");
}
TEST(DiscordRpcTest, RendersCustomTitleAndStateTemplates) {
DiscordRpcConfig config;
config.text.title = "{place_name}";
config.text.state = "In {place_name}";
const RobloxExperienceLaunchRequest request = PublicServer();
const DiscordRpcActivity playing =
BuildDiscordRpcActivity(config, RobloxExperiencePresencePhase::kPlaying,
&request, "Natural Disaster Survival", 1);
EXPECT_EQ(playing.name, "Natural Disaster Survival");
EXPECT_EQ(playing.state, "In Natural Disaster Survival");
const DiscordRpcActivity browsing = BuildDiscordRpcActivity(
config, RobloxExperiencePresencePhase::kBrowsing, nullptr, {}, 0);
EXPECT_TRUE(browsing.name.empty());
}
TEST(DiscordRpcTest, RendersCustomImagesWithPlaceholders) {
DiscordRpcConfig config;
config.images.large = "nightcap_logo";
config.images.large_text = "Nightcap";
config.images.small = "{place_icon}";
config.images.small_text = "{place_name}";
const RobloxExperienceLaunchRequest request = PublicServer();
const DiscordRpcActivity activity = BuildDiscordRpcActivity(
config, RobloxExperiencePresencePhase::kPlaying, &request,
"Natural Disaster Survival", 1,
"https://tr.rbxcdn.com/example/512/512/Image/Png");
EXPECT_EQ(activity.large_image, "nightcap_logo");
EXPECT_EQ(activity.large_text, "Nightcap");
EXPECT_EQ(activity.small_image,
"https://tr.rbxcdn.com/example/512/512/Image/Png");
EXPECT_EQ(activity.small_text, "Natural Disaster Survival");
}
TEST(DiscordRpcTest, EmptyLargeImageHidesBothImages) {
DiscordRpcConfig config;
config.images.large = "";
config.images.small = "nightcap_logo";
config.images.small_text = "Nightcap";
const RobloxExperienceLaunchRequest request = PublicServer();
const DiscordRpcActivity activity = BuildDiscordRpcActivity(
config, RobloxExperiencePresencePhase::kPlaying, &request,
"Natural Disaster Survival", 1,
"https://tr.rbxcdn.com/example/512/512/Image/Png");
EXPECT_TRUE(activity.large_image.empty());
EXPECT_TRUE(activity.large_text.empty());
EXPECT_TRUE(activity.small_image.empty());
EXPECT_TRUE(activity.small_text.empty());
}
TEST(DiscordRpcTest, DropsUnsafeCustomImages) {
const RobloxExperienceLaunchRequest request = PublicServer();
for (const char* unsafe :
{"file:///etc/passwd", "http://example.test/a.png", "not a key",
"{place_name}"}) {
DiscordRpcConfig config;
config.images.large = "nightcap_logo";
config.images.small = unsafe;
const DiscordRpcActivity small = BuildDiscordRpcActivity(
config, RobloxExperiencePresencePhase::kPlaying, &request,
"Natural Disaster Survival", 1);
EXPECT_TRUE(small.small_image.empty()) << unsafe;
config.images.large = unsafe;
const DiscordRpcActivity large = BuildDiscordRpcActivity(
config, RobloxExperiencePresencePhase::kPlaying, &request,
"Natural Disaster Survival", 1);
EXPECT_TRUE(large.large_image.empty()) << unsafe;
EXPECT_TRUE(large.large_text.empty()) << unsafe;
}
}
TEST(DiscordRpcTest, HiddenPlaceNameAlsoHidesPlaceImagesAndTitle) {
DiscordRpcConfig config;
config.show_place_name = false;
config.text.title = "{place_name}";
const RobloxExperienceLaunchRequest request = PublicServer();
const DiscordRpcActivity activity = BuildDiscordRpcActivity(
config, RobloxExperiencePresencePhase::kPlaying, &request,
"Secret place name", 1,
"https://tr.rbxcdn.com/example/512/512/Image/Png");
EXPECT_TRUE(activity.name.empty());
EXPECT_TRUE(activity.large_image.empty());
EXPECT_TRUE(activity.large_text.empty());
}
TEST(DiscordRpcTest, PublishesLifecycleActivitiesOverDiscordIpc) {
char directory_pattern[] = "/tmp/mocktail_discord_rpc_XXXXXX";
char* directory = mkdtemp(directory_pattern);
ASSERT_NE(directory, nullptr);
const std::filesystem::path socket_path =
std::filesystem::path(directory) / "discord-ipc-0";
const int listener = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
ASSERT_GE(listener, 0);
sockaddr_un address{};
address.sun_family = AF_UNIX;
ASSERT_LT(socket_path.string().size(), sizeof(address.sun_path));
std::memcpy(address.sun_path, socket_path.c_str(),
socket_path.string().size() + 1);
ASSERT_EQ(bind(listener, reinterpret_cast<const sockaddr*>(&address),
sizeof(address)),
0);
ASSERT_EQ(listen(listener, 1), 0);
const char* previous_runtime = std::getenv("XDG_RUNTIME_DIR");
const std::optional<std::string> saved_runtime =
previous_runtime != nullptr ? std::optional<std::string>(previous_runtime)
: std::nullopt;
ASSERT_EQ(setenv("XDG_RUNTIME_DIR", directory, 1), 0);
std::mutex mutex;
std::condition_variable condition;
std::string handshake_payload;
std::vector<std::string> activity_payloads;
std::thread server([&]() {
const int client = accept4(listener, nullptr, nullptr, SOCK_CLOEXEC);
if (client >= 0) {
timeval timeout{4, 0};
(void)setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &timeout,
sizeof(timeout));
uint32_t opcode = 0;
if (ReadTestFrame(client, &opcode, &handshake_payload) && opcode == 0 &&
WriteTestFrame(client, 1,
R"({"cmd":"DISPATCH","data":{},"evt":"READY"})")) {
for (int index = 0; index < 3; ++index) {
std::string activity_payload;
if (!ReadTestFrame(client, &opcode, &activity_payload) ||
opcode != 1) {
break;
}
std::lock_guard<std::mutex> lock(mutex);
activity_payloads.push_back(std::move(activity_payload));
condition.notify_all();
}
std::string clear_payload;
(void)ReadTestFrame(client, &opcode, &clear_payload);
}
(void)close(client);
}
});
DiscordRpcConfig config;
config.enabled = true;
config.application_id = "123456789012345678";
config.show_place_name = false;
config.join_enabled = false;
config.text.title = "Nightcap Test";
config.text.state = "Playing Roblox";
// Joining has no place yet, so this renders empty and must be left out.
config.text.joining = "{place_name}";
config.images.large = "nightcap_logo";
config.images.small = "linux";
config.images.small_text = "On Linux";
DiscordRpcSession session(std::move(config));
std::string detail;
ASSERT_TRUE(session.Start(&detail)) << detail;
{
std::unique_lock<std::mutex> lock(mutex);
EXPECT_TRUE(condition.wait_for(lock, std::chrono::seconds(4),
[&]() {
return activity_payloads.size() >= 1;
}));
}
RobloxExperienceLaunchRequest request = PublicServer();
// This IPC framing test intentionally avoids real Roblox metadata traffic.
request.place_id = 0;
const RobloxExperiencePresenceObserver observer = session.observer();
observer.notify(observer.context, RobloxExperiencePresencePhase::kJoining,
&request);
{
std::unique_lock<std::mutex> lock(mutex);
EXPECT_TRUE(condition.wait_for(lock, std::chrono::seconds(4),
[&]() {
return activity_payloads.size() >= 2;
}));
}
observer.notify(observer.context, RobloxExperiencePresencePhase::kPlaying,
&request);
{
std::unique_lock<std::mutex> lock(mutex);
EXPECT_TRUE(condition.wait_for(lock, std::chrono::seconds(4),
[&]() {
return activity_payloads.size() >= 3;
}));
}
session.Stop();
server.join();
EXPECT_NE(handshake_payload.find("123456789012345678"), std::string::npos);
ASSERT_EQ(activity_payloads.size(), 3U);
EXPECT_NE(activity_payloads[0].find("SET_ACTIVITY"), std::string::npos);
EXPECT_NE(activity_payloads[0].find("Browsing experiences"),
std::string::npos);
EXPECT_NE(activity_payloads[0].find("\"name\":\"Nightcap Test\""),
std::string::npos);
EXPECT_NE(activity_payloads[0].find("\"large_image\":\"nightcap_logo\""),
std::string::npos);
EXPECT_NE(activity_payloads[0].find("\"small_image\":\"linux\""),
std::string::npos);
EXPECT_NE(activity_payloads[0].find("\"small_text\":\"On Linux\""),
std::string::npos);
EXPECT_EQ(activity_payloads[1].find("\"details\""), std::string::npos);
EXPECT_NE(activity_payloads[2].find("Playing Roblox"), std::string::npos);
EXPECT_NE(activity_payloads[2].find("timestamps"), std::string::npos);
if (saved_runtime.has_value()) {
EXPECT_EQ(setenv("XDG_RUNTIME_DIR", saved_runtime->c_str(), 1), 0);
} else {
EXPECT_EQ(unsetenv("XDG_RUNTIME_DIR"), 0);
}
(void)close(listener);
std::error_code ignored;
std::filesystem::remove_all(directory, ignored);
}
} // namespace
} // namespace runtime
} // namespace mocktail