aboutsummaryrefslogtreecommitdiff
path: root/src/zen-test
diff options
context:
space:
mode:
Diffstat (limited to 'src/zen-test')
-rw-r--r--src/zen-test/artifactprovider-tests.cpp66
-rw-r--r--src/zen-test/suggestion-tests.cpp32
-rw-r--r--src/zen-test/trace-tests.cpp107
-rw-r--r--src/zen-test/utility-cmd-tests.cpp179
-rw-r--r--src/zen-test/xmake.lua19
-rw-r--r--src/zen-test/zen-test.cpp238
-rw-r--r--src/zen-test/zen-test.h34
7 files changed, 675 insertions, 0 deletions
diff --git a/src/zen-test/artifactprovider-tests.cpp b/src/zen-test/artifactprovider-tests.cpp
new file mode 100644
index 000000000..48bbf8222
--- /dev/null
+++ b/src/zen-test/artifactprovider-tests.cpp
@@ -0,0 +1,66 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+#include <zencore/zencore.h>
+
+#if ZEN_WITH_TESTS
+
+# include "zen-test.h"
+
+# include <zencore/filesystem.h>
+# include <zencore/testing.h>
+# include <zencore/testutils.h>
+# include <zenutil/testartifactprovider.h>
+
+namespace zen::tests {
+
+namespace {
+
+ // Returns "<unset>" when the variable is unset, the literal value otherwise.
+ std::string DescribeEnvVar(std::string_view Name)
+ {
+ const std::string Value = GetEnvVariable(Name);
+ return Value.empty() ? std::string("<unset>") : Value;
+ }
+
+ // Same as DescribeEnvVar but redacts the value, since the variable may be a credential.
+ // Reports presence and length without revealing contents.
+ std::string DescribeSecretEnvVar(std::string_view Name)
+ {
+ const std::string Value = GetEnvVariable(Name);
+ return Value.empty() ? std::string("<unset>") : fmt::format("<set, {} chars>", Value.size());
+ }
+
+} // namespace
+
+TEST_SUITE_BEGIN("zen.artifactprovider");
+
+TEST_CASE("probe.s3_readme" * doctest::skip(!S3TestArtifactsAvailable()))
+{
+ // Use a fresh cache so Exists() is forced through to S3 rather than being satisfied
+ // by a README cached from a prior run.
+ ScopedTemporaryDirectory CacheDir;
+ TestArtifactProviderOptions Opts;
+ Opts.CacheDir = CacheDir.Path();
+ Ref<TestArtifactProvider> Provider = CreateTestArtifactProvider(std::move(Opts));
+ REQUIRE_MESSAGE(Provider, "no test artifact provider could be created on this platform");
+
+ constexpr std::string_view kArtifactPath = "README.md";
+ const std::string Description = Provider->Describe();
+
+ ZEN_INFO("Provider: {}", Description);
+ ZEN_INFO("ZEN_TEST_ARTIFACTS_S3={}", DescribeEnvVar(kTestArtifactsS3EnvVar));
+ ZEN_INFO("AWS_DEFAULT_REGION={}", DescribeEnvVar("AWS_DEFAULT_REGION"));
+ ZEN_INFO("AWS_REGION={}", DescribeEnvVar("AWS_REGION"));
+ ZEN_INFO("AWS_ENDPOINT_URL={}", DescribeEnvVar("AWS_ENDPOINT_URL"));
+ ZEN_INFO("AWS_ACCESS_KEY_ID={}", DescribeSecretEnvVar("AWS_ACCESS_KEY_ID"));
+ ZEN_INFO("AWS_SECRET_ACCESS_KEY={}", DescribeSecretEnvVar("AWS_SECRET_ACCESS_KEY"));
+ ZEN_INFO("AWS_SESSION_TOKEN={}", DescribeSecretEnvVar("AWS_SESSION_TOKEN"));
+
+ CHECK_MESSAGE(Provider->Exists(kArtifactPath), "'" << kArtifactPath << "' not available via " << Description);
+}
+
+TEST_SUITE_END();
+
+} // namespace zen::tests
+
+#endif
diff --git a/src/zen-test/suggestion-tests.cpp b/src/zen-test/suggestion-tests.cpp
new file mode 100644
index 000000000..8607c7bd5
--- /dev/null
+++ b/src/zen-test/suggestion-tests.cpp
@@ -0,0 +1,32 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+#include <zencore/zencore.h>
+
+#if ZEN_WITH_TESTS
+
+# include "zen-test.h"
+
+# include <zencore/testing.h>
+
+namespace zen::tests {
+
+TEST_SUITE_BEGIN("zen.suggestion");
+
+// Unit tests for the suggestion algorithm live in zenutil (util.suggest). This
+// integration test exists solely to verify the CLI wires the suggestion output
+// into the "Unknown command" handler.
+TEST_CASE("unknown_top_level_command_suggests_close_match")
+{
+ const ZenCommandResult Result = RunZen("stauts");
+
+ CHECK(Result.ExitCode != 0);
+ CHECK(Result.Output.find("Unknown command") != std::string::npos);
+ CHECK_MESSAGE(Result.Output.find("status") != std::string::npos,
+ fmt::format("expected 'status' suggestion in output, got:\n{}", Result.Output));
+}
+
+TEST_SUITE_END();
+
+} // namespace zen::tests
+
+#endif
diff --git a/src/zen-test/trace-tests.cpp b/src/zen-test/trace-tests.cpp
new file mode 100644
index 000000000..e57d30e6c
--- /dev/null
+++ b/src/zen-test/trace-tests.cpp
@@ -0,0 +1,107 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+#include <zencore/zencore.h>
+
+#if ZEN_WITH_TESTS
+
+# include "zen-test.h"
+
+# include <zencore/filesystem.h>
+# include <zencore/testing.h>
+
+# include <system_error>
+
+namespace zen::tests {
+
+using namespace std::literals;
+
+TEST_SUITE_BEGIN("zen.trace");
+
+TEST_CASE("help.top_level")
+{
+ ZenCommandResult Result = RunZen("trace --help");
+ CHECK_EQ(Result.ExitCode, 0);
+ CHECK_MESSAGE(Result.Output.find("subcommands:") != std::string::npos, Result.Output);
+ CHECK_MESSAGE(Result.Output.find("analyze") != std::string::npos, Result.Output);
+ CHECK_MESSAGE(Result.Output.find("inspect") != std::string::npos, Result.Output);
+ CHECK_MESSAGE(Result.Output.find("serve") != std::string::npos, Result.Output);
+}
+
+TEST_CASE("help.inspect")
+{
+ ZenCommandResult Result = RunZen("trace inspect --help");
+ CHECK_EQ(Result.ExitCode, 0);
+ CHECK_MESSAGE(Result.Output.find("Inspect event schemas") != std::string::npos, Result.Output);
+}
+
+TEST_CASE("inspect.missing_file")
+{
+ ZenCommandResult Result = RunZen("trace inspect this-file-does-not-exist.utrace");
+ CHECK_NE(Result.ExitCode, 0);
+ CHECK_MESSAGE(Result.Output.find("File not found") != std::string::npos, Result.Output);
+}
+
+TEST_CASE("inspect.generated_tracefile")
+{
+ const std::filesystem::path DataDir = TestEnv.CreateNewTestDir();
+ const std::filesystem::path TraceFile = DataDir / "powercycle-trace.utrace";
+
+ ZenServerInstance Instance(TestEnv);
+ Instance.SetDataDir(DataDir);
+
+ ExtendableStringBuilder<512> ServerArgs;
+ ServerArgs << "--tracefile=\"";
+ PathToUtf8(TraceFile.c_str(), ServerArgs);
+ ServerArgs << "\" --powercycle";
+
+ Instance.SpawnServer(TestEnv.GetNewPortNumber(), ServerArgs.ToView());
+ CHECK_MESSAGE(Instance.WaitUntilReady(10000), Instance.GetLogOutput());
+
+ if (Instance.IsRunning())
+ {
+ std::error_code Ec;
+ CHECK_MESSAGE(Instance.WaitUntilExited(10000, Ec), Instance.GetLogOutput());
+ CHECK_MESSAGE(!Ec, Ec.message());
+ }
+
+ CHECK_EQ(Instance.Shutdown(), 0);
+ CHECK_MESSAGE(std::filesystem::exists(TraceFile), TraceFile.string());
+ CHECK_MESSAGE(std::filesystem::file_size(TraceFile) > 0, TraceFile.string());
+
+ ExtendableStringBuilder<512> InspectArgs;
+ InspectArgs << "trace inspect \"";
+ PathToUtf8(TraceFile.c_str(), InspectArgs);
+ InspectArgs << "\"";
+
+ ZenCommandResult InspectResult = RunZen(InspectArgs.ToView());
+ CHECK_EQ(InspectResult.ExitCode, 0);
+ CHECK_MESSAGE(InspectResult.Output.find("Trace:") != std::string::npos, InspectResult.Output);
+ CHECK_MESSAGE(InspectResult.Output.find("Events:") != std::string::npos, InspectResult.Output);
+ CHECK_MESSAGE(InspectResult.Output.find("Types:") != std::string::npos, InspectResult.Output);
+ CHECK_MESSAGE(InspectResult.Output.find("Event Schemas:") != std::string::npos, InspectResult.Output);
+ CHECK_MESSAGE(InspectResult.Output.find("Events: 0") == std::string::npos, InspectResult.Output);
+ CHECK_MESSAGE(InspectResult.Output.find("Types: 0") == std::string::npos, InspectResult.Output);
+ CHECK_MESSAGE(InspectResult.Output.find("(uid=") != std::string::npos, InspectResult.Output);
+ CHECK_MESSAGE(InspectResult.Output.find("events=") != std::string::npos, InspectResult.Output);
+
+ ExtendableStringBuilder<512> AnalyzeArgs;
+ AnalyzeArgs << "trace analyze --no-cache --symbols=off \"";
+ PathToUtf8(TraceFile.c_str(), AnalyzeArgs);
+ AnalyzeArgs << "\"";
+
+ ZenCommandResult AnalyzeResult = RunZen(AnalyzeArgs.ToView());
+ CHECK_EQ(AnalyzeResult.ExitCode, 0);
+ CHECK_MESSAGE(AnalyzeResult.Output.find("Trace:") != std::string::npos, AnalyzeResult.Output);
+ CHECK_MESSAGE(AnalyzeResult.Output.find("Size:") != std::string::npos, AnalyzeResult.Output);
+ CHECK_MESSAGE(AnalyzeResult.Output.find("Events:") != std::string::npos, AnalyzeResult.Output);
+ CHECK_MESSAGE(AnalyzeResult.Output.find("Duration:") != std::string::npos, AnalyzeResult.Output);
+ CHECK_MESSAGE(AnalyzeResult.Output.find("Threads:") != std::string::npos, AnalyzeResult.Output);
+ CHECK_MESSAGE(AnalyzeResult.Output.find("Modules:") != std::string::npos, AnalyzeResult.Output);
+ CHECK_MESSAGE(AnalyzeResult.Output.find("Parsed:") != std::string::npos, AnalyzeResult.Output);
+}
+
+TEST_SUITE_END();
+
+} // namespace zen::tests
+
+#endif
diff --git a/src/zen-test/utility-cmd-tests.cpp b/src/zen-test/utility-cmd-tests.cpp
new file mode 100644
index 000000000..aa49d7299
--- /dev/null
+++ b/src/zen-test/utility-cmd-tests.cpp
@@ -0,0 +1,179 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+#include <zencore/zencore.h>
+
+#if ZEN_WITH_TESTS
+
+# include "zen-test.h"
+
+# include <zencore/compactbinarybuilder.h>
+# include <zencore/filesystem.h>
+# include <zencore/iobuffer.h>
+# include <zencore/testing.h>
+# include <zencore/testutils.h>
+
+namespace zen::tests {
+
+TEST_SUITE_BEGIN("zen.utility-cmd");
+
+TEST_CASE("print.cbo_to_json")
+{
+ ScopedTemporaryDirectory TempDir;
+
+ // Build a small compact binary object covering a few primitive field types.
+ CbObjectWriter Writer;
+ Writer << "Name"
+ << "ZenPrintTest";
+ Writer << "Count" << int32_t(42);
+ Writer << "Enabled" << true;
+
+ IoBuffer Payload = Writer.Save().GetBuffer().AsIoBuffer();
+ REQUIRE(Payload.GetSize() > 0);
+
+ const std::filesystem::path CboPath = TempDir.Path() / "object.cbo";
+ WriteFile(CboPath, Payload);
+
+ const ZenCommandResult Result = RunZen(fmt::format(R"(print --source "{}")", CboPath.string()));
+
+ REQUIRE_MESSAGE(Result.ExitCode == 0, fmt::format("zen print exited with code {}", Result.ExitCode));
+
+ // CompactBinaryToJson emits a JSON object; field names are quoted and values appear verbatim.
+ CHECK(Result.Output.find("\"Name\"") != std::string::npos);
+ CHECK(Result.Output.find("\"ZenPrintTest\"") != std::string::npos);
+ CHECK(Result.Output.find("\"Count\"") != std::string::npos);
+ CHECK(Result.Output.find("42") != std::string::npos);
+ CHECK(Result.Output.find("\"Enabled\"") != std::string::npos);
+ CHECK(Result.Output.find("true") != std::string::npos);
+}
+
+TEST_CASE("print.show_type_info_annotates_output")
+{
+ ScopedTemporaryDirectory TempDir;
+
+ CbObjectWriter Writer;
+ Writer << "Count" << int32_t(7);
+
+ IoBuffer Payload = Writer.Save().GetBuffer().AsIoBuffer();
+ const std::filesystem::path CboPath = TempDir.Path() / "object.cbo";
+ WriteFile(CboPath, Payload);
+
+ const ZenCommandResult Plain = RunZen(fmt::format(R"(print --source "{}")", CboPath.string()));
+ const ZenCommandResult Typed = RunZen(fmt::format(R"(print --show-type-info --source "{}")", CboPath.string()));
+
+ REQUIRE(Plain.ExitCode == 0);
+ REQUIRE(Typed.ExitCode == 0);
+
+ // --show-type-info prefixes values with type annotations like [IntegerPositive] that the plain variant omits.
+ CHECK(Typed.Output.size() > Plain.Output.size());
+ CHECK(Typed.Output.find("[IntegerPositive]") != std::string::npos);
+ CHECK(Plain.Output.find("[IntegerPositive]") == std::string::npos);
+}
+
+TEST_CASE("print.missing_source_fails")
+{
+ const ZenCommandResult Result = RunZen(R"(print)");
+
+ CHECK(Result.ExitCode != 0);
+}
+
+TEST_CASE("print.non_cbo_file_fails")
+{
+ ScopedTemporaryDirectory TempDir;
+
+ const std::filesystem::path GarbagePath = TempDir.Path() / "garbage.cbo";
+ const std::string_view Garbage = "this is not compact binary data";
+ WriteFile(GarbagePath, IoBuffer(IoBuffer::Wrap, Garbage.data(), Garbage.size()));
+
+ const ZenCommandResult Result = RunZen(fmt::format(R"(print --source "{}")", GarbagePath.string()));
+
+ CHECK(Result.ExitCode != 0);
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// wipe
+
+namespace {
+
+ // Drop a small fixture tree of files and nested directories inside Root so wipe has
+ // something non-trivial to recurse over.
+ void PopulateWipeFixture(const std::filesystem::path& Root)
+ {
+ std::filesystem::create_directories(Root / "nested" / "deep");
+ const std::string_view Contents = "hello";
+ const IoBuffer Payload(IoBuffer::Wrap, Contents.data(), Contents.size());
+ WriteFile(Root / "top.txt", Payload);
+ WriteFile(Root / "nested" / "mid.txt", Payload);
+ WriteFile(Root / "nested" / "deep" / "leaf.txt", Payload);
+ }
+
+ bool DirectoryIsEmpty(const std::filesystem::path& Path)
+ {
+ std::error_code Ec;
+ return std::filesystem::is_empty(Path, Ec) && !Ec;
+ }
+
+} // namespace
+
+TEST_CASE("wipe.removes_directory_contents")
+{
+ ScopedTemporaryDirectory TempDir;
+ const std::filesystem::path Target = TempDir.Path() / "to-wipe";
+ PopulateWipeFixture(Target);
+
+ REQUIRE(std::filesystem::exists(Target / "top.txt"));
+ REQUIRE(std::filesystem::exists(Target / "nested" / "deep" / "leaf.txt"));
+
+ const ZenCommandResult Result = RunZen(fmt::format(R"(wipe --yes --quiet --directory "{}")", Target.string()));
+
+ REQUIRE_MESSAGE(Result.ExitCode == 0, fmt::format("zen wipe exited with code {}", Result.ExitCode));
+
+ CHECK_FALSE(std::filesystem::exists(Target / "top.txt"));
+ CHECK_FALSE(std::filesystem::exists(Target / "nested"));
+}
+
+TEST_CASE("wipe.dryrun_keeps_files")
+{
+ ScopedTemporaryDirectory TempDir;
+ const std::filesystem::path Target = TempDir.Path() / "to-wipe";
+ PopulateWipeFixture(Target);
+
+ const ZenCommandResult Result = RunZen(fmt::format(R"(wipe --yes --quiet --dryrun --directory "{}")", Target.string()));
+
+ REQUIRE_MESSAGE(Result.ExitCode == 0, fmt::format("zen wipe --dryrun exited with code {}", Result.ExitCode));
+
+ // --dryrun must leave the tree untouched.
+ CHECK(std::filesystem::exists(Target / "top.txt"));
+ CHECK(std::filesystem::exists(Target / "nested" / "mid.txt"));
+ CHECK(std::filesystem::exists(Target / "nested" / "deep" / "leaf.txt"));
+}
+
+TEST_CASE("wipe.nonexistent_directory_is_noop")
+{
+ ScopedTemporaryDirectory TempDir;
+ const std::filesystem::path Missing = TempDir.Path() / "does-not-exist";
+ REQUIRE_FALSE(std::filesystem::exists(Missing));
+
+ const ZenCommandResult Result = RunZen(fmt::format(R"(wipe --yes --quiet --directory "{}")", Missing.string()));
+
+ // A missing directory should be silently skipped without failing.
+ CHECK(Result.ExitCode == 0);
+ CHECK_FALSE(std::filesystem::exists(Missing));
+}
+
+TEST_CASE("wipe.empty_directory")
+{
+ ScopedTemporaryDirectory TempDir;
+ const std::filesystem::path Target = TempDir.Path() / "empty";
+ std::filesystem::create_directories(Target);
+ REQUIRE(DirectoryIsEmpty(Target));
+
+ const ZenCommandResult Result = RunZen(fmt::format(R"(wipe --yes --quiet --directory "{}")", Target.string()));
+
+ CHECK(Result.ExitCode == 0);
+}
+
+TEST_SUITE_END();
+
+} // namespace zen::tests
+
+#endif
diff --git a/src/zen-test/xmake.lua b/src/zen-test/xmake.lua
new file mode 100644
index 000000000..e3a3757a2
--- /dev/null
+++ b/src/zen-test/xmake.lua
@@ -0,0 +1,19 @@
+-- Copyright Epic Games, Inc. All Rights Reserved.
+
+target("zen-test")
+ set_kind("binary")
+ set_group("tests")
+ add_headerfiles("**.h")
+ add_files("*.cpp")
+ add_files("zen-test.cpp", {unity_ignored = true })
+ add_deps("zencore", "zenhttp", "zenutil")
+ add_deps("zen", {inherit=false})
+ add_deps("zenserver", {inherit=false})
+ add_deps("zentest-appstub", {inherit=false})
+ add_packages("llhttp")
+
+ if is_plat("macosx") then
+ add_ldflags("-framework CoreFoundation")
+ add_ldflags("-framework Security")
+ add_ldflags("-framework SystemConfiguration")
+ end
diff --git a/src/zen-test/zen-test.cpp b/src/zen-test/zen-test.cpp
new file mode 100644
index 000000000..3ee401045
--- /dev/null
+++ b/src/zen-test/zen-test.cpp
@@ -0,0 +1,238 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+#define _SILENCE_CXX17_C_HEADER_DEPRECATION_WARNING
+
+#if ZEN_WITH_TESTS
+
+# include "zen-test.h"
+
+# include <zencore/except.h>
+# include <zencore/filesystem.h>
+# include <zencore/fmtutils.h>
+# include <zencore/logging.h>
+# include <zencore/logging/registry.h>
+# include <zencore/process.h>
+# include <zencore/string.h>
+# include <zencore/testutils.h>
+# include <zencore/trace.h>
+# include <zenhttp/httpclient.h>
+# include <zenutil/config/commandlineoptions.h>
+# include <zenutil/logging/fullformatter.h>
+# include <zenutil/zenserverprocess.h>
+
+# include <filesystem>
+
+# if ZEN_PLATFORM_WINDOWS
+# include <zencore/windows.h>
+# else
+# include <cerrno>
+# include <unistd.h>
+# endif
+
+# include <zencore/memory/newdelete.h>
+
+//////////////////////////////////////////////////////////////////////////
+
+using namespace std::literals;
+
+//////////////////////////////////////////////////////////////////////////
+
+namespace zen::tests {
+
+zen::ZenServerEnvironment TestEnv;
+
+static std::filesystem::path s_ProgramBaseDir;
+
+std::filesystem::path
+GetZenExecutablePath()
+{
+ return s_ProgramBaseDir / "zen" ZEN_EXE_SUFFIX_LITERAL;
+}
+
+static std::string
+ReadAllFromPipe(StdoutPipeHandles& Pipe)
+{
+ std::string Result;
+ char Buffer[4096];
+
+# if ZEN_PLATFORM_WINDOWS
+ DWORD BytesRead = 0;
+ while (::ReadFile(Pipe.ReadHandle, Buffer, sizeof(Buffer), &BytesRead, nullptr) && BytesRead > 0)
+ {
+ Result.append(Buffer, BytesRead);
+ }
+# else
+ ssize_t BytesRead = 0;
+ while ((BytesRead = read(Pipe.ReadFd, Buffer, sizeof(Buffer))) > 0)
+ {
+ Result.append(Buffer, static_cast<size_t>(BytesRead));
+ }
+# endif
+
+ return Result;
+}
+
+ZenCommandResult
+RunZen(std::string_view Args)
+{
+ StdoutPipeHandles Pipe;
+ if (!CreateStdoutPipe(Pipe))
+ {
+ return {.ExitCode = -1, .Output = "failed to create stdout pipe"};
+ }
+
+ const std::filesystem::path ZenExe = GetZenExecutablePath();
+ const std::string CommandLine = fmt::format("zen {}", Args);
+
+ CreateProcOptions Options;
+ Options.StdoutPipe = &Pipe; // stderr shares the stdout pipe when StderrPipe is null
+
+ ProcessHandle Process(CreateProc(ZenExe, CommandLine, Options));
+
+ // Drain output before Wait() so the child doesn't block on a full pipe buffer.
+ Pipe.CloseWriteEnd();
+ std::string Output = ReadAllFromPipe(Pipe);
+
+ Process.Wait();
+
+ return {.ExitCode = Process.GetExitCode(), .Output = std::move(Output)};
+}
+
+} // namespace zen::tests
+
+int
+main(int argc, char** argv)
+{
+# if ZEN_PLATFORM_WINDOWS
+ setlocale(LC_ALL, "en_us.UTF8");
+# endif // ZEN_PLATFORM_WINDOWS
+
+ using namespace std::literals;
+ using namespace zen;
+
+ zen::CommandLineConverter ArgConverter(argc, argv);
+
+# if ZEN_WITH_TRACE
+ zen::TraceInit("zen-test");
+ TraceOptions TraceCommandlineOptions;
+ if (GetTraceOptionsFromCommandline(TraceCommandlineOptions))
+ {
+ TraceConfigure(TraceCommandlineOptions);
+ }
+# endif // ZEN_WITH_TRACE
+
+ zen::logging::InitializeLogging();
+
+ zen::logging::SetLogLevel(zen::logging::Debug);
+ zen::logging::Registry::Instance().SetFormatter(
+ std::make_unique<zen::logging::FullFormatter>("test", std::chrono::system_clock::now()));
+
+ std::filesystem::path ProgramBaseDir = GetRunningExecutablePath().parent_path();
+ std::filesystem::path TestBaseDir = std::filesystem::current_path() / ".test";
+
+ zen::tests::s_ProgramBaseDir = ProgramBaseDir;
+
+ // This is pretty janky because we're passing most of the options through to the test
+ // framework, so we can't just use cxxopts (I think). This should ideally be cleaned up
+ // somehow in the future
+
+ std::string ServerClass;
+ bool Verbose = false;
+ bool KillStale = false;
+
+ for (int i = 1; i < argc; ++i)
+ {
+ if (argv[i] == "--http"sv)
+ {
+ if ((i + 1) < argc)
+ {
+ ServerClass = argv[++i];
+ }
+ }
+ else if (std::string_view Arg(argv[i]); Arg.starts_with("--httpclient="sv))
+ {
+ std::string_view Value = Arg.substr(13);
+ zen::SetDefaultHttpClientBackend(Value);
+ }
+ else if (argv[i] == "--verbose"sv)
+ {
+ Verbose = true;
+ }
+ else if (argv[i] == "--kill-stale-processes"sv)
+ {
+ KillStale = true;
+ }
+ }
+
+ // Since GHA runners can leave processes behind from a previous test run, we need
+ // to be able to clean up any stale server processes before starting the tests to
+ // avoid interference
+
+ if (KillStale)
+ {
+ ZEN_INFO("Killing any stale processes from previous test runs...");
+
+ auto KillStaleProcesses = [](const std::filesystem::path& Executable) {
+ ZEN_INFO(" Looking for stale '{}' processes...", Executable.filename());
+
+ for (;;)
+ {
+ ProcessHandle StaleProcess;
+ std::error_code Ec = FindProcess(Executable, StaleProcess, /*IncludeSelf*/ false);
+
+ if (Ec || !StaleProcess.IsValid())
+ {
+ break;
+ }
+
+ ZEN_WARN("====> Found stale '{}' process (pid {}) from a previous test run - terminating it",
+ Executable.filename(),
+ StaleProcess.Pid());
+
+ StaleProcess.Terminate(0);
+ }
+ };
+
+ KillStaleProcesses(ProgramBaseDir / "zen" ZEN_EXE_SUFFIX_LITERAL);
+ KillStaleProcesses(ProgramBaseDir / "zenserver" ZEN_EXE_SUFFIX_LITERAL);
+ KillStaleProcesses(ProgramBaseDir / "zen-test" ZEN_EXE_SUFFIX_LITERAL);
+ KillStaleProcesses(ProgramBaseDir / "zentest-appstub" ZEN_EXE_SUFFIX_LITERAL);
+ }
+
+ zen::tests::TestEnv.InitializeForTest(ProgramBaseDir, TestBaseDir, ServerClass);
+
+ if (Verbose)
+ {
+ zen::tests::TestEnv.SetPassthroughOutput(true);
+ }
+
+ ZEN_INFO("Running tests...(base dir: '{}')", TestBaseDir);
+
+ zen::testing::TestRunner Runner;
+ Runner.ApplyCommandLine(argc, argv, "zen.*");
+
+ return Runner.Run();
+}
+
+namespace zen::tests {
+
+TEST_SUITE_BEGIN("zen.zen-test");
+
+TEST_CASE("scaffolding.executable_present")
+{
+ const std::filesystem::path ZenExe = GetZenExecutablePath();
+ CHECK_MESSAGE(std::filesystem::exists(ZenExe), fmt::format("zen executable not found at '{}'", ZenExe.string()));
+}
+
+TEST_SUITE_END();
+
+} // namespace zen::tests
+#else
+# include <stdio.h>
+
+int
+main()
+{
+ printf("tests are disabled (ZEN_WITH_TESTS=0). Tests are only available in debug builds\n");
+}
+#endif
diff --git a/src/zen-test/zen-test.h b/src/zen-test/zen-test.h
new file mode 100644
index 000000000..bab86ca88
--- /dev/null
+++ b/src/zen-test/zen-test.h
@@ -0,0 +1,34 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+#pragma once
+
+#if ZEN_WITH_TESTS
+
+# include <zencore/iobuffer.h>
+# include <zencore/string.h>
+# include <zencore/testing.h>
+# include <zencore/testutils.h>
+# include <zenutil/zenserverprocess.h>
+
+# include <filesystem>
+
+namespace zen::tests {
+
+extern zen::ZenServerEnvironment TestEnv;
+
+// Path to the zen CLI executable, located alongside the running test binary.
+std::filesystem::path GetZenExecutablePath();
+
+struct ZenCommandResult
+{
+ int ExitCode = -1;
+ std::string Output; // combined stdout + stderr
+};
+
+// Spawn the zen CLI with the given argument string (without the executable name)
+// and capture combined stdout/stderr until exit. Blocks until the process exits.
+ZenCommandResult RunZen(std::string_view Args);
+
+} // namespace zen::tests
+
+#endif