mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-08-05 09:07:39 +00:00
Add printer-agent and plugin status tests
Ports the agent lifecycle, duplicate agent-id, built-in-id clash and status-resolution tests. The loader runs on a detached worker thread, so the lifecycle tests live in their own executable. Tests install the production unload-side registry wiring themselves (no GUI in the test binary) and register agents manually so concurrent loads stay deterministic.
This commit is contained in:
414
tests/slic3rutils/test_printer_agent_plugin.cpp
Normal file
414
tests/slic3rutils/test_printer_agent_plugin.cpp
Normal file
@@ -0,0 +1,414 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <slic3r/plugin/PluginManager.hpp>
|
||||
#include <slic3r/Utils/NetworkAgentFactory.hpp>
|
||||
#include <libslic3r/Utils.hpp> // for set_data_dir
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <boost/system/error_code.hpp>
|
||||
|
||||
#include <catch2/catch_session.hpp>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
|
||||
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
|
||||
// <data_dir>/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);
|
||||
}
|
||||
Reference in New Issue
Block a user