Files
OrcaSlicer/tests/slic3rutils/test_printer_agent_plugin.cpp
Andrew dddfc7a8ad 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.
2026-08-04 18:12:37 +08:00

415 lines
17 KiB
C++

#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);
}