2026-04-25 22:11:47 +08:00
|
|
|
#ifndef NOMINMAX
|
|
|
|
|
#define NOMINMAX
|
|
|
|
|
#endif
|
|
|
|
|
|
|
|
|
|
#include <windows.h>
|
|
|
|
|
|
|
|
|
|
#include <chrono>
|
|
|
|
|
#include <filesystem>
|
|
|
|
|
#include <fstream>
|
|
|
|
|
#include <iostream>
|
|
|
|
|
#include <sstream>
|
|
|
|
|
#include <string>
|
|
|
|
|
#include <string_view>
|
|
|
|
|
#include <system_error>
|
|
|
|
|
#include <vector>
|
|
|
|
|
|
|
|
|
|
namespace {
|
|
|
|
|
|
|
|
|
|
constexpr auto kLaunchTimeout = std::chrono::seconds(20);
|
2026-04-26 01:39:03 +08:00
|
|
|
constexpr auto kShutdownTimeout = std::chrono::seconds(25);
|
2026-04-25 22:11:47 +08:00
|
|
|
constexpr auto kPollInterval = std::chrono::milliseconds(100);
|
|
|
|
|
constexpr const char* kReadyTrace = "[app] shell runtime initialized:";
|
|
|
|
|
|
|
|
|
|
struct ProcessWindowSearch {
|
|
|
|
|
DWORD processId = 0;
|
|
|
|
|
HWND hwnd = nullptr;
|
|
|
|
|
bool requireVisible = false;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
std::wstring QuoteCommandLine(const std::filesystem::path& executablePath) {
|
|
|
|
|
return L"\"" + executablePath.wstring() + L"\"";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void PrintFailure(std::string_view message) {
|
2026-04-27 01:33:25 +08:00
|
|
|
std::cerr << "[xceditor_smoke] " << message << '\n';
|
2026-04-25 22:11:47 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::string ReadTextFile(const std::filesystem::path& path) {
|
|
|
|
|
std::ifstream stream(path, std::ios::in | std::ios::binary);
|
|
|
|
|
if (!stream.is_open()) {
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::ostringstream buffer = {};
|
|
|
|
|
buffer << stream.rdbuf();
|
|
|
|
|
return buffer.str();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool FileContains(const std::filesystem::path& path, std::string_view needle) {
|
|
|
|
|
const std::string content = ReadTextFile(path);
|
|
|
|
|
return !content.empty() && content.find(needle) != std::string::npos;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
BOOL CALLBACK FindProcessWindowProc(HWND hwnd, LPARAM lParam) {
|
|
|
|
|
auto& search = *reinterpret_cast<ProcessWindowSearch*>(lParam);
|
|
|
|
|
|
|
|
|
|
DWORD processId = 0;
|
|
|
|
|
GetWindowThreadProcessId(hwnd, &processId);
|
|
|
|
|
if (processId != search.processId) {
|
|
|
|
|
return TRUE;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (search.requireVisible && !IsWindowVisible(hwnd)) {
|
|
|
|
|
return TRUE;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
RECT clientRect = {};
|
|
|
|
|
if (!GetClientRect(hwnd, &clientRect)) {
|
|
|
|
|
return TRUE;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const LONG width = clientRect.right - clientRect.left;
|
|
|
|
|
const LONG height = clientRect.bottom - clientRect.top;
|
|
|
|
|
if (width <= 0 || height <= 0) {
|
|
|
|
|
return TRUE;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
search.hwnd = hwnd;
|
|
|
|
|
return FALSE;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
HWND FindProcessWindow(DWORD processId, bool requireVisible) {
|
|
|
|
|
ProcessWindowSearch search = {};
|
|
|
|
|
search.processId = processId;
|
|
|
|
|
search.requireVisible = requireVisible;
|
|
|
|
|
EnumWindows(&FindProcessWindowProc, reinterpret_cast<LPARAM>(&search));
|
|
|
|
|
return search.hwnd;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool RemoveFileIfPresent(const std::filesystem::path& path) {
|
|
|
|
|
std::error_code errorCode = {};
|
|
|
|
|
if (!std::filesystem::exists(path, errorCode)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::filesystem::remove(path, errorCode);
|
|
|
|
|
return !errorCode;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool PrepareLogDirectory(
|
|
|
|
|
const std::filesystem::path& logDirectory,
|
|
|
|
|
const std::filesystem::path& runtimeLogPath,
|
|
|
|
|
const std::filesystem::path& crashLogPath) {
|
|
|
|
|
std::error_code errorCode = {};
|
|
|
|
|
std::filesystem::create_directories(logDirectory, errorCode);
|
|
|
|
|
if (errorCode) {
|
|
|
|
|
PrintFailure("failed to create log directory: " + logDirectory.string());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!RemoveFileIfPresent(runtimeLogPath)) {
|
|
|
|
|
PrintFailure("failed to clear runtime log: " + runtimeLogPath.string());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!RemoveFileIfPresent(crashLogPath)) {
|
|
|
|
|
PrintFailure("failed to clear crash log: " + crashLogPath.string());
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool LaunchEditorProcess(
|
|
|
|
|
const std::filesystem::path& executablePath,
|
|
|
|
|
PROCESS_INFORMATION& outProcessInfo) {
|
|
|
|
|
SetEnvironmentVariableW(L"XCUI_AUTO_CAPTURE_ON_STARTUP", L"0");
|
2026-04-26 01:39:03 +08:00
|
|
|
SetEnvironmentVariableW(L"XCUIEDITOR_SMOKE_TEST", L"1");
|
|
|
|
|
SetEnvironmentVariableW(L"XCUIEDITOR_SMOKE_TEST_DURATION_SECONDS", L"12");
|
2026-04-25 22:11:47 +08:00
|
|
|
SetEnvironmentVariableW(L"XCUIEDITOR_SMOKE_TEST_FRAME_LIMIT", nullptr);
|
|
|
|
|
|
|
|
|
|
STARTUPINFOW startupInfo = {};
|
|
|
|
|
startupInfo.cb = sizeof(startupInfo);
|
|
|
|
|
|
|
|
|
|
std::wstring commandLine = QuoteCommandLine(executablePath);
|
|
|
|
|
std::vector<wchar_t> mutableCommandLine(commandLine.begin(), commandLine.end());
|
|
|
|
|
mutableCommandLine.push_back(L'\0');
|
|
|
|
|
|
|
|
|
|
ZeroMemory(&outProcessInfo, sizeof(outProcessInfo));
|
|
|
|
|
return CreateProcessW(
|
|
|
|
|
executablePath.c_str(),
|
|
|
|
|
mutableCommandLine.data(),
|
|
|
|
|
nullptr,
|
|
|
|
|
nullptr,
|
|
|
|
|
FALSE,
|
|
|
|
|
0,
|
|
|
|
|
nullptr,
|
|
|
|
|
nullptr,
|
|
|
|
|
&startupInfo,
|
|
|
|
|
&outProcessInfo) == TRUE;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool WaitForEditorReady(
|
|
|
|
|
HANDLE processHandle,
|
2026-04-26 01:39:03 +08:00
|
|
|
const std::filesystem::path& runtimeLogPath) {
|
2026-04-25 22:11:47 +08:00
|
|
|
const auto deadline = std::chrono::steady_clock::now() + kLaunchTimeout;
|
|
|
|
|
|
|
|
|
|
while (std::chrono::steady_clock::now() < deadline) {
|
|
|
|
|
if (WaitForSingleObject(processHandle, 0) == WAIT_OBJECT_0) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 01:39:03 +08:00
|
|
|
if (FileContains(runtimeLogPath, kReadyTrace)) {
|
2026-04-25 22:11:47 +08:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Sleep(static_cast<DWORD>(kPollInterval.count()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool WaitForEditorExit(HANDLE processHandle, DWORD timeoutMilliseconds, DWORD& outExitCode) {
|
|
|
|
|
if (WaitForSingleObject(processHandle, timeoutMilliseconds) != WAIT_OBJECT_0) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
outExitCode = 0;
|
|
|
|
|
return GetExitCodeProcess(processHandle, &outExitCode) == TRUE;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::string BuildEarlyExitReason(
|
|
|
|
|
HANDLE processHandle,
|
|
|
|
|
const std::filesystem::path& runtimeLogPath,
|
|
|
|
|
const std::filesystem::path& crashLogPath) {
|
|
|
|
|
DWORD exitCode = 0;
|
|
|
|
|
GetExitCodeProcess(processHandle, &exitCode);
|
|
|
|
|
|
|
|
|
|
std::ostringstream message = {};
|
|
|
|
|
message << "editor exited before ready, code=" << exitCode;
|
|
|
|
|
|
|
|
|
|
const std::string runtimeLog = ReadTextFile(runtimeLogPath);
|
|
|
|
|
if (!runtimeLog.empty()) {
|
|
|
|
|
message << "\nruntime.log:\n" << runtimeLog;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const std::string crashLog = ReadTextFile(crashLogPath);
|
|
|
|
|
if (!crashLog.empty()) {
|
|
|
|
|
message << "\ncrash.log:\n" << crashLog;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return message.str();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::string BuildTimeoutReason(
|
|
|
|
|
const std::filesystem::path& runtimeLogPath,
|
|
|
|
|
const std::filesystem::path& crashLogPath) {
|
|
|
|
|
std::ostringstream message = {};
|
|
|
|
|
message << "editor did not reach ready state within "
|
|
|
|
|
<< std::chrono::duration_cast<std::chrono::milliseconds>(kLaunchTimeout).count()
|
|
|
|
|
<< "ms";
|
|
|
|
|
|
|
|
|
|
const std::string runtimeLog = ReadTextFile(runtimeLogPath);
|
|
|
|
|
if (!runtimeLog.empty()) {
|
|
|
|
|
message << "\nruntime.log:\n" << runtimeLog;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const std::string crashLog = ReadTextFile(crashLogPath);
|
|
|
|
|
if (!crashLog.empty()) {
|
|
|
|
|
message << "\ncrash.log:\n" << crashLog;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return message.str();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void CloseProcessHandles(PROCESS_INFORMATION& processInfo) {
|
|
|
|
|
if (processInfo.hThread != nullptr) {
|
|
|
|
|
CloseHandle(processInfo.hThread);
|
|
|
|
|
processInfo.hThread = nullptr;
|
|
|
|
|
}
|
|
|
|
|
if (processInfo.hProcess != nullptr) {
|
|
|
|
|
CloseHandle(processInfo.hProcess);
|
|
|
|
|
processInfo.hProcess = nullptr;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} // namespace
|
|
|
|
|
|
|
|
|
|
int wmain(int argc, wchar_t* argv[]) {
|
|
|
|
|
if (argc < 2 || argv[1] == nullptr || argv[1][0] == L'\0') {
|
2026-04-27 01:33:25 +08:00
|
|
|
PrintFailure("missing XCEditor path");
|
2026-04-25 22:11:47 +08:00
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const std::filesystem::path executablePath = std::filesystem::path(argv[1]).lexically_normal();
|
|
|
|
|
if (!std::filesystem::exists(executablePath)) {
|
2026-04-27 01:33:25 +08:00
|
|
|
PrintFailure("XCEditor not found: " + executablePath.string());
|
2026-04-25 22:11:47 +08:00
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const std::filesystem::path logDirectory =
|
|
|
|
|
(executablePath.parent_path() / "logs").lexically_normal();
|
|
|
|
|
const std::filesystem::path runtimeLogPath =
|
|
|
|
|
(logDirectory / "runtime.log").lexically_normal();
|
|
|
|
|
const std::filesystem::path crashLogPath =
|
|
|
|
|
(logDirectory / "crash.log").lexically_normal();
|
|
|
|
|
if (!PrepareLogDirectory(logDirectory, runtimeLogPath, crashLogPath)) {
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
PROCESS_INFORMATION processInfo = {};
|
|
|
|
|
if (!LaunchEditorProcess(executablePath, processInfo)) {
|
2026-04-27 01:33:25 +08:00
|
|
|
PrintFailure("failed to launch XCEditor");
|
2026-04-25 22:11:47 +08:00
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!WaitForEditorReady(
|
|
|
|
|
processInfo.hProcess,
|
2026-04-26 01:39:03 +08:00
|
|
|
runtimeLogPath)) {
|
2026-04-25 22:11:47 +08:00
|
|
|
const DWORD waitState = WaitForSingleObject(processInfo.hProcess, 0);
|
|
|
|
|
const std::string message =
|
|
|
|
|
waitState == WAIT_OBJECT_0
|
|
|
|
|
? BuildEarlyExitReason(processInfo.hProcess, runtimeLogPath, crashLogPath)
|
|
|
|
|
: BuildTimeoutReason(runtimeLogPath, crashLogPath);
|
|
|
|
|
PrintFailure(message);
|
|
|
|
|
TerminateProcess(processInfo.hProcess, 2);
|
|
|
|
|
CloseProcessHandles(processInfo);
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
DWORD exitCode = 0;
|
|
|
|
|
if (!WaitForEditorExit(
|
|
|
|
|
processInfo.hProcess,
|
|
|
|
|
static_cast<DWORD>(
|
|
|
|
|
std::chrono::duration_cast<std::chrono::milliseconds>(kShutdownTimeout).count()),
|
|
|
|
|
exitCode)) {
|
|
|
|
|
PrintFailure("editor did not exit after WM_CLOSE");
|
|
|
|
|
TerminateProcess(processInfo.hProcess, 4);
|
|
|
|
|
CloseProcessHandles(processInfo);
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
CloseProcessHandles(processInfo);
|
|
|
|
|
|
|
|
|
|
if (exitCode != 0) {
|
|
|
|
|
PrintFailure("editor exited with non-zero code: " + std::to_string(exitCode));
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const std::string crashLog = ReadTextFile(crashLogPath);
|
|
|
|
|
if (!crashLog.empty()) {
|
|
|
|
|
PrintFailure("crash log was generated:\n" + crashLog);
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return 0;
|
|
|
|
|
}
|