remove updater tests after validation

This commit is contained in:
nmzik 2026-10-10 05:31:08 +02:00
parent b41c23d338
commit f1764a0bba
5 changed files with 9 additions and 488 deletions

View File

@ -116,12 +116,12 @@ jobs:
audio_out2_port_tests pad_haptics_tests controller_settings_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests ^
shader_recompiler_compute_tests ^
resource_tracking_tests scalar_provenance_tests ^
resource_materialization_tests shader_vertex_metadata_tests archive_file_tests avplayer_file_tests update_release_tests update_installer_tests --parallel
resource_materialization_tests shader_vertex_metadata_tests archive_file_tests avplayer_file_tests --parallel
- name: Test
shell: cmd
run: |
ctest --test-dir _Build/windows --output-on-failure -R "^(audio_out2_port|pad_haptics|controller_settings|ime_dialog|lru_cache|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|archive_file|avplayer_file|virtual_memory_allocation|update_release|update_installer)$"
ctest --test-dir _Build/windows --output-on-failure -R "^(audio_out2_port|pad_haptics|controller_settings|ime_dialog|lru_cache|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|archive_file|avplayer_file|virtual_memory_allocation)$"
- name: Install
shell: cmd
@ -205,14 +205,14 @@ jobs:
--target launcher audio_out2_port_tests pad_haptics_tests controller_settings_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests \
memory_tracker_tests page_manager_tests shader_recompiler_compute_tests \
resource_tracking_tests scalar_provenance_tests \
resource_materialization_tests shader_vertex_metadata_tests archive_file_tests avplayer_file_tests update_release_tests update_installer_tests \
resource_materialization_tests shader_vertex_metadata_tests archive_file_tests avplayer_file_tests \
--parallel
- name: Test
shell: bash
run: |
ctest --test-dir _Build/macos --output-on-failure \
-R '^(audio_out2_port|pad_haptics|controller_settings|ime_dialog|lru_cache|memory_tracker|page_manager|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|archive_file|avplayer_file|virtual_memory_allocation|update_release|update_installer)$'
-R '^(audio_out2_port|pad_haptics|controller_settings|ime_dialog|lru_cache|memory_tracker|page_manager|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|archive_file|avplayer_file|virtual_memory_allocation)$'
- name: Install
shell: bash
@ -390,14 +390,14 @@ jobs:
--target launcher page_manager_tests memory_tracker_tests \
audio_out2_port_tests pad_haptics_tests controller_settings_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests \
resource_tracking_tests scalar_provenance_tests \
resource_materialization_tests shader_vertex_metadata_tests archive_file_tests avplayer_file_tests update_release_tests update_installer_tests \
resource_materialization_tests shader_vertex_metadata_tests archive_file_tests avplayer_file_tests \
--parallel
- name: Test
shell: bash
run: |
ctest --test-dir _Build/linux --output-on-failure \
-R '^(audio_out2_port|pad_haptics|controller_settings|ime_dialog|lru_cache|memory_tracker|page_manager|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|archive_file|avplayer_file|virtual_memory_allocation|update_release|update_installer)$'
-R '^(audio_out2_port|pad_haptics|controller_settings|ime_dialog|lru_cache|memory_tracker|page_manager|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|archive_file|avplayer_file|virtual_memory_allocation)$'
- name: Install
shell: bash

View File

@ -51,15 +51,6 @@ add_custom_target(stage_updater
VERBATIM)
add_dependencies(launcher kyty_emulator stage_updater)
if(BUILD_TESTING)
add_executable(update_release_tests EXCLUDE_FROM_ALL
"${KYTY_TESTS_DIR}/UpdateReleaseTests.cpp" src/updateRelease.cpp)
target_include_directories(update_release_tests PRIVATE include)
target_link_libraries(update_release_tests PRIVATE Qt6::Core)
add_test(NAME update_release COMMAND update_release_tests)
add_dependencies(kyty_tests update_release_tests)
endif()
if(KYTY_CLANG_CL)
add_custom_command(TARGET launcher POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different $<TARGET_PDB_FILE:kyty_emulator> $<TARGET_FILE_DIR:launcher>/$<TARGET_PDB_FILE_NAME:kyty_emulator>

View File

@ -3,24 +3,13 @@
if(MSVC)
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
endif()
add_library(update_installer STATIC UpdateInstaller.cpp)
target_include_directories(update_installer PUBLIC "${KYTY_SOURCE_DIR}")
add_executable(kyty_updater main.cpp)
target_link_libraries(kyty_updater PRIVATE update_installer)
add_executable(kyty_updater main.cpp UpdateInstaller.cpp)
if(MSVC)
target_compile_options(update_installer PUBLIC /EHsc)
target_compile_options(kyty_updater PRIVATE /EHsc)
else()
target_compile_options(update_installer PUBLIC -fexceptions)
target_compile_options(kyty_updater PRIVATE -fexceptions)
endif()
if(MINGW)
target_link_options(kyty_updater PRIVATE -static -municode)
endif()
if(BUILD_TESTING)
add_executable(update_installer_tests EXCLUDE_FROM_ALL "${KYTY_TESTS_DIR}/UpdateInstallerTests.cpp")
target_link_libraries(update_installer_tests PRIVATE update_installer)
add_dependencies(update_installer_tests kyty_updater)
add_test(NAME update_installer COMMAND update_installer_tests $<TARGET_FILE:kyty_updater>)
add_dependencies(kyty_tests update_installer_tests)
endif()

View File

@ -1,335 +0,0 @@
#include "updater/UpdateInstaller.h"
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <string>
#include <thread>
#ifdef _WIN32
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#else
#include <csignal>
#include <sys/wait.h>
#include <unistd.h>
#endif
namespace {
namespace fs = std::filesystem;
using Kyty::Updater::Install;
using Kyty::Updater::Validate;
void Check(bool condition, const char* message) {
if (!condition) {
std::fprintf(stderr, "UpdateInstallerTests: %s\n", message);
std::abort();
}
}
std::string Read(const fs::path& path) {
std::ifstream input(path);
return {std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
}
void Write(const fs::path& path, const std::string& content) {
fs::create_directories(path.parent_path());
std::ofstream(path) << content;
fs::permissions(path, fs::perms::owner_all, fs::perm_options::add);
}
struct Fixture {
fs::path root = fs::temp_directory_path() /
("kyty-updater-test-" +
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
fs::path source = root / "release";
fs::path target = root / "installed";
#ifdef _WIN32
fs::path launcher = "launcher.exe";
const char* emulator = "kyty_emulator.exe";
const char* updater = "kyty_updater.exe";
#else
fs::path launcher = "launcher";
const char* emulator = "kyty_emulator";
const char* updater = "kyty_updater";
#endif
Fixture() {
fs::create_directories(target);
Release();
Write(target / launcher, "old launcher");
}
void Release() {
Write(source / launcher, "new launcher");
Write(source / emulator, "new emulator");
Write(source / updater, "new updater");
}
~Fixture() {
std::error_code ignored;
fs::remove_all(root, ignored);
}
};
void TestOverlayPreservesUserFiles() {
Fixture fixture;
Write(fixture.target / "_SaveData" / "save.dat", "valuable save");
Write(fixture.target / "config.ini", "user settings");
Write(fixture.source / "lib" / "runtime", "new runtime");
Check(Validate(fixture.source, fixture.target, fixture.launcher).success,
"valid release rejected");
Check(Install(fixture.source, fixture.target, fixture.launcher).success,
"valid release failed to install");
Check(Read(fixture.target / fixture.launcher) == "new launcher", "launcher was not updated");
Check(!fs::exists(fixture.source / fixture.launcher),
"update copied rather than moved its staged executable");
Check(Read(fixture.target / "lib" / "runtime") == "new runtime",
"nested runtime was not installed");
Check(Read(fixture.target / "_SaveData" / "save.dat") == "valuable save",
"savedata was removed");
Check(Read(fixture.target / "config.ini") == "user settings", "settings were overwritten");
for (const auto& entry: fs::directory_iterator(fixture.target)) {
Check(!entry.path().filename().string().starts_with(".kyty-update"),
"successful update left a backup");
}
}
void TestExclusiveInstallLock() {
Fixture fixture;
fs::create_directory(fixture.target / ".kyty-update-lock");
Check(!Validate(fixture.source, fixture.target, fixture.launcher).success,
"validation ignored existing install lock");
const auto result = Install(fixture.source, fixture.target, fixture.launcher);
Check(!result.success, "concurrent install lock was ignored");
Check(Read(fixture.target / fixture.launcher) == "old launcher",
"locked update changed installed files");
Check(fs::exists(fixture.target / ".kyty-update-lock"), "another updater's lock was removed");
}
void TestValidation() {
Fixture fixture;
Check(!Validate(fixture.source, fixture.target, "../launcher").success,
"parent traversal accepted");
Check(!Validate(fixture.source, fixture.target, fixture.source / fixture.launcher).success,
"absolute launcher accepted");
Check(!Validate(fixture.source, fixture.source, fixture.launcher).success,
"overlapping source accepted");
fs::remove(fixture.source / fixture.updater);
Check(!Validate(fixture.source, fixture.target, fixture.launcher).success,
"missing updater accepted");
Write(fixture.source / fixture.updater, "updater");
Write(fixture.source / ".kyty-update-result", "spoofed result");
Check(!Validate(fixture.source, fixture.target, fixture.launcher).success,
"reserved updater file accepted");
fs::remove(fixture.source / ".kyty-update-result");
Write(fixture.target / "lib", "user file");
Write(fixture.source / "lib" / "runtime", "runtime");
Check(!Install(fixture.source, fixture.target, fixture.launcher).success,
"directory/file collision accepted");
Check(Read(fixture.target / fixture.launcher) == "old launcher",
"validation failure changed existing launcher");
}
#ifndef _WIN32
void TestSymlinks() {
Fixture fixture;
Write(fixture.source / "lib" / "runtime.2", "runtime v2");
fs::create_symlink("runtime.2", fixture.source / "lib" / "runtime");
const auto first = Install(fixture.source, fixture.target, fixture.launcher);
if (!first.success) {
std::fprintf(stderr, "%s\n", first.message.c_str());
}
Check(first.success, "internal release symlink rejected");
Check(fs::is_symlink(fixture.target / "lib" / "runtime"), "release symlink was dereferenced");
Check(Read(fixture.target / "lib" / "runtime") == "runtime v2",
"installed symlink target is wrong");
fixture.Release();
Write(fixture.source / "lib" / "runtime.2", "runtime v3");
fs::create_symlink("runtime.2", fixture.source / "lib" / "runtime");
Check(Install(fixture.source, fixture.target, fixture.launcher).success,
"second update rejected existing internal link");
fixture.Release();
fs::create_symlink("../../installed/launcher", fixture.source / "lib" / "escape");
Check(!Validate(fixture.source, fixture.target, fixture.launcher).success,
"escaping source symlink accepted");
fs::remove(fixture.source / "lib" / "escape");
fs::create_symlink(fixture.source / fixture.launcher, fixture.source / "absolute-link");
Check(!Validate(fixture.source, fixture.target, fixture.launcher).success,
"absolute source symlink accepted");
fs::remove(fixture.source / "absolute-link");
fs::remove_all(fixture.target / "lib");
fs::create_directory(fixture.root / "user-data");
fs::create_directory_symlink(fixture.root / "user-data", fixture.target / "lib");
Check(!Validate(fixture.source, fixture.target, fixture.launcher).success,
"destination symlink directory accepted");
Check(fs::is_empty(fixture.root / "user-data"), "validation wrote through destination symlink");
}
void TestExecutePermissions() {
Fixture fixture;
fs::permissions(fixture.source / fixture.launcher,
fs::perms::owner_read | fs::perms::owner_write);
Check(!Validate(fixture.source, fixture.target, fixture.launcher).success,
"non-executable launcher accepted");
}
#endif
void TestRollback() {
#ifndef _WIN32
// A root test runner bypasses file permissions; normal users exercise a real
// rename failure after the launcher and a new nested file have been
// installed.
if (geteuid() == 0) {
return;
}
#endif
Fixture fixture;
Write(fixture.source / "a-new" / "runtime", "new file");
Write(fixture.source / "z-locked" / "runtime", "new library");
Write(fixture.target / "z-locked" / "runtime", "old library");
#ifdef _WIN32
HANDLE locked_file =
CreateFileW((fixture.target / "z-locked" / "runtime").c_str(), GENERIC_READ,
FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
Check(locked_file != INVALID_HANDLE_VALUE, "could not lock destination for rollback test");
#else
fs::permissions(fixture.target / "z-locked", fs::perms::owner_read | fs::perms::owner_exec);
#endif
const auto result = Install(fixture.source, fixture.target, fixture.launcher);
#ifdef _WIN32
CloseHandle(locked_file);
#else
fs::permissions(fixture.target / "z-locked", fs::perms::owner_all);
#endif
Check(!result.success, "locked destination did not fail the transaction");
Check(Read(fixture.source / fixture.launcher) == "new launcher",
"rollback did not restore the staged release");
Check(Read(fixture.target / fixture.launcher) == "old launcher",
"rollback did not restore the launcher");
Check(Read(fixture.target / "z-locked" / "runtime") == "old library",
"rollback damaged an unchanged file");
Check(!fs::exists(fixture.target / "a-new"), "rollback retained newly installed directories");
Check(!fs::exists(fixture.target / fixture.emulator),
"rollback retained a newly installed executable");
}
#ifndef _WIN32
void TestCommandLine(const fs::path& helper) {
for (int scenario = 0; scenario < 3; ++scenario) {
Fixture fixture;
const auto workspace = fixture.target / ".kyty-update-test";
fs::create_directory(workspace);
fs::rename(fixture.source, workspace / "payload");
fixture.source = workspace / "payload";
const auto original_cwd = fixture.root / "original working directory";
fs::create_directory(original_cwd);
const std::string old_launcher =
"#!/bin/sh\nprintf 'old\\n' > restarted-version\npwd > restarted-cwd\n";
Write(fixture.target / fixture.launcher, old_launcher);
Write(fixture.source / fixture.launcher,
"#!/bin/sh\nprintf 'new\\n' > restarted-version\npwd > "
"restarted-cwd\n");
if (scenario == 1) {
// Invalid payload exercises the CLI's failed-install/old-launcher path.
fs::remove(fixture.source / fixture.updater);
} else if (scenario == 2) {
Write(fixture.target / ".kyty-update-result" / "blocker", "keep");
}
int parent_pipe[2];
Check(pipe(parent_pipe) == 0, "cannot create parent synchronization pipe");
const auto parent = fork();
Check(parent >= 0, "cannot fork simulated launcher");
if (parent == 0) {
close(parent_pipe[1]);
char signal;
static_cast<void>(read(parent_pipe[0], &signal, 1));
_exit(0);
}
close(parent_pipe[0]);
const auto parent_pid = std::to_string(parent);
const auto updater = fork();
Check(updater >= 0, "cannot fork update helper");
if (updater == 0) {
close(parent_pipe[1]);
execl(helper.c_str(), helper.c_str(), "--install", fixture.source.c_str(),
fixture.target.c_str(), fixture.launcher.c_str(), parent_pid.c_str(),
original_cwd.c_str(), static_cast<char*>(nullptr));
_exit(127);
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
int status = 0;
Check(waitpid(updater, &status, WNOHANG) == 0, "helper exited before its parent");
Check(Read(fixture.target / fixture.launcher) == old_launcher,
"helper updated a running launcher");
close(parent_pipe[1]);
waitpid(parent, nullptr, 0);
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
pid_t finished;
while ((finished = waitpid(updater, &status, WNOHANG)) == 0 &&
std::chrono::steady_clock::now() < deadline) {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
if (finished == 0) {
kill(updater, SIGKILL);
waitpid(updater, nullptr, 0);
}
Check(finished == updater && WIFEXITED(status) &&
WEXITSTATUS(status) == (scenario == 1 ? 1 : 0),
"helper did not complete with the expected status");
while (Read(original_cwd / "restarted-cwd").empty() &&
std::chrono::steady_clock::now() < deadline) {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
Check(Read(original_cwd / "restarted-version") == (scenario == 1 ? "old\n" : "new\n"),
"wrong launcher restarted");
Check(Read(original_cwd / "restarted-cwd") == fs::canonical(original_cwd).string() + "\n",
"restart changed the working directory");
if (scenario != 2) {
Check(Read(fixture.target / ".kyty-update-result")
.starts_with(scenario == 1 ? "failure\n" : "success\n"),
"helper did not record the update result");
}
if (scenario != 1) {
Check(!fs::exists(workspace), "helper did not clean the successful download");
}
}
}
void TestMacBundleScope() {
Fixture fixture;
const fs::path launcher = "KytyPS5.app/Contents/MacOS/KytyPS5";
Write(fixture.source / launcher, "new mac launcher");
Write(fixture.source / launcher.parent_path() / "kyty_emulator", "new mac emulator");
Write(fixture.source / launcher.parent_path() / "kyty_updater", "new mac updater");
Write(fixture.target / launcher, "old mac launcher");
Write(fixture.target / launcher.parent_path() / "_SaveData" / "save", "mac save");
Write(fixture.source / "unrelated.txt", "must stay outside installation");
Check(Install(fixture.source, fixture.target, launcher).success, "mac bundle update failed");
Check(Read(fixture.target / launcher) == "new mac launcher", "mac launcher not replaced");
Check(Read(fixture.target / launcher.parent_path() / "_SaveData" / "save") == "mac save",
"mac savedata was removed");
Check(!fs::exists(fixture.target / "unrelated.txt"), "mac update installed archive siblings");
Check(Read(fixture.target / fixture.launcher) == "old launcher",
"mac update changed flat launcher sibling");
}
#endif
} // namespace
int main(int argc, char* argv[]) {
TestOverlayPreservesUserFiles();
TestValidation();
TestExclusiveInstallLock();
TestRollback();
#ifndef _WIN32
TestSymlinks();
TestExecutePermissions();
TestMacBundleScope();
if (argc == 2) {
TestCommandLine(fs::absolute(argv[1]));
}
#else
static_cast<void>(argc);
static_cast<void>(argv);
#endif
return 0;
}

View File

@ -1,124 +0,0 @@
#include "updateRelease.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <cstdio>
#include <cstdlib>
namespace {
const QUrl EXPECTED_PAGE_URL(
QStringLiteral("https://github.com/KytyPS5/KytyPS5/releases/tag/KytyPS5-2026-10-10-abcdef0"));
const QByteArray EXPECTED_DOWNLOAD_URL =
"https://github.com/KytyPS5/KytyPS5/releases/download/v1%2Fstable%2Btest/"
"v1%2Fstable%2Btest-Linux-x86_64.tar.gz";
const QString TAG = QStringLiteral("KytyPS5-2026-10-10-abcdef0");
const QString SUFFIX = QStringLiteral("-Linux-x86_64.tar.gz");
void Check(bool condition, const char* message) {
if (!condition) {
std::fprintf(stderr, "UpdateReleaseTests: %s\n", message);
std::abort();
}
}
QJsonObject Release(const QString& suffix = SUFFIX, const QString& tag = TAG) {
const QJsonObject asset {
{QStringLiteral("name"), tag + suffix},
{QStringLiteral("size"), 1234},
{QStringLiteral("digest"), QStringLiteral("sha256:") + QString(64, QLatin1Char('a'))}};
return {{QStringLiteral("tag_name"), tag},
{QStringLiteral("published_at"), QStringLiteral("2026-10-10T12:00:00Z")},
{QStringLiteral("draft"), false},
{QStringLiteral("prerelease"), false},
{QStringLiteral("assets"), QJsonArray {asset}}};
}
UpdateRelease::Info Parse(const QJsonObject& root, const QString& suffix = SUFFIX) {
return UpdateRelease::Parse(QJsonDocument(root).toJson(QJsonDocument::Compact), suffix);
}
void CheckInvalidAsset(const QString& key, const QJsonValue& value) {
auto root = Release();
auto asset = root.value(QStringLiteral("assets")).toArray().first().toObject();
asset.insert(key, value);
root.insert(QStringLiteral("assets"), QJsonArray {asset});
const auto info = Parse(root);
Check(!info.error.isEmpty() && info.download_url.isEmpty() && info.sha256.isEmpty(),
"invalid asset was accepted");
Check(info.tag == TAG && !info.page_url.isEmpty() && info.published_at.isValid(),
"invalid package discarded valid release metadata");
}
void TestParsing() {
for (const auto& suffix:
{SUFFIX, QStringLiteral("-Windows-x64.zip"), QStringLiteral("-macOS-x86_64.zip")}) {
const auto info = Parse(Release(suffix), suffix);
Check(info.error.isEmpty() && info.tag == TAG && info.size == 1234,
"valid platform package was rejected");
Check(info.sha256 == QByteArray(32, static_cast<char>(0xaa)), "checksum was not decoded");
Check(info.download_url.fileName() == TAG + suffix, "wrong asset was selected");
Check(info.page_url == EXPECTED_PAGE_URL,
"release page was not derived from the official repository");
}
const auto encoded = Parse(Release(SUFFIX, QStringLiteral("v1/stable+test")));
Check(encoded.download_url.toEncoded() == EXPECTED_DOWNLOAD_URL,
"release tag and asset name were not encoded as URL segments");
Check(!UpdateRelease::Parse("not JSON", SUFFIX).error.isEmpty(), "malformed JSON was accepted");
Check(!UpdateRelease::Parse("[]", SUFFIX).error.isEmpty(), "JSON array was accepted");
Check(!Parse(Release(), {}).error.isEmpty(), "unsupported platform was accepted");
Check(!Parse(Release(), QStringLiteral("-Windows-x64.zip")).error.isEmpty(),
"missing asset was accepted");
for (const auto& key: {QStringLiteral("draft"), QStringLiteral("prerelease")}) {
auto root = Release();
root.insert(key, true);
Check(!Parse(root).error.isEmpty(), "draft or prerelease was accepted");
}
for (const auto& field: {QStringLiteral("tag_name"), QStringLiteral("published_at")}) {
auto root = Release();
root.remove(field);
Check(!Parse(root).error.isEmpty(), "missing release metadata was accepted");
}
for (const auto& digest: {QString(), QStringLiteral("sha256:abc"),
QStringLiteral("sha256:") + QString(64, QLatin1Char('z'))}) {
CheckInvalidAsset(QStringLiteral("digest"), digest);
}
for (const double size: {-1., 0., 1.5}) {
CheckInvalidAsset(QStringLiteral("size"), size);
}
CheckInvalidAsset(QStringLiteral("name"), QStringLiteral("another-release") + SUFFIX);
}
void TestOrdering() {
using UpdateRelease::Compare;
using UpdateRelease::VersionRelation;
const auto latest = Parse(Release());
auto current = latest;
Check(Compare(latest, current) == VersionRelation::Current, "matching tags were not current");
current.tag = QStringLiteral("KytyPS5-2026-10-10-1234567");
Check(Compare(latest, current) == VersionRelation::Unknown,
"equal timestamps implied an update");
current.published_at = latest.published_at.addSecs(-60);
Check(Compare(latest, current) == VersionRelation::Newer, "same-day newer release was missed");
current.published_at = latest.published_at.addSecs(60);
Check(Compare(latest, current) == VersionRelation::Older,
"older release would cause a downgrade");
current.published_at = {};
Check(Compare(latest, current) == VersionRelation::Unknown,
"missing current metadata implied an update");
Check(Compare(latest, {}) == VersionRelation::Unknown,
"empty current release implied an update");
}
} // namespace
int main() {
TestParsing();
TestOrdering();
return 0;
}