Cockatrice/tests/crash_reporter_test.cpp
sentry[bot] 9793a0df4a
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / vcpkg baseline (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Fedora 45 release (push) Blocked by required conditions
Build Desktop / Fedora 45 debug (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 sanitize (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 27 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
[Crash] Add lifecycle breadcrumbs for unattributable native crashes (#7813)
* [Crash] Add lifecycle breadcrumbs for unattributable native crashes

Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>

* Restore the native split host button on non-macOS styles

The merged quick-host control composed a QPushButton and a separate autoRaise arrow QToolButton, which Fusion draws as two adjacent controls. Version one of the quick-host branch used a single QToolButton with MenuButtonPopup, which every style draws as one native split button.

Use that split button on every style that can draw one, and keep the plain primary + settings button pair as the native macOS fallback.

* Enable hover zoom and the hover panel by default

Both hover triggers now default to Hover, with the existing 400 ms zoom delay and 200 ms panel delay. The Off trigger remains available for users who want the old behavior.

---------

Co-authored-by: Lukas Brübach <44814898+BruebachL@users.noreply.github.com>
Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
2026-11-08 23:06:21 +01:00

437 lines
19 KiB
C++

#include "client/crash/crash_reporter.h"
#include <QByteArray>
#include <QCoreApplication>
#include <QDir>
#include <QFile>
#include <QLockFile>
#include <QString>
#include <QSysInfo>
#include <QTemporaryDir>
#include <QTextStream>
#include <gtest/gtest.h>
#include <libcockatrice/settings/personal_settings.h>
#include <string>
namespace
{
TEST(CrashReporterTest, SanitizeLogLineStripsAnsiColorCodes)
{
ASSERT_EQ(CrashReporter::sanitizeLogLine(QStringLiteral("\033[32mhello\033[0m")), QStringLiteral("hello"));
ASSERT_EQ(CrashReporter::sanitizeLogLine(QStringLiteral("\033[1;31mfatal\033[0m")), QStringLiteral("fatal"));
ASSERT_EQ(CrashReporter::sanitizeLogLine(QStringLiteral("plain line")), QStringLiteral("plain line"));
}
TEST(CrashReporterTest, RedactCockatriceUrlsKeepsSchemeAndHostOnly)
{
ASSERT_EQ(
CrashReporter::redactCockatriceUrls(QStringLiteral("open cockatrice://opendeck?token=secret&user=me now")),
QStringLiteral("open cockatrice://opendeck now"));
}
TEST(CrashReporterTest, RedactCockatriceUrlsIsIdempotent)
{
// A secret-free URL must not match again after redaction (regression: this hung forever).
ASSERT_EQ(CrashReporter::redactCockatriceUrls(QStringLiteral("open cockatrice://opendeck now")),
QStringLiteral("open cockatrice://opendeck now"));
}
TEST(CrashReporterTest, RedactCockatriceUrlsLeavesOtherTextAlone)
{
ASSERT_EQ(CrashReporter::redactCockatriceUrls(QStringLiteral("https://cockatrice.github.io/?token=secret")),
QStringLiteral("https://cockatrice.github.io/?token=secret"));
ASSERT_EQ(CrashReporter::sanitizeLogLine(QStringLiteral("")), QStringLiteral(""));
}
TEST(CrashReporterTest, InitSkippedWithoutDsn)
{
CrashReporter reporter;
reporter.init(QString(), QStringLiteral("3.1.2"), QDir::tempPath() + QStringLiteral("/cockatrice-crash-test"),
PersonalSettings::CrashReportingMode::Ask);
ASSERT_FALSE(reporter.isReady());
reporter.shutdown();
ASSERT_FALSE(reporter.isReady());
}
TEST(CrashReporterTest, InitSkippedWhenNeverSend)
{
CrashReporter reporter;
reporter.init(QStringLiteral("https://key@sentry.io/1"), QStringLiteral("3.1.2"),
QDir::tempPath() + QStringLiteral("/cockatrice-crash-test"),
PersonalSettings::CrashReportingMode::NeverSend);
ASSERT_FALSE(reporter.isReady());
reporter.shutdown();
}
// The init() tests above compile to no-ops when the SDK is off, so they pass
// regardless of the gating; the gating decision itself is covered here, in both
// build modes, through the SDK-independent helper init() consults.
TEST(CrashReporterTest, ReportingEnabledGating)
{
using Mode = PersonalSettings::CrashReportingMode;
const QString dsn = QStringLiteral("https://key@sentry.io/1");
ASSERT_FALSE(CrashReporter::isReportingEnabled(QString(), Mode::Ask));
ASSERT_FALSE(CrashReporter::isReportingEnabled(QString(), Mode::AlwaysSend));
ASSERT_FALSE(CrashReporter::isReportingEnabled(QString(), Mode::NeverSend));
ASSERT_TRUE(CrashReporter::isReportingEnabled(dsn, Mode::Ask));
ASSERT_TRUE(CrashReporter::isReportingEnabled(dsn, Mode::AlwaysSend));
ASSERT_FALSE(CrashReporter::isReportingEnabled(dsn, Mode::NeverSend));
}
TEST(CrashReporterTest, IsDsnWellFormedAcceptsWhatTheBuildAcceptsAndRejectsUiLinks)
{
ASSERT_TRUE(CrashReporter::isDsnWellFormed(QStringLiteral("https://abc123@o0.ingest.sentry.io/1234567")));
ASSERT_TRUE(CrashReporter::isDsnWellFormed(QStringLiteral("http://key@localhost:9000/42")));
// Unanchored like cmake/CrashReporting.cmake's MATCHES, so a DSN the build baked is never
// rejected here: a trailing path still matches.
ASSERT_TRUE(CrashReporter::isDsnWellFormed(QStringLiteral("https://key@host.example/7/extra")));
// The project-settings URL is the mistake a tester actually makes.
ASSERT_FALSE(CrashReporter::isDsnWellFormed(
QStringLiteral("https://sentry.io/settings/cockatrice/projects/cockatrice-desktop/keys/")));
ASSERT_FALSE(CrashReporter::isDsnWellFormed(QStringLiteral("https://key@host.example/not-a-number")));
ASSERT_FALSE(CrashReporter::isDsnWellFormed(QStringLiteral("o0.ingest.sentry.io/1234567")));
ASSERT_FALSE(CrashReporter::isDsnWellFormed(QString()));
}
TEST(CrashReporterTest, DescribeDsnNamesHostAndProjectWithoutTheKey)
{
const QString described =
CrashReporter::describeDsn(QStringLiteral("https://supersecretkey@o0.ingest.sentry.io/1234567"));
ASSERT_EQ(described, QStringLiteral("o0.ingest.sentry.io (project 1234567)"));
ASSERT_FALSE(described.contains(QStringLiteral("supersecretkey")));
ASSERT_TRUE(CrashReporter::describeDsn(QStringLiteral("not a dsn")).isEmpty());
ASSERT_TRUE(CrashReporter::describeDsn(QString()).isEmpty());
}
TEST(CrashReporterTest, DescribeOriginMismatchReportsOnlyRealDestinationChanges)
{
const QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString envelopePath = dir.filePath(QStringLiteral("crash.envelope"));
QFile file(envelopePath);
ASSERT_TRUE(file.open(QIODevice::WriteOnly));
file.write(QByteArrayLiteral("{\"event_id\":\"x\",\"dsn\":\"https://keyA@hostA.example/111\"}\n"));
file.write(QByteArrayLiteral("{\"type\":\"event\",\"length\":2}\n{}\n"));
file.close();
const QString described =
CrashReporter::describeOriginMismatch(envelopePath, QStringLiteral("https://keyB@hostB.example/222"));
ASSERT_FALSE(described.isEmpty());
ASSERT_TRUE(described.contains(QStringLiteral("hostA.example")));
ASSERT_TRUE(described.contains(QStringLiteral("hostB.example")));
ASSERT_FALSE(described.contains(QStringLiteral("keyA")));
ASSERT_FALSE(described.contains(QStringLiteral("keyB")));
// A different key for the SAME host and project is not a routing change and must stay quiet.
ASSERT_TRUE(
CrashReporter::describeOriginMismatch(envelopePath, QStringLiteral("https://otherkey@hostA.example/111"))
.isEmpty());
// An unreadable envelope, a header without a dsn, or no current dsn are all silent.
ASSERT_TRUE(CrashReporter::describeOriginMismatch(dir.filePath(QStringLiteral("absent.envelope")),
QStringLiteral("https://keyB@hostB.example/222"))
.isEmpty());
ASSERT_TRUE(CrashReporter::describeOriginMismatch(envelopePath, QString()).isEmpty());
const QString headerlessPath = dir.filePath(QStringLiteral("no-dsn.envelope"));
QFile headerless(headerlessPath);
ASSERT_TRUE(headerless.open(QIODevice::WriteOnly));
headerless.write(QByteArrayLiteral("{\"event_id\":\"y\"}\n"));
headerless.close();
ASSERT_TRUE(CrashReporter::describeOriginMismatch(headerlessPath, QStringLiteral("https://keyB@hostB.example/222"))
.isEmpty());
}
TEST(CrashReporterTest, ShutdownWithoutInitIsSafe)
{
CrashReporter reporter;
ASSERT_FALSE(reporter.isReady());
reporter.shutdown();
ASSERT_FALSE(reporter.isReady());
}
TEST(CrashReporterTest, ConsentAndSendWithoutDsnAreSafeNoOps)
{
CrashReporter reporter;
reporter.init(QString(), QStringLiteral("3.1.2"), QDir::tempPath() + QStringLiteral("/cockatrice-crash-test"),
PersonalSettings::CrashReportingMode::Ask);
ASSERT_FALSE(reporter.isReady());
reporter.giveConsent();
reporter.revokeConsent();
ASSERT_FALSE(reporter.isReady());
ASSERT_FALSE(reporter.sendReport(QStringLiteral("preview")));
ASSERT_FALSE(reporter.sendReport(QString()));
reporter.shutdown();
ASSERT_FALSE(reporter.isReady());
}
namespace
{
QByteArray readFileBytes(const QString &path)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) {
return {};
}
return file.readAll();
}
bool writeFileBytes(const QString &path, const QByteArray &content)
{
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
return false;
}
return file.write(content) == content.size();
}
QString uuidOf(char marker)
{
// 36 chars, 4 hyphens — structurally a UUID, content irrelevant for these tests.
return QStringLiteral("00000000-0000-4000-8000-00000000000%1").arg(marker);
}
} // namespace
TEST(CrashReporterTest, AppendAttachmentAppendsWellFormedItemAfterExistingContent)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = dir.filePath(QStringLiteral("event.envelope"));
// Existing envelope content deliberately WITHOUT a trailing newline.
const QByteArray original = QByteArrayLiteral("{\"event_id\":\"x\"}\n{\"type\":\"event\",\"length\":2}\n{}");
ASSERT_TRUE(writeFileBytes(path, original));
ASSERT_TRUE(
CrashReporter::appendAttachmentToEnvelope(path, QStringLiteral("crash-log.txt"), QByteArrayLiteral("hello")));
const QByteArray expected = original + '\n' +
QByteArrayLiteral("{\"type\":\"attachment\",\"length\":5,\"attachment_length\":5,"
"\"filename\":\"crash-log.txt\",\"content_type\":\"text/plain\"}\n") +
QByteArrayLiteral("hello");
ASSERT_EQ(readFileBytes(path), expected);
}
TEST(CrashReporterTest, AppendAttachmentOnEmptyFileHasNoLeadingNewline)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = dir.filePath(QStringLiteral("event.envelope"));
ASSERT_TRUE(writeFileBytes(path, QByteArray()));
ASSERT_TRUE(
CrashReporter::appendAttachmentToEnvelope(path, QStringLiteral("user-note.txt"), QByteArrayLiteral("note")));
const QByteArray expected = QByteArrayLiteral("{\"type\":\"attachment\",\"length\":4,\"attachment_length\":4,"
"\"filename\":\"user-note.txt\",\"content_type\":\"text/plain\"}\n") +
QByteArrayLiteral("note");
ASSERT_EQ(readFileBytes(path), expected);
}
TEST(CrashReporterTest, AppendAttachmentRejectsMissingFile)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
ASSERT_FALSE(CrashReporter::appendAttachmentToEnvelope(dir.filePath(QStringLiteral("missing.envelope")),
QStringLiteral("crash-log.txt"), QByteArrayLiteral("x")));
}
TEST(CrashReporterTest, AppendAttachmentLengthIsByteAccurate)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = dir.filePath(QStringLiteral("event.envelope"));
ASSERT_TRUE(writeFileBytes(path, QByteArrayLiteral("{}")));
const QByteArray utf8Payload = QString::fromUtf8("héllo").toUtf8();
ASSERT_EQ(utf8Payload.size(), 6); // 5 characters, "é" is 2 bytes
ASSERT_TRUE(CrashReporter::appendAttachmentToEnvelope(path, QStringLiteral("crash-log.txt"), utf8Payload));
const QByteArray content = readFileBytes(path);
ASSERT_TRUE(content.contains(QByteArrayLiteral("\"length\":6")));
ASSERT_TRUE(content.contains(QByteArrayLiteral("\"attachment_length\":6")));
ASSERT_TRUE(content.endsWith(utf8Payload));
}
// The SDK's envelope re-parser frames each item's payload by its `length` header; a
// missing or wrong length ends the payload at the first newline, so the remaining log
// lines get parsed as item headers and the whole envelope is rejected (and deleted).
TEST(CrashReporterTest, AppendAttachmentKeepsMultiLinePayloadIntact)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = dir.filePath(QStringLiteral("event.envelope"));
ASSERT_TRUE(writeFileBytes(path, QByteArrayLiteral("{}")));
const QByteArray payload = QByteArrayLiteral("first line\nsecond line\nthird line");
ASSERT_TRUE(CrashReporter::appendAttachmentToEnvelope(path, QStringLiteral("crash-log.txt"), payload));
const QByteArray content = readFileBytes(path);
ASSERT_TRUE(content.contains("\"length\":" + QByteArray::number(payload.size())));
ASSERT_TRUE(content.contains("\"attachment_length\":" + QByteArray::number(payload.size())));
ASSERT_TRUE(content.endsWith(payload));
}
TEST(CrashReporterTest, PendingEnvelopePathsMatchesSdkNamingInCacheAndRunDirs)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString dbPath = dir.path();
ASSERT_TRUE(QDir().mkpath(dbPath + QStringLiteral("/cache")));
ASSERT_TRUE(QDir().mkpath(dbPath + QStringLiteral("/aaaa.run")));
ASSERT_TRUE(QDir().mkpath(dbPath + QStringLiteral("/other")));
const QString retryName = QStringLiteral("1700000000000-0-") + uuidOf('a') + QStringLiteral(".envelope");
const QString bareName = uuidOf('b') + QStringLiteral(".envelope");
// Valid: retry-style cache file + bare run-dir file.
ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/cache/") + retryName, QByteArrayLiteral("{}")));
ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/aaaa.run/") + bareName, QByteArrayLiteral("{}")));
// Rejected: junk names and the SDK's own marker file.
ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/cache/") + uuidOf('c') + QStringLiteral(".envelope.bak"),
QByteArrayLiteral("{}")));
ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/cache/") + QStringLiteral("random.envelope"),
QByteArrayLiteral("{}")));
ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/aaaa.run/") + QStringLiteral("__sentry-crash.envelope"),
QByteArrayLiteral("{}")));
ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/other/") + bareName, QByteArrayLiteral("{}")));
const QStringList found = CrashReporter::pendingEnvelopePaths(dbPath);
ASSERT_EQ(found.size(), 2);
ASSERT_TRUE(found.contains(dbPath + QStringLiteral("/cache/") + retryName));
ASSERT_TRUE(found.contains(dbPath + QStringLiteral("/aaaa.run/") + bareName));
}
TEST(CrashReporterTest, PendingEnvelopePathsWithoutDatabaseIsEmpty)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
ASSERT_TRUE(CrashReporter::pendingEnvelopePaths(dir.filePath(QStringLiteral("no-such-db"))).isEmpty());
ASSERT_TRUE(CrashReporter::pendingEnvelopePaths(QString()).isEmpty());
}
TEST(CrashReporterTest, AttachFoldsLogAndNoteIntoPendingEnvelopes)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString dbPath = dir.path();
ASSERT_TRUE(QDir().mkpath(dbPath + QStringLiteral("/cache")));
const QString envelopePath = dbPath + QStringLiteral("/cache/") + uuidOf('d') + QStringLiteral(".envelope");
ASSERT_TRUE(writeFileBytes(envelopePath, QByteArrayLiteral("{\"event_id\":\"x\"}\n{}\n")));
CrashReporter reporter;
// Empty DSN: no SDK, but init() still records the database path the dialog uses.
reporter.init(QString(), QStringLiteral("3.1.2"), dbPath, PersonalSettings::CrashReportingMode::Ask);
ASSERT_EQ(reporter.attachToPendingCrashEnvelopes(QStringLiteral("log line"), QStringLiteral("my note")), 1);
const QByteArray content = readFileBytes(envelopePath);
ASSERT_TRUE(content.startsWith(QByteArrayLiteral("{\"event_id\":\"x\"}\n{}\n")));
ASSERT_TRUE(content.contains(QByteArrayLiteral("\"filename\":\"crash-log.txt\"")));
ASSERT_TRUE(content.contains(QByteArrayLiteral("\nlog line")));
ASSERT_TRUE(content.contains(QByteArrayLiteral("\"filename\":\"user-note.txt\"")));
ASSERT_TRUE(content.endsWith(QByteArrayLiteral("\nmy note")));
}
TEST(CrashReporterTest, AttachSkipsNoteWhenEmptyAndFindsRunDirEnvelopes)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString dbPath = dir.path();
ASSERT_TRUE(QDir().mkpath(dbPath + QStringLiteral("/bbbb.run")));
const QString envelopePath = dbPath + QStringLiteral("/bbbb.run/") + uuidOf('e') + QStringLiteral(".envelope");
ASSERT_TRUE(writeFileBytes(envelopePath, QByteArrayLiteral("{}")));
// Static overload (the pre-init AlwaysSend path) with an empty note.
ASSERT_EQ(CrashReporter::attachToPendingCrashEnvelopes(dbPath, QStringLiteral("log only"), QString()), 1);
const QByteArray content = readFileBytes(envelopePath);
ASSERT_TRUE(content.contains(QByteArrayLiteral("\"filename\":\"crash-log.txt\"")));
ASSERT_FALSE(content.contains(QByteArrayLiteral("user-note.txt")));
}
TEST(CrashReporterTest, BeginSessionOnEmptyDirReportsClean)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
CrashReporter reporter;
ASSERT_FALSE(reporter.beginSession(dir.path()));
reporter.endSession();
}
TEST(CrashReporterTest, BeginSessionIgnoresLiveConcurrentInstance)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
// A second QLockFile held in-process stands in for a live concurrent instance: same
// pid, alive, so the scan must leave it alone and report a clean start.
QLockFile liveInstance(dir.filePath(QStringLiteral("session-424242")));
ASSERT_TRUE(liveInstance.tryLock(0));
CrashReporter reporter;
ASSERT_FALSE(reporter.beginSession(dir.path()));
ASSERT_TRUE(QFile::exists(liveInstance.fileName()));
reporter.endSession();
liveInstance.unlock();
}
TEST(CrashReporterTest, BeginSessionDetectsStaleDeadOwnerLock)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
// A well-formed lock whose owner is dead: our own pid (so the host matches) but a
// process name that cannot match, which is how QLockFile spots pid reuse. Must be
// reported unclean and cleaned up, even though the file is brand new (this is why
// the scan probes with staleLockTime 0 rather than the 30 s default).
const QString stalePath = dir.filePath(QStringLiteral("session-stale-owner"));
QFile stale(stalePath);
ASSERT_TRUE(stale.open(QIODevice::WriteOnly | QIODevice::Text));
QTextStream out(&stale);
out << QCoreApplication::applicationPid() << '\n'
<< QStringLiteral("definitely-not-the-test-binary") << '\n'
<< QSysInfo::machineHostName() << '\n';
stale.close();
CrashReporter reporter;
ASSERT_TRUE(reporter.beginSession(dir.path()));
ASSERT_FALSE(QFile::exists(stalePath));
reporter.endSession();
}
TEST(CrashReporterTest, BeginSessionCleansGarbageFileAndReportsUnclean)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
// An unreadable lock file has no live owner by definition: report unclean and remove it.
const QString garbagePath = dir.filePath(QStringLiteral("session-garbage"));
QFile garbage(garbagePath);
ASSERT_TRUE(garbage.open(QIODevice::WriteOnly));
garbage.write("not-a-lock-file\x00\xff", 17);
garbage.close();
CrashReporter reporter;
ASSERT_TRUE(reporter.beginSession(dir.path()));
ASSERT_FALSE(QFile::exists(garbagePath));
reporter.endSession();
}
TEST(CrashReporterTest, BeginSessionEndSessionRoundTripReportsClean)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
CrashReporter reporter;
ASSERT_FALSE(reporter.beginSession(dir.path()));
reporter.endSession();
ASSERT_FALSE(reporter.beginSession(dir.path()));
reporter.endSession();
}
TEST(CrashReporterTest, AddBreadcrumbIsSafeWithoutInitAndWithNullArguments)
{
// Breadcrumbs are emitted from GUI slots and QtConcurrent workers regardless of whether
// the SDK was initialised (empty DSN, Never consent, OFF builds); none of these may crash.
CrashReporter::addBreadcrumb("card_db", "card database snapshot swapped in");
CrashReporter::addBreadcrumb(nullptr, "message");
CrashReporter::addBreadcrumb("category", nullptr);
SUCCEED();
}
} // namespace
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}