Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,17 @@ add_custom_command(TARGET lvt_avalonia_tap POST_BUILD
COMMENT "Publishing managed Avalonia tree walker assembly"
)

add_custom_target(lvt_avalonia_tree_walker
COMMAND ${CMAKE_COMMAND} -E make_directory "$<TARGET_FILE_DIR:lvt>/plugins/avalonia"
COMMAND dotnet publish "${CMAKE_SOURCE_DIR}/src/plugin_avalonia/LvtAvaloniaTreeWalker/LvtAvaloniaTreeWalker.csproj"
-c Release -o "$<TARGET_FILE_DIR:lvt>/plugins/avalonia"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${CMAKE_SOURCE_DIR}/src/plugin_avalonia/LvtAvaloniaTreeWalker/LvtAvaloniaTreeWalker.runtimeconfig.json"
"$<TARGET_FILE_DIR:lvt>/plugins/avalonia/LvtAvaloniaTreeWalker.runtimeconfig.json"
WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
COMMENT "Publishing managed Avalonia tree walker assembly"
)

# Chromium plugin DLL — runtime-loaded plugin for Chrome/Edge DOM tree support
add_library(lvt_chromium_plugin SHARED
src/plugin_chromium/lvt_chromium_plugin.cpp
Expand Down Expand Up @@ -329,6 +340,18 @@ add_custom_target(winui3_sample_app ALL
file(TO_NATIVE_PATH "${WINUI3_SAMPLE_OUTPUT_DIR}/WinUI3Sample.exe" WINUI3_SAMPLE_EXE_NATIVE)
string(REPLACE "\\" "\\\\" WINUI3_SAMPLE_EXE_ESCAPED "${WINUI3_SAMPLE_EXE_NATIVE}")

# Avalonia sample app used by integration tests
set(AVALONIA_SAMPLE_PROJECT "${CMAKE_SOURCE_DIR}/tests/avalonia_test_app/AvaloniaTestApp.csproj")
set(AVALONIA_SAMPLE_OUTPUT_DIR "${CMAKE_BINARY_DIR}/tests/avalonia_test_app")
add_custom_target(avalonia_test_app ALL
COMMAND dotnet build "${AVALONIA_SAMPLE_PROJECT}" -c Release -o "${AVALONIA_SAMPLE_OUTPUT_DIR}"
BYPRODUCTS "${AVALONIA_SAMPLE_OUTPUT_DIR}/AvaloniaTestApp.exe"
WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
COMMENT "Building Avalonia integration test app"
)
file(TO_NATIVE_PATH "${AVALONIA_SAMPLE_OUTPUT_DIR}/AvaloniaTestApp.exe" AVALONIA_SAMPLE_EXE_NATIVE)
string(REPLACE "\\" "\\\\" AVALONIA_SAMPLE_EXE_ESCAPED "${AVALONIA_SAMPLE_EXE_NATIVE}")

# Integration tests — require a running Notepad instance
add_executable(lvt_integration_tests
tests/integration_tests.cpp
Expand All @@ -339,14 +362,23 @@ target_compile_definitions(lvt_integration_tests PRIVATE
WIN32_LEAN_AND_MEAN NOMINMAX WINRT_LEAN_AND_MEAN UNICODE _UNICODE
WPF_SAMPLE_EXE_PATH="${WPF_SAMPLE_EXE_ESCAPED}"
WINUI3_SAMPLE_EXE_PATH="${WINUI3_SAMPLE_EXE_ESCAPED}"
AVALONIA_SAMPLE_EXE_PATH="${AVALONIA_SAMPLE_EXE_ESCAPED}"
)
target_link_libraries(lvt_integration_tests PRIVATE
GTest::gtest GTest::gtest_main
WIL::WIL nlohmann_json::nlohmann_json
comctl32
)
target_link_options(lvt_integration_tests PRIVATE "/MANIFEST:NO")
add_dependencies(lvt_integration_tests wpf_sample_app winui3_sample_app)
add_dependencies(lvt_integration_tests
lvt
lvt_avalonia_plugin
lvt_avalonia_tap
lvt_avalonia_tree_walker
avalonia_test_app
wpf_sample_app
winui3_sample_app
)
add_test(NAME integration_tests COMMAND lvt_integration_tests)

# Chromium plugin tests — DOM JSON format and native messaging protocol
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"tfm": "net8.0",
"framework": {
"name": "Microsoft.NETCore.App",
"version": "8.0.0"
"version": "8.0.0",
"rollForward": "Major"
}
}
}
2 changes: 1 addition & 1 deletion tests/avalonia_test_app/AvaloniaTestApp.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<AssemblyName>AvaloniaTestApp</AssemblyName>
<Nullable>enable</Nullable>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
Expand Down
6 changes: 3 additions & 3 deletions tests/avalonia_test_app/MainWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
Title="LVT Avalonia Test"
Width="400" Height="300">
<StackPanel Margin="20" Spacing="10">
<TextBlock Name="HelloText" Text="Hello World" FontSize="24" />
<Button Name="ClickButton" Content="Click Me" />
<TextBox Name="InputBox" Watermark="Type here..." />
<TextBlock x:Name="HelloText" Text="Hello World" FontSize="24" />
<Button x:Name="ClickButton" Content="Click Me" />
<TextBox x:Name="InputBox" Watermark="Type here..." />
</StackPanel>
</Window>
228 changes: 228 additions & 0 deletions tests/integration_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,16 @@ static bool frameworks_contain_winui3(const json& j) {
return false;
}

static bool frameworks_contain_avalonia(const json& j) {
if (!j.contains("frameworks") || !j["frameworks"].is_array())
return false;
for (auto& fw : j["frameworks"]) {
if (fw.is_string() && fw.get<std::string>().starts_with("avalonia"))
return true;
}
return false;
}

static bool has_winui3_descendant(const json& el) {
if (el.value("framework", "") == "winui3")
return true;
Expand Down Expand Up @@ -443,6 +453,41 @@ static bool has_winui3_stitched_under_bridge(const json& el) {
return false;
}

static bool has_avalonia_control(const json& el) {
if (el.value("framework", "") == "avalonia") {
auto type = el.value("type", "");
if (type == "Button" || type == "TextBlock" || type == "TextBox")
return true;
if (el.contains("properties") && el["properties"].is_object() &&
el["properties"].contains("name"))
return true;
}

if (el.contains("children") && el["children"].is_array()) {
for (auto& child : el["children"]) {
if (has_avalonia_control(child))
return true;
}
}
return false;
}

static bool has_avalonia_under_host(const json& el, bool underAvaloniaHost = false) {
auto className = el.value("className", "");
bool isAvaloniaHost = className.rfind("Avalonia-", 0) == 0;
bool underHost = underAvaloniaHost || isAvaloniaHost;
if (underHost && el.value("framework", "") == "avalonia")
return true;

if (el.contains("children") && el["children"].is_array()) {
for (auto& child : el["children"]) {
if (has_avalonia_under_host(child, underHost))
return true;
}
}
return false;
}

static const json* find_element_by_type_property(const json& el,
const std::string& type,
const std::string& property,
Expand Down Expand Up @@ -509,6 +554,189 @@ static std::string query_prop_until(const std::string& lvt, const std::string& p
return r;
}

struct VisibleWindowSearch {
DWORD pid;
bool found;
};

static BOOL CALLBACK find_visible_window_for_pid(HWND hwnd, LPARAM lParam) {
auto* search = reinterpret_cast<VisibleWindowSearch*>(lParam);
DWORD windowPid = 0;
GetWindowThreadProcessId(hwnd, &windowPid);
if (windowPid == search->pid && IsWindowVisible(hwnd)) {
search->found = true;
return FALSE;
}
return TRUE;
}

static bool has_visible_window_for_pid(DWORD pid) {
VisibleWindowSearch search{pid, false};
EnumWindows(find_visible_window_for_pid, reinterpret_cast<LPARAM>(&search));
return search.found;
}

static bool deploy_plugins(const fs::path& source, const fs::path& dest, std::string& error) {
std::error_code ec;
fs::create_directories(dest, ec);
if (ec) {
error = "Failed to create " + dest.string() + ": " + ec.message();
return false;
}

for (const auto& entry : fs::recursive_directory_iterator(source, ec)) {
if (ec) {
error = "Failed to enumerate " + source.string() + ": " + ec.message();
return false;
}

auto relative = fs::relative(entry.path(), source, ec);
if (ec) {
error = "Failed to compute relative path for " + entry.path().string() + ": " + ec.message();
return false;
}

auto target = dest / relative;
if (entry.is_directory()) {
fs::create_directories(target, ec);
if (ec) {
error = "Failed to create " + target.string() + ": " + ec.message();
return false;
}
continue;
}

fs::create_directories(target.parent_path(), ec);
if (ec) {
error = "Failed to create " + target.parent_path().string() + ": " + ec.message();
return false;
}

if (!CopyFileW(entry.path().c_str(), target.c_str(), FALSE)) {
DWORD err = GetLastError();
if ((err == ERROR_SHARING_VIOLATION || err == ERROR_ACCESS_DENIED) && fs::exists(target))
continue;
error = "Failed to copy " + entry.path().string() + " to " + target.string() +
" (error " + std::to_string(err) + ")";
return false;
}
}
return true;
}

class AvaloniaFixture : public ::testing::Test {
protected:
void SetUp() override {
auto lvt = get_lvt_path();
fs::path buildDir = fs::path(lvt).parent_path();
fs::path builtPlugins = buildDir / "plugins";
if (!fs::exists(builtPlugins)) {
GTEST_SKIP() << "Avalonia plugin output not found at " << builtPlugins.string()
<< "; build lvt_avalonia_plugin first";
}

wchar_t profile[MAX_PATH]{};
DWORD profileLen = GetEnvironmentVariableW(L"USERPROFILE", profile, MAX_PATH);
if (profileLen == 0 || profileLen >= MAX_PATH)
GTEST_SKIP() << "USERPROFILE is not set; cannot deploy lvt plugins";

fs::path userPlugins = fs::path(profile) / ".lvt" / "plugins";
std::string deployError;
if (!deploy_plugins(builtPlugins, userPlugins, deployError))
GTEST_SKIP() << deployError;

fs::path appExe = AVALONIA_SAMPLE_EXE_PATH;
if (!fs::exists(appExe)) {
GTEST_SKIP() << "Avalonia test app not built at " << appExe.string()
<< "; build the avalonia_test_app target first";
}

STARTUPINFOA si = {sizeof(si)};
pi_ = {};
auto workdir = appExe.parent_path().string();
std::string cmd = "\"" + appExe.string() + "\"";
ASSERT_TRUE(CreateProcessA(nullptr, cmd.data(), nullptr, nullptr, FALSE,
0, nullptr, workdir.c_str(), &si, &pi_))
<< "Failed to launch " << appExe.string() << " (error " << GetLastError() << ")";
if (pi_.hProcess)
WaitForInputIdle(pi_.hProcess, 5000);

for (int attempt = 0; attempt < 10 && !has_visible_window_for_pid(pi_.dwProcessId); ++attempt)
Sleep(500);

for (int attempt = 0; attempt < 30; ++attempt) {
auto output = run_command(make_cmd(lvt, pid_arg()));
auto j = json::parse(output, nullptr, false);
if (!j.is_discarded() && frameworks_contain_avalonia(j) &&
j.contains("root") && has_avalonia_control(j["root"])) {
initialDump_ = std::move(j);
return;
}
Sleep(1000);
}

GTEST_SKIP() << "Avalonia app never became ready: lvt did not detect avalonia with controls. "
<< "Ensure build\\plugins was deployed and the Avalonia plugin/tree walker were built.";
}

void TearDown() override {
if (pi_.hProcess) {
TerminateProcess(pi_.hProcess, 0);
CloseHandle(pi_.hProcess);
CloseHandle(pi_.hThread);
}
}

std::string pid_arg() const {
return "--pid " + std::to_string(pi_.dwProcessId);
}

PROCESS_INFORMATION pi_{};
json initialDump_;
};

TEST_F(AvaloniaFixture, DurableKeysAndQueryRoundTrip) {
ASSERT_TRUE(initialDump_.contains("root"));
ASSERT_TRUE(has_avalonia_under_host(initialDump_["root"]))
<< "Avalonia elements should be stitched under the Avalonia-* HWND";

auto lvt = get_lvt_path();
auto avaloniaReady = [](const json& j) {
return frameworks_contain_avalonia(j) &&
j.contains("root") &&
has_avalonia_control(j["root"]);
};
auto secondDump = dump_ready_tree(lvt, pid_arg(), avaloniaReady);
ASSERT_FALSE(secondDump.is_discarded());

std::vector<const json*> elements;
collect_json_elements(initialDump_["root"], elements);
ASSERT_GT(elements.size(), 0u);

std::set<std::string> keys;
for (auto* el : elements) {
auto key = el->value("key", "");
EXPECT_FALSE(key.empty()) << "Element " << el->value("id", "?") << " has empty durable key";
EXPECT_TRUE(keys.insert(key).second) << "Duplicate durable key: " << key;
}

std::map<std::string, std::string> firstMap;
std::map<std::string, std::string> secondMap;
collect_key_contract_map(initialDump_["root"], "0", firstMap);
collect_key_contract_map(secondDump["root"], "0", secondMap);
EXPECT_EQ(firstMap, secondMap);

auto* button = find_named_control(initialDump_["root"], "ClickButton");
ASSERT_NE(button, nullptr) << "Expected x:Name'd Avalonia button in test tree";
EXPECT_EQ(button->value("framework", ""), "avalonia");
EXPECT_EQ(button->value("type", ""), "Button");

auto buttonKey = button->value("key", "");
ASSERT_FALSE(buttonKey.empty());
auto queriedName = query_prop_until(lvt, pid_arg(), buttonKey, "name", "ClickButton");
EXPECT_EQ(trim_crlf(queriedName), "ClickButton");
}

class WpfSampleFixture : public ::testing::Test {
protected:
static void SetUpTestSuite() {
Expand Down
Loading