diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index c1424064b2..da312fc483 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -8,6 +8,8 @@ add_executable(${_TEST_NAME}_tests test_plugin_capability_config.cpp test_plugin_config.cpp test_plugin_capabilities_in_use.cpp + test_plugin_status.cpp + test_printer_agent.cpp test_plugin_install.cpp test_plugin_lifecycle.cpp test_slicing_pipeline_bindings.cpp @@ -55,3 +57,22 @@ elseif (APPLE) endif() orcaslicer_discover_tests(${_TEST_NAME}_tests) + +# why: the loader runs on a detached worker thread, so its Python interpreter +# ownership model cannot share the embedded interpreter in the main test binary. +add_executable(printer_agent_plugin_tests test_printer_agent_plugin.cpp) + +if (MSVC) + target_link_libraries(printer_agent_plugin_tests Setupapi.lib) +endif () + +target_link_libraries(printer_agent_plugin_tests test_common libslic3r_gui libslic3r pybind11::embed Catch2::Catch2) +set_property(TARGET printer_agent_plugin_tests PROPERTY FOLDER "tests") + +# why: the existing target stages the complete bundled Python home under +# python/, which is the layout PythonInterpreter discovers beside the test exe. +add_dependencies(printer_agent_plugin_tests ${_TEST_NAME}_tests) + +orcaslicer_copy_test_dlls() + +orcaslicer_discover_tests(printer_agent_plugin_tests) diff --git a/tests/slic3rutils/test_plugin_status.cpp b/tests/slic3rutils/test_plugin_status.cpp new file mode 100644 index 0000000000..c51c54c6c6 --- /dev/null +++ b/tests/slic3rutils/test_plugin_status.cpp @@ -0,0 +1,23 @@ +#include + +#include + +using Slic3r::GUI::PluginStatus; +using Slic3r::GUI::resolve_plugin_status; + +TEST_CASE("resolve_plugin_status precedence", "[plugin][status]") { + // the new branch: loaded module + error -> runtime fault, not a load failure. + REQUIRE(resolve_plugin_status(/*loading*/ false, /*has_error*/ true, /*is_loaded*/ true) == PluginStatus::RuntimeError); + + // error without a live module is a load-time Error. + REQUIRE(resolve_plugin_status(false, true, false) == PluginStatus::Error); + + // loading wins over a pending error so a reload never flashes red. + REQUIRE(resolve_plugin_status(true, true, true) == PluginStatus::Loading); + + // healthy loaded plugin. + REQUIRE(resolve_plugin_status(false, false, true) == PluginStatus::Activated); + + // nothing loaded, no error. + REQUIRE(resolve_plugin_status(false, false, false) == PluginStatus::Inactive); +} diff --git a/tests/slic3rutils/test_printer_agent.cpp b/tests/slic3rutils/test_printer_agent.cpp new file mode 100644 index 0000000000..fbab289dd1 --- /dev/null +++ b/tests/slic3rutils/test_printer_agent.cpp @@ -0,0 +1,180 @@ +#include + +#include +#include + +#include +#include + +#include +#include + +using namespace Slic3r; +namespace py = pybind11; + +// =========================================================================== +// UNIT - printer-agent registry duplicate handling. +// Confirms a duplicate agent id is rejected so a plugin cannot shadow a built-in +// or previously registered agent. +// =========================================================================== +TEST_CASE("unit: printer-agent registry register / lookup / duplicate-reject", "[registry][unit]") +{ + // why: the registry is process-global state shared by the test binary, and + // Catch2 may run cases in any order. Use an id that cannot collide with + // built-ins or other cases. Avoid SECTIONs because each section re-runs the + // body and would register the same id twice. + const std::string id = "orca-test::registry-probe-7f3a"; + auto stub_factory = [](std::shared_ptr, const std::string&) + -> std::shared_ptr { return nullptr; }; + + REQUIRE_FALSE(NetworkAgentFactory::is_printer_agent_registered(id)); + + REQUIRE(NetworkAgentFactory::register_printer_agent(id, "Registry Probe", stub_factory)); + REQUIRE(NetworkAgentFactory::is_printer_agent_registered(id)); + + // Re-registering the same id is rejected and does not replace the entry. + REQUIRE_FALSE(NetworkAgentFactory::register_printer_agent(id, "Impostor", stub_factory)); + + // The first registration's display name survives the rejected duplicate. + const PrinterAgentInfo* info = NetworkAgentFactory::get_printer_agent_info(id); + REQUIRE(info != nullptr); + CHECK(info->display_name == "Registry Probe"); + + // It appears exactly once in the UI-population list. + auto agents = NetworkAgentFactory::get_registered_printer_agents(); + int count = 0; + for (const auto& a : agents) + if (a.id == id) + ++count; + CHECK(count == 1); +} + +// =========================================================================== +// INTEGRATION - the orca.printer_agent Python binding surface. +// Boots the embedded interpreter and asserts the C++ to Python contract that +// every printer-agent plugin subclasses. If a binding is renamed or removed, +// plugins fail at runtime even though C++ still compiles. +// =========================================================================== +namespace { + +void ensure_python_initialized() +{ + // why: the `orca` module is embedded in this binary, so a bare interpreter + // can import it without a bundled Python home. The app interpreter expects + // that deployed layout, which is not present beside this test binary. + if (!Py_IsInitialized()) { + static py::scoped_interpreter interpreter; + (void) interpreter; + } +} + +py::module_ import_orca_module() +{ + ensure_python_initialized(); + // Force PythonPluginBridge.cpp into the binary so the embedded + // PYBIND11_EMBEDDED_MODULE(orca, ...) registration (incl. printer_agent) exists. + (void) Slic3r::PythonPluginBridge::instance(); + return py::module_::import("orca"); +} + +} // namespace + +TEST_CASE("integration: orca.printer_agent binding surface", "[integration][Python]") +{ + py::module_ orca = import_orca_module(); + + REQUIRE(py::hasattr(orca, "printer_agent")); + py::object pa = orca.attr("printer_agent"); + + // The base class every printer-agent plugin subclasses. + REQUIRE(py::hasattr(pa, "PrinterAgentBase")); + py::object base = pa.attr("PrinterAgentBase"); + for (const char* method : { "get_agent_info", "connect_printer", "disconnect_printer", + "send_message", "start_discovery", "bind_detect", + "start_print", "get_filament_sync_mode" }) { + CAPTURE(method); + CHECK(py::hasattr(base, method)); + } + + // AgentInfo value type - the registry identity the host reads (id is the key). + REQUIRE(py::hasattr(pa, "AgentInfo")); + py::object info = pa.attr("AgentInfo")("moonraker", "Moonraker", "1.0", "test agent"); + CHECK(info.attr("id").cast() == "moonraker"); + CHECK(info.attr("name").cast() == "Moonraker"); + + // FilamentSyncMode enum the host queries to pick pull vs subscription. + REQUIRE(py::hasattr(pa, "FilamentSyncMode")); + py::object mode = pa.attr("FilamentSyncMode"); + CHECK(py::hasattr(mode, "Pull")); + CHECK(py::hasattr(mode, "Subscription")); + CHECK(py::hasattr(mode, "None_")); + + // Plugin-type enum exposed at module root (host reads it without the GIL). + CHECK(py::hasattr(orca, "PluginType")); +} + +// =========================================================================== +// INTEGRATION - plugin-registration API and discovery-context guards. +// These are the symbols every plugin package uses: the @orca.plugin decorator, +// orca.base, orca.register_capability, and the capability base modules. Checking +// them in the lightweight embedded-interpreter test catches binding breakage +// before the plugin-loader test needs to run. +// =========================================================================== +TEST_CASE("integration: orca plugin-registration API surface + discovery-context guards", "[integration][Python]") +{ + py::module_ orca = import_orca_module(); + + // Module-level surface every plugin package relies on. + // note: no "gcode" module here - this branch has no G-code capability module; + // PostProcessing exists only as a PluginType value. + for (const char* name : { "plugin", "register_capability", "base", "PythonPluginBase", + "PluginType", "PluginResult", "script", "printer_agent", "host" }) { + CAPTURE(name); + CHECK(py::hasattr(orca, name)); + } + + // Plugin package base and capability base contract. + CHECK(py::hasattr(orca.attr("base"), "register_capabilities")); + py::object cap_base = orca.attr("PythonPluginBase"); + for (const char* method : { "get_name", "get_type", "on_load", "on_unload" }) { + CAPTURE(method); + CHECK(py::hasattr(cap_base, method)); + } + + // The script capability module exposes its own base class. + CHECK(py::hasattr(orca.attr("script"), "ScriptPluginCapabilityBase")); + + // PluginType enum carries the values that route a capability, including PrinterConnection. + // note: no PostProcessing value on this branch's binding. + py::object types = orca.attr("PluginType"); + for (const char* value : { "PrinterConnection", "Script" }) { + CAPTURE(value); + CHECK(py::hasattr(types, value)); + } + + // note: this is testing behavior, not normal plugin loading. + // These APIs should only work while Orca is actively loading a plugin. + try { + // Calls Python's orca.register_capability(0) from C++. + // 0 is intentionally bogus. The important part is that there is no active + // plugin load context, so the function should reject the call immediately. + orca.attr("register_capability")(py::int_(0)); + + // If the call above does NOT throw, the test fails here. + FAIL("register_capability outside discovery context must raise"); + } catch (const py::error_already_set& error) { + // pybind11 wraps Python exceptions as py::error_already_set. + // This checks the Python exception type is ValueError. + CHECK(error.matches(PyExc_ValueError)); + } + + try { + // This is the function behind @orca.plugin. + // Same logic as above. + orca.attr("plugin")(py::int_(0)); + + FAIL("@orca.plugin outside discovery context must raise"); + } catch (const py::error_already_set& error) { + CHECK(error.matches(PyExc_ValueError)); + } +} diff --git a/tests/slic3rutils/test_printer_agent_plugin.cpp b/tests/slic3rutils/test_printer_agent_plugin.cpp new file mode 100644 index 0000000000..70c290a795 --- /dev/null +++ b/tests/slic3rutils/test_printer_agent_plugin.cpp @@ -0,0 +1,414 @@ +#include + +#include +#include +#include // for set_data_dir + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +using namespace Slic3r; +namespace fs = boost::filesystem; + +namespace { + +// why: embedding the fake plugin keeps this test self-contained. The PEP 723 +// block declares a printer-connection plugin, and the decorated plugin package +// registers one PrinterAgentBase capability whose AgentInfo.id is the registry +// key asserted below. +constexpr const char* kFakePluginSource = R"PY(# /// script +# requires-python = ">=3.12" +# dependencies = [] +# +# [tool.orcaslicer.plugin] +# name = "Lifecycle Test Agent" +# description = "Minimal printer-agent plugin for the lifecycle test." +# author = "tests" +# version = "1.0.0" +# type = "printer-connection" +# /// +import orca + + +class LifecycleTestAgentCapability(orca.printer_agent.PrinterAgentBase): + def get_name(self): + return "Lifecycle Test Agent" + + def get_agent_info(self): + return orca.printer_agent.AgentInfo( + id="lifecycle-test-agent", + name="Lifecycle Test Agent", + version="1.0.0", + description="Lifecycle test printer agent", + ) + + +@orca.plugin +class LifecycleTestPlugin(orca.base): + def register_capabilities(self): + orca.register_capability(LifecycleTestAgentCapability) +)PY"; + +// why: in production GUI_App::init_plugin_gui_wiring subscribes the agent-registry +// callbacks; the test binary has no GUI, so install the same UNLOAD-side wiring +// once so the tests exercise the production deregister-on-unload path. +// note: the load-side (register) wiring is deliberately NOT installed - the two +// concurrent load_plugin calls in the duplicate-id test would race for the id; +// the tests register manually, in a deterministic order, instead. +void install_agent_registry_wiring() +{ + static bool installed = false; + if (installed) + return; + installed = true; + + PluginManager& mgr = PluginManager::instance(); + mgr.subscribe_on_unload_callback(NetworkAgentFactory::deregister_python_plugin); + mgr.subscribe_on_capability_unload_callback([](const PluginCapabilityId& capability) { + if (capability.type == PluginCapabilityType::PrinterConnection) + NetworkAgentFactory::deregister_python_printer_agent(capability.plugin_key, capability.name); + }); +} + +} // namespace + +// =========================================================================== +// PRINTER-AGENT PLUGIN LIFECYCLE: load, register, unload, deregister. +// +// This test uses its own executable because load_plugin runs on a detached worker +// thread that needs the GIL released on the main thread (the PythonInterpreter +// model). slic3rutils_tests' other Python tests hold the GIL on the main thread +// via a bare scoped_interpreter; the two models can't share one process. +// +// When bundled Python is unavailable in the test environment, the test is +// skipped so source-only or partially staged builds can still run the rest of +// the suite. +// =========================================================================== +TEST_CASE("plugin lifecycle: printer-agent load registers and unload deregisters", "[plugin][lifecycle][Python]") +{ + const std::string plugin_key = "LifecycleTestAgent"; // entry-file stem + const std::string agent_id = "lifecycle-test-agent"; // AgentInfo.id from the plugin + + // Stage a throwaway data directory. Plugins are discovered under + // /orca_plugins, so this controls which plugin is loaded. + const fs::path data_dir = fs::temp_directory_path() / "orca-plugin-lifecycle-test"; + const fs::path plugin_dir = data_dir / "orca_plugins" / plugin_key; + { + boost::system::error_code ec; + fs::remove_all(data_dir, ec); // clear any stale run + } + fs::create_directories(plugin_dir); + { + std::ofstream out((plugin_dir / (plugin_key + ".py")).string(), std::ios::binary); + out << kFakePluginSource; + } + // why: best-effort cleanup even if an assertion throws. + struct DirGuard + { + fs::path p; + ~DirGuard() + { + boost::system::error_code ec; + fs::remove_all(p, ec); + } + } guard{data_dir}; + + Slic3r::set_data_dir(data_dir.string()); + + // Initialize the plugin system on this thread. If the bundled Python home + // is not reachable, skip gracefully. + PluginManager& mgr = PluginManager::instance(); + if (!mgr.initialize()) + SKIP("PythonInterpreter could not initialize (bundled Python home not found in this environment)"); + install_agent_registry_wiring(); + + // Discover synchronously so the catalog holds the descriptor before loading. + mgr.discover_plugins(/*async=*/false, /*clear=*/true); + INFO("expected plugin_key (entry-file stem): " << plugin_key); + PluginDescriptor descriptor; + REQUIRE(mgr.try_get_valid_plugin_descriptor(plugin_key, descriptor)); + + // Load on the worker thread and block until it finishes. + std::string error; + mgr.load_plugin(plugin_key, /*skip_deps=*/true); + const bool loaded = mgr.wait_for_plugin_load(plugin_key, std::chrono::seconds(60), error); + INFO("plugin load error: " << error); + REQUIRE(loaded); + REQUIRE(mgr.is_plugin_loaded(plugin_key)); + + // Resolve the one PrinterConnection capability and register it as an agent. + auto caps = mgr.get_plugin_capabilities(plugin_key, PluginCapabilityType::PrinterConnection); + REQUIRE(caps.size() == 1); + REQUIRE(caps[0] != nullptr); + NetworkAgentFactory::register_python_printer_agent(plugin_key, caps[0]->name()); + + // The AgentInfo.id returned by the plugin is now in the registry. + CHECK(NetworkAgentFactory::is_printer_agent_registered(agent_id)); + + // Unloading the plugin deregisters its Python-backed agent. + REQUIRE(mgr.unload_plugin(plugin_key)); + + // The registry no longer contains the agent id after unload. + CHECK_FALSE(NetworkAgentFactory::is_printer_agent_registered(agent_id)); +} + +namespace { + +// why: duplicate-id coverage needs two distinct plugins with different package +// keys and classes while both return the same AgentInfo.id. +std::string make_agent_plugin_source(const std::string& suffix, const std::string& display_name, const std::string& agent_id) +{ + return std::string{} + + "# /// script\n" + + "# requires-python = \">=3.12\"\n" + + "# dependencies = []\n" + + "#\n" + + "# [tool.orcaslicer.plugin]\n" + + "# name = \"" + display_name + "\"\n" + + "# description = \"Duplicate-id fake printer-agent plugin.\"\n" + + "# author = \"tests\"\n" + + "# version = \"1.0.0\"\n" + + "# type = \"printer-connection\"\n" + + "# ///\n" + + "import orca\n" + + "\n\n" + + "class Cap" + suffix + "(orca.printer_agent.PrinterAgentBase):\n" + + " def get_name(self):\n" + + " return \"" + display_name + "\"\n" + + "\n" + + " def get_agent_info(self):\n" + + " return orca.printer_agent.AgentInfo(\n" + + " id=\"" + agent_id + "\", name=\"" + display_name + "\",\n" + + " version=\"1.0.0\", description=\"duplicate id test\")\n" + + "\n\n" + + "@orca.plugin\n" + + "class Plugin" + suffix + "(orca.base):\n" + + " def register_capabilities(self):\n" + + " orca.register_capability(Cap" + suffix + ")\n"; +} + +void stage_plugin(const fs::path& data_dir, const std::string& plugin_key, const std::string& source) +{ + const fs::path dir = data_dir / "orca_plugins" / plugin_key; + fs::create_directories(dir); + std::ofstream out((dir / (plugin_key + ".py")).string(), std::ios::binary); + out << source; +} + +} // namespace + +// =========================================================================== +// DUPLICATE PRINTER-AGENT IDS +// When two loaded plugins return the same AgentInfo.id, the first registration +// keeps ownership. Unloading it removes the id, and the rejected plugin is not +// promoted automatically. Manual re-registration is required. +// =========================================================================== +TEST_CASE("duplicate agent id is rejected and the winner is not clobbered", "[plugin][lifecycle][Python]") +{ + const std::string key_a = "DuplicateIdAgentA"; // winner, registered first + const std::string key_b = "DuplicateIdAgentB"; // duplicate, rejected + const std::string dup_id = "duplicate-id-agent"; // both plugins return this AgentInfo.id + + const fs::path data_dir = fs::temp_directory_path() / "orca-duplicate-agent-test"; + { + boost::system::error_code ec; + fs::remove_all(data_dir, ec); + } + stage_plugin(data_dir, key_a, make_agent_plugin_source("A", "Duplicate Id Agent A", dup_id)); + stage_plugin(data_dir, key_b, make_agent_plugin_source("B", "Duplicate Id Agent B", dup_id)); + struct DirGuard + { + fs::path p; + ~DirGuard() + { + boost::system::error_code ec; + fs::remove_all(p, ec); + } + } guard{data_dir}; + + Slic3r::set_data_dir(data_dir.string()); + + PluginManager& mgr = PluginManager::instance(); + if (!mgr.initialize()) + SKIP("PythonInterpreter could not initialize (bundled Python home not found in this environment)"); + install_agent_registry_wiring(); + + mgr.discover_plugins(/*async=*/false, /*clear=*/true); + PluginDescriptor descriptor_a; + PluginDescriptor descriptor_b; + REQUIRE(mgr.try_get_valid_plugin_descriptor(key_a, descriptor_a)); + REQUIRE(mgr.try_get_valid_plugin_descriptor(key_b, descriptor_b)); + + std::string error; + mgr.load_plugin(key_a, /*skip_deps=*/true); + mgr.load_plugin(key_b, /*skip_deps=*/true); + const bool loaded_a = mgr.wait_for_plugin_load(key_a, std::chrono::seconds(60), error); + INFO("plugin A load error: " << error); + REQUIRE(loaded_a); + const bool loaded_b = mgr.wait_for_plugin_load(key_b, std::chrono::seconds(60), error); + INFO("plugin B load error: " << error); + REQUIRE(loaded_b); + + auto caps_a = mgr.get_plugin_capabilities(key_a, PluginCapabilityType::PrinterConnection); + auto caps_b = mgr.get_plugin_capabilities(key_b, PluginCapabilityType::PrinterConnection); + REQUIRE(caps_a.size() == 1); + REQUIRE(caps_b.size() == 1); + + // Register A first as the owner, then B with the duplicate id. + NetworkAgentFactory::register_python_printer_agent(key_a, caps_a[0]->name()); + NetworkAgentFactory::register_python_printer_agent(key_b, caps_b[0]->name()); + + // The shared id is registered, and still owned by A; B did not replace it. + CHECK(NetworkAgentFactory::is_printer_agent_registered(dup_id)); + const PrinterAgentInfo* info = NetworkAgentFactory::get_printer_agent_info(dup_id); + REQUIRE(info != nullptr); + CHECK(info->plugin_identifier.find(key_a) != std::string::npos); // owned by A + CHECK(info->plugin_identifier.find(key_b) == std::string::npos); // B never took ownership + + // Unload the owner. The id is not promoted to the still loaded duplicate. + REQUIRE(mgr.unload_plugin(key_a)); + CHECK_FALSE(NetworkAgentFactory::is_printer_agent_registered(dup_id)); + + mgr.unload_plugin(key_b); // the duplicate was loaded but never registered +} + +// =========================================================================== +// NATIVE (BUILT-IN) AGENT ID COLLISION +// A plugin may not hijack a built-in agent id (e.g. "bbl"). The built-in keeps +// ownership and the plugin's agent is rejected. +// =========================================================================== +TEST_CASE("printer-agent plugin cannot claim a built-in agent id", "[plugin][lifecycle][Python]") +{ + // Register the native built-ins so "bbl"/"orca" occupy the registry. + NetworkAgentFactory::register_all_agents(); + REQUIRE(NetworkAgentFactory::is_printer_agent_registered(BBL_PRINTER_AGENT_ID)); + + const std::string plugin_key = "BuiltinClashAgent"; + + const fs::path data_dir = fs::temp_directory_path() / "orca-builtin-clash-test"; + { + boost::system::error_code ec; + fs::remove_all(data_dir, ec); + } + stage_plugin(data_dir, plugin_key, make_agent_plugin_source("Clash", "Builtin Clash", BBL_PRINTER_AGENT_ID)); + struct DirGuard + { + fs::path p; + ~DirGuard() + { + boost::system::error_code ec; + fs::remove_all(p, ec); + } + } guard{data_dir}; + + Slic3r::set_data_dir(data_dir.string()); + + PluginManager& mgr = PluginManager::instance(); + if (!mgr.initialize()) + SKIP("PythonInterpreter could not initialize (bundled Python home not found in this environment)"); + install_agent_registry_wiring(); + + mgr.discover_plugins(/*async=*/false, /*clear=*/true); + PluginDescriptor descriptor; + REQUIRE(mgr.try_get_valid_plugin_descriptor(plugin_key, descriptor)); + + std::string error; + mgr.load_plugin(plugin_key, /*skip_deps=*/true); + REQUIRE(mgr.wait_for_plugin_load(plugin_key, std::chrono::seconds(60), error)); + + auto caps = mgr.get_plugin_capabilities(plugin_key, PluginCapabilityType::PrinterConnection); + REQUIRE(caps.size() == 1); + NetworkAgentFactory::register_python_printer_agent(plugin_key, caps[0]->name()); + + // "bbl" is still the native built-in, not the plugin. + const PrinterAgentInfo* info = NetworkAgentFactory::get_printer_agent_info(BBL_PRINTER_AGENT_ID); + REQUIRE(info != nullptr); + CHECK_FALSE(info->is_plugin()); + CHECK(info->plugin_identifier.find(plugin_key) == std::string::npos); + + mgr.unload_plugin(plugin_key); +} + +// =========================================================================== +// RE-REGISTERING THE SAME CAPABILITY IS A REFRESH, NOT A DUPLICATE +// The guard rejects only a DIFFERENT owner. The same capability registering its +// own id again must stay registered (insert_or_assign refreshes it in place). +// =========================================================================== +TEST_CASE("re-registering the same capability keeps its agent registered", "[plugin][lifecycle][Python]") +{ + const std::string plugin_key = "ReRegisterAgent"; + const std::string agent_id = "re-register-agent"; + + const fs::path data_dir = fs::temp_directory_path() / "orca-re-register-test"; + { + boost::system::error_code ec; + fs::remove_all(data_dir, ec); + } + stage_plugin(data_dir, plugin_key, make_agent_plugin_source("Re", "Re Register", agent_id)); + struct DirGuard + { + fs::path p; + ~DirGuard() + { + boost::system::error_code ec; + fs::remove_all(p, ec); + } + } guard{data_dir}; + + Slic3r::set_data_dir(data_dir.string()); + + PluginManager& mgr = PluginManager::instance(); + if (!mgr.initialize()) + SKIP("PythonInterpreter could not initialize (bundled Python home not found in this environment)"); + install_agent_registry_wiring(); + + mgr.discover_plugins(/*async=*/false, /*clear=*/true); + PluginDescriptor descriptor; + REQUIRE(mgr.try_get_valid_plugin_descriptor(plugin_key, descriptor)); + + std::string error; + mgr.load_plugin(plugin_key, /*skip_deps=*/true); + REQUIRE(mgr.wait_for_plugin_load(plugin_key, std::chrono::seconds(60), error)); + + auto caps = mgr.get_plugin_capabilities(plugin_key, PluginCapabilityType::PrinterConnection); + REQUIRE(caps.size() == 1); + + NetworkAgentFactory::register_python_printer_agent(plugin_key, caps[0]->name()); + REQUIRE(NetworkAgentFactory::is_printer_agent_registered(agent_id)); + const PrinterAgentInfo* first = NetworkAgentFactory::get_printer_agent_info(agent_id); + REQUIRE(first != nullptr); + const std::string owner = first->plugin_identifier; + + // The same capability registering again is a refresh: still registered, same owner, not rejected. + NetworkAgentFactory::register_python_printer_agent(plugin_key, caps[0]->name()); + CHECK(NetworkAgentFactory::is_printer_agent_registered(agent_id)); + const PrinterAgentInfo* second = NetworkAgentFactory::get_printer_agent_info(agent_id); + REQUIRE(second != nullptr); + CHECK(second->plugin_identifier == owner); + + mgr.unload_plugin(plugin_key); +} + +// why: this binary embeds CPython through the PythonInterpreter singleton, which +// lives for the full process. Normal static destruction can tear Python down +// while C++ objects still hold Python handles, producing a Windows heap +// corruption after the assertions have finished. The app has an ordered shutdown +// path; this test harness does not, so it returns the Catch2 result through +// _Exit after flushing output. +int main(int argc, char* argv[]) +{ + const int result = Catch::Session().run(argc, argv); + std::cout.flush(); + std::cerr.flush(); + std::fflush(nullptr); + std::_Exit(result); +}