mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-04 22:31:02 +00:00
* Add developer flag for printer agents
* Parse user print info on the UI thread to prevent heap corruption (#119)
get_user_print_info()'s HTTP fetch can run on a worker thread (e.g. BindJob),
but parse_user_print_info() mutates userMachineList (insert/erase/delete
MachineObject). on_machine_alive (SSDP) mutates the same maps on the UI thread
without locking, so parsing off-thread races the map and frees MachineObjects
out from under it -> heap corruption.
Keep all device-list mutation on the UI thread: parse inline when already on
the main thread, otherwise marshal via CallAfter so it stays serialized with
on_machine_alive.
* Prevent loss of user access code on LAN reselect
Keep user access code intact to maintain access rights even if
device slot is unpopulated, ensuring continuous connection
and status message reception.
* Harden send flow and separate upload failure recovery (#111)
* fix(send): harden FT send path + IP pre-flight UX
* Remove early returns
* Working Moonraker and Qidi printer agent transport (#104)
Folds the Qidi AMS box-mapping print
overrides (apply_box_mapping +
start_* wrappers) that the transport
fix builds on.
* Add support for runtime error status in plugins
Distinguish a loaded plugin whose
capability errored (RuntimeError,
warn-styled, stays checked) from a
load-time Error. Status now derives
via resolve_plugin_status(); enum
ordinal keeps dialog sort priority.
Unloading clears stale errors.
* Resolve duplicate agent ID conflicts
Reject a printer-agent capability
whose agent ID is already owned by
another capability or built-in:
flag the plugin error, disable the
capability, and warn the user
instead of silently ignoring it.
* fix: checkbox should depend on plugin is_loaded status
* Replace fake-enum printer agent dropdown (#121)
A dedicated PrinterAgentChoice field
reads rows straight from the live
agent registry and stores the agent
id string, replacing the fake-coEnum
index mapping. The field moves to
TabPrinter and registers with the
searcher so UnsavedChanges renders
it; the PhysicalPrinterDialog copy
and its update hook are removed
(#125). switch_printer_agent now
resolves ids via
resolve_printer_agent_id.
* Reset device selection on agent swap or unload (#124)
set_live_printer_agent centralizes
the swap: deselect the machine,
clear stale sidebar state and the
previous agent's Other Devices, then
install the new agent (or null when
its provider vanished). Plugin
load/unload callbacks refresh the
dropdown and re-run agent selection.
load_last_machine no longer falls
back to the first available machine.
* Gate agent mode behind use_printer_agents toggle
Replace per-printer auto-activation
(is_current_printer_agent_plugin)
with a global experimental AppConfig
toggle, default off: legacy
print-host behavior is unchanged
until the user opts in. The toggle
drives device-tab routing, print
button defaults, connect-button
visibility and sidebar layout, and
dedups machine-select dialog opens.
* Track BBLPrinterAgentPlugin.py
* 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.
* fix: pin HTTP to prevent connection refusal
Set `use_ssl` to false to ensure Moonraker
connectivity, as the service uses HTTP rather
than HTTPS, preventing connection issues. Initialize
device info early for reliable name resolution.
* Bring Moonraker device panel to feature parity
The monitor panel showed wrong or missing
data for Moonraker printers, and its
controls did nothing.
Push payload now carries layer number and
total layers. Remaining time replaces the
wrong total_duration - print_duration
formula. The chamber light toggle maps to
Klipper SET_PIN / SET_LED, and pause,
resume and stop post to
/printer/print/{action}. Task thumbnails
resolve via /server/files/thumbnails onto
a new MachineObject thumbnail url.
Filament sync switches to pull mode so the
agent is queried on demand.
Not compiled or run.
* Stop blocking print on unreported nozzle data
* fix: make Klipper macro lamp control reliable
* Surface Moonraker webcams and gate unrunnable controls
* Keep Bambu AMS dialect out of the agent waist
M620 is Bambu firmware dialect, not a
neutral command. Composing it in
MachineObject let non-Bambu agents
(Moonraker/Klipper) forward it and
report success on firmware that
cannot run it.
Agents now own the dialect: the
default refusal on IPrinterAgent
returns not-supported so the UI
can say so; BBLPrinterAgent keeps
the byte-identical composition.
* Fix multi-color filament logic
Reuse color decoding across functions to improve
code readability and maintain consistency in
multi-color filament handling.
* Move Moonraker commands off the UI thread
Pause/resume/stop, g-code sends, temps, and
light ran synchronous HTTP on the UI thread,
freezing the app up to 10s per click on slow
or unreachable printers.
Run them on a single agent-owned FIFO worker
so g-code ordering is preserved, while command
translation stays synchronous so unsupported-
command dialogs still work.
Add a pending-disabled state to the pause,
resume, and abort buttons for Moonraker-family
printers: the icon only flips once the
WebSocket reports the real state, which also
rules out double-click races.
* Show Snapmaker U1 camera in Device tab
The U1 exposes no /server/webcams/list entry;
its camera only captures after an explicit
camera.start_monitor RPC, which the Moonraker
websocket executes unauthenticated but only
answers over MQTT - so the call is fire and
forget.
Start the camera when the camera view is
shown and renew every 300 s: the printer
retires the capture task at ~362 s and
stop_monitor is accepted but ineffective,
so teardown is simply to stop renewing.
Frames land in monitor.jpg as still JPEGs
(~2 fps at interval 0), so the webview loads
a local HTML wrapper that repolls with a
cache buster.
* fix: start stream when camera URL changes
* fix: stop Qidi slot parse throwing on null
* Keep printer-agent progress in sync
Keep the shared task progress aligned with agent
reports that lack Bambu cloud task identity.
Release the lazily allocated task during reset to
avoid leaks when machine objects reconnect.
* docs: document the printer-agent subsystem
* fix: merge access codes into one
* Reconcile implementation split with PR tip
* feat: abstract remaining gcode commands in devicemanager
* refactor: abstract bambu specific protocol to printer agent
* refactor: push bbl workflows to bbl printer agent
* remove unused
* fix: default impl
* fix callback error
* fix: remove redundant cache
* specify api for getting file transfer url
* revert file transfer abstraction
* fix: ams filament mapping workflow
* feat: update qidi to use subscription based filament sync mode
* fix: resolve stubgen byte header conflict
* fix: ams sync info and periodic ams sync via subscription workflow
* fix: skip filament sync dialog if filamentSyncMode is none
* feat: parse nozzle information for qidi and moonraker printer agents
* fix: extend access code requirements t 0, 8 or more characters.
* remove irrelevant docs
* fix: remove heavy includes from IPrinterAgent
* fix: defer filesystem and camera abstractions
* fix: remote do_fetch_filament_info from tests
* cleanup moonraker and snapmaker printer agents
* fix: access codes regression
* fix: tests
* fix: printer agent switching on preset change
* fix: remove unused variable
* fix: snapmaker U1 SelectMachineDialog blocking print
* fix: merge artifact
* fix: clear up some unrelated changes
* feat: connect to cloud printer and monitor
* feat: connect to cloud printer and monitor
* feat: generic camera stream support for http snapshot and rtsp
* fix: build & access code UI
* feat: generic camera stream support for http snapshot and rtsp
* fix: build & access code UI
* feat: connect to cloud printer and monitor
* feat: connect to cloud printer and monitor
* fix: build errors
* feat: camera via webrtc
* fix: build
* fix: cmake
* feat: remove frame assembler and change config to set protocol
* fix: orcaprinteragent refactor
* fix: LAN paths and camera stream
* feat: use ffmpeg to render http camera stream
* fix: make model_id/dev_type optional instead of blocking
* fix: connect via ip dialog
* feat: LAN impl for Orca Printer Agent
* fix: model_id resolution method for non bambu printers
* fix: ffmpeg http camera stream jittering due to incomplete frames
* fix: revert sdcard check
* feat: check printer storage status before sending
* fix: moonraker printer agent hang on printer power cut
* fix: shim layer for any compatibiliity changes
* fix: cloud printers were using the wrong MQTT endpoint
* feat: cloud download via HTTP
* temp: doc for intended change
* fix: warnings
* fix: warnings
* fix: camera auto-play on startup
* fix: split infra from impl
* fix: uninitialized ams state blocking print
* Fixes nullptr deref
* Log first before std::move
* fix: printer agent virutal optional functions
* fix: parameterize orcaslicer_copy_test_dlls() for printer_agent_plugin_tests
* Revert "fix: parameterize orcaslicer_copy_test_dlls() for printer_agent_plugin_tests"
This reverts commit 2f566e3779.
* Guard libdatachannel. Remove unused code
* fix: unit tests & unused variables
* fix(ci): deps build order for datachannel
* Resolve printer agent first before getting cloud printer agent
* fix(ci): set depends openssl
* fix: re-include apply header guarded by ifdef __APPLE__
* fix(ci): add libdatachannel to flatpak manifest
* fix: add internal_developer_mode chekc back to MediaPlayCtrl::load()
* fix: invoke js clearInterval on WebMediaController::stop
* fix: change rtc log level
* fix: inject provider, agent id and generation to get_user_print_info to ensure correct metadata
* fix: revert moonraker specific behavior
* fix: remove stale comment
* fix: use ORCA_CLOUD_PROVIDER instead of hardcoded string
* fix: remove hardcoded ICE servers
* feat: enable https camera stream mode
* fix: move non-mandatory printer agent function stubs to IPrinterAgent
* fix: dedupe compatible printer type check
* fix: stop the correct media controller
* fix: scope get_my_machine_list to printers listed under the current printer agent
* fix: move printer agent plugin tests into test_plugin_lifecycle.cpp
* fix: always build bundled DataChannel dep
* fix: disable unused DataChannel media support
* revert: filament sync work
* fix: wrap command_* with small wrapper
* fix: regression bug, connecting to bambu needs bblp username
* fix: default impl for vendor agnostic gcode commansd
* refactor: media controller playback routing and ownership
* fix: bump libdatachannel ver & update flatpak to use tar instead
* fix: stop flatpak DataChannel build from re-cloning over the sandboxed network
* fix: update windows ffmpeg prebuild
* fix: update printer agent plugin API
* fix: shift camera signaling channel to network agent
* fix: follow external-packages for flatpak libdatachannel deps & add flatpak path to use source_dir
* feat: add printer-agent.md doc to HLSD
* fix: guard DeviceManager command dispatch when no printer agent is bound
* fix: port BBL implementations from #15711
* refactor: connect_printer api and dialog
* fix(tests): make omitted printer agent operations answer like a missing agent
* fix: preserve printer agent defaults in PrinterAgentPluginCapabilityTrampoline
* test: cover printer agent default command dispatch
* fix: validate windows FFmpeg avformat library
* feat: extend optional printer model warnings to calibration & ams workflows
* fix: handle malformed printer progress values safely
* fix: clear webview document on stop
* chore: reduce diagnostic logging level to trace
* refactor: centralize printer compatibility checks
* tests: add device manager integration coverage
* tests: cover WebMediaController lifecycle with wxWebView stub
* fix: make integration tests headless
* refactor: collapse command_ams_refresh_rfid and command_ams_refresh_rfid2
* refactor: make printer connection SSL agent-specific
* fix: persist input printer host and port
* fix: use correct device id for Moonraker connections
* fix: make moonraker gcode commands asynchronous
* fix: preserve moonraker device names
* fix: add include for non BBL_RELEASE_TO_PUBLIC path in BBLPrinterAgent
---------
Co-authored-by: Andrew <159703254+andrewsoonqn@users.noreply.github.com>
Co-authored-by: SoftFever <softfeverever@gmail.com>
Co-authored-by: Lam Wei Lun <weilun.lam@gmail.com>
1047 lines
40 KiB
C++
1047 lines
40 KiB
C++
#include <catch2/catch_all.hpp>
|
|
|
|
#include <libslic3r/LifecycleEvents.hpp>
|
|
#include <libslic3r/Utils.hpp>
|
|
#include <slic3r/Utils/NetworkAgentFactory.hpp>
|
|
#include <slic3r/plugin/PluginDescriptor.hpp>
|
|
#include <slic3r/plugin/PluginManager.hpp>
|
|
#include <slic3r/plugin/PluginFsUtils.hpp>
|
|
#include <slic3r/plugin/PythonInterpreter.hpp>
|
|
|
|
#include "plugin_test_utils.hpp"
|
|
|
|
#include <boost/filesystem.hpp>
|
|
|
|
#include <algorithm>
|
|
#include <chrono>
|
|
#include <condition_variable>
|
|
#include <fstream>
|
|
#include <mutex>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
using namespace Slic3r;
|
|
namespace fs = boost::filesystem;
|
|
|
|
// Plugin load/unload lifecycle: discovery -> load -> capability materialization -> enable/disable
|
|
// -> unload.
|
|
//
|
|
// Each Catch2 test case runs in its own process (catch_discover_tests), so the PluginManager and
|
|
// interpreter singletons are brought up at most once per test.
|
|
|
|
namespace {
|
|
|
|
// Brings the plugin system up, and tears it down explicitly at the end of the test.
|
|
//
|
|
// Shutting the interpreter down here, rather than leaving it to PythonInterpreter's static
|
|
// destructor, mirrors what the app does (GUI_App finalizes it before exit). Left to static
|
|
// destruction, shutdown()'s logging runs after boost::log has torn down its thread-local storage
|
|
// and throws, aborting the process after the tests have already passed.
|
|
//
|
|
// Declare this FIRST in a test so it is destroyed last.
|
|
struct ScopedPluginManager
|
|
{
|
|
bool initialized = false;
|
|
|
|
ScopedPluginManager() { initialized = PluginManager::instance().initialize(); }
|
|
~ScopedPluginManager()
|
|
{
|
|
PluginManager::instance().shutdown();
|
|
PythonInterpreter::instance().shutdown();
|
|
}
|
|
};
|
|
|
|
// A minimal script plugin exposing exactly one capability, "Echo".
|
|
const char* const ECHO_PLUGIN_SOURCE = R"PY(# /// script
|
|
# requires-python = ">=3.12"
|
|
#
|
|
# [tool.orcaslicer.plugin]
|
|
# name = "Echo Plugin"
|
|
# description = "Plugin lifecycle characterization fixture"
|
|
# author = "OrcaSlicer"
|
|
# version = "1.0"
|
|
# type = "script"
|
|
# ///
|
|
import orca
|
|
|
|
class Echo(orca.script.ScriptPluginCapabilityBase):
|
|
def get_name(self):
|
|
return "Echo"
|
|
|
|
def execute(self, ctx):
|
|
return orca.ExecutionResult.success()
|
|
|
|
@orca.plugin
|
|
class EchoPackage(orca.base):
|
|
def register_capabilities(self):
|
|
orca.register_capability(Echo)
|
|
)PY";
|
|
|
|
// A minimal printer-agent plugin exposing exactly one PrinterConnection capability.
|
|
const char* const PRINTER_AGENT_PLUGIN_SOURCE = 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";
|
|
|
|
// 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 these tests exercise the production deregister-on-unload path.
|
|
//
|
|
// The load-side (register) wiring is deliberately not installed: duplicate-id
|
|
// coverage registers agents manually in a deterministic order instead.
|
|
void install_agent_registry_wiring()
|
|
{
|
|
static bool installed = false;
|
|
if (installed)
|
|
return;
|
|
installed = true;
|
|
|
|
PluginManager& manager = PluginManager::instance();
|
|
manager.subscribe_on_unload_callback(NetworkAgentFactory::deregister_python_plugin);
|
|
manager.subscribe_on_capability_unload_callback([](const PluginCapabilityId& capability) {
|
|
if (capability.type == PluginCapabilityType::PrinterConnection)
|
|
NetworkAgentFactory::deregister_python_printer_agent(capability.plugin_key, capability.name);
|
|
});
|
|
}
|
|
|
|
// 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";
|
|
}
|
|
|
|
// Writes {data_dir}/orca_plugins/<stem>/<stem>.py and returns the plugin directory.
|
|
fs::path write_plugin(const ScopedDataDir& data_dir_guard, const std::string& stem, const std::string& source)
|
|
{
|
|
const fs::path plugin_dir = data_dir_guard.plugins_dir() / stem;
|
|
fs::create_directories(plugin_dir);
|
|
|
|
std::ofstream out((plugin_dir / (stem + ".py")).string(), std::ios::binary);
|
|
out << source;
|
|
out.close();
|
|
|
|
return plugin_dir;
|
|
}
|
|
|
|
// Loads a plugin and blocks until the detached worker thread is done with it.
|
|
bool load_and_wait(PluginManager& manager,
|
|
const std::string& plugin_key,
|
|
std::string& error,
|
|
std::vector<std::string> capabilities_to_enable = {})
|
|
{
|
|
manager.load_plugin(plugin_key, /*skip_deps=*/true, std::move(capabilities_to_enable));
|
|
return manager.wait_for_plugin_load(plugin_key, std::chrono::seconds(120), error);
|
|
}
|
|
|
|
std::shared_ptr<PluginCapabilityInterface> find_capability(PluginManager& manager, const std::string& plugin_key,
|
|
const std::string& name)
|
|
{ return manager.get_plugin_capability({PluginCapabilityType::Unknown, name, plugin_key}, /*only_enabled=*/false); }
|
|
|
|
std::vector<std::shared_ptr<PluginCapabilityInterface>> capabilities_of(PluginManager& manager, const std::string& plugin_key)
|
|
{
|
|
return manager.get_plugin_capabilities(plugin_key, PluginCapabilityType::Unknown, /*only_enabled=*/false);
|
|
}
|
|
|
|
PluginDescriptor descriptor_of(PluginManager& manager, const std::string& plugin_key)
|
|
{
|
|
PluginDescriptor descriptor;
|
|
manager.try_get_plugin_descriptor(plugin_key, descriptor);
|
|
return descriptor;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("Lifecycle hook shutdown drains concurrent dispatch", "[PluginLifecycle][LifecycleEvents]")
|
|
{
|
|
std::mutex mutex;
|
|
std::condition_variable cv;
|
|
bool callback_entered = false;
|
|
bool release_callback = false;
|
|
bool shutdown_started = false;
|
|
bool shutdown_finished = false;
|
|
|
|
set_lifecycle_hook_fn([&](LifecycleEvent, const LifecycleEventContext&) {
|
|
std::unique_lock<std::mutex> lock(mutex);
|
|
callback_entered = true;
|
|
cv.notify_all();
|
|
cv.wait(lock, [&] { return release_callback; });
|
|
});
|
|
|
|
std::thread dispatch_thread([] {
|
|
LifecycleEventContext ctx;
|
|
fire_lifecycle_event(LifecycleEvent::ProjectOpened, ctx);
|
|
});
|
|
|
|
bool callback_was_entered = false;
|
|
{
|
|
std::unique_lock<std::mutex> lock(mutex);
|
|
callback_was_entered = cv.wait_for(lock, std::chrono::seconds(5), [&] { return callback_entered; });
|
|
}
|
|
if (!callback_was_entered) {
|
|
{
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
release_callback = true;
|
|
cv.notify_all();
|
|
}
|
|
dispatch_thread.join();
|
|
set_lifecycle_hook_fn(nullptr);
|
|
FAIL("Lifecycle callback did not start");
|
|
}
|
|
|
|
std::thread shutdown_thread([&] {
|
|
{
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
shutdown_started = true;
|
|
cv.notify_all();
|
|
}
|
|
set_lifecycle_hook_fn(nullptr);
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
shutdown_finished = true;
|
|
cv.notify_all();
|
|
});
|
|
|
|
bool shutdown_was_started = false;
|
|
{
|
|
std::unique_lock<std::mutex> lock(mutex);
|
|
shutdown_was_started = cv.wait_for(lock, std::chrono::seconds(5), [&] { return shutdown_started; });
|
|
if (shutdown_was_started)
|
|
CHECK_FALSE(cv.wait_for(lock, std::chrono::milliseconds(100), [&] { return shutdown_finished; }));
|
|
release_callback = true;
|
|
cv.notify_all();
|
|
}
|
|
|
|
dispatch_thread.join();
|
|
shutdown_thread.join();
|
|
if (!shutdown_was_started)
|
|
FAIL("Lifecycle hook shutdown did not start");
|
|
CHECK(shutdown_finished);
|
|
}
|
|
|
|
TEST_CASE("A discovered script plugin loads and materializes its capability", "[PluginLifecycle][Python]")
|
|
{
|
|
ScopedPluginManager plugin_system;
|
|
if (!plugin_system.initialized)
|
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
|
|
|
ScopedDataDir data_dir_guard("lifecycle-load");
|
|
write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
|
|
|
|
PluginManager& manager = PluginManager::instance();
|
|
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
|
|
|
PluginDescriptor descriptor;
|
|
REQUIRE(manager.try_get_valid_plugin_descriptor("Echo_Plugin", descriptor));
|
|
CHECK(descriptor.name == "Echo Plugin");
|
|
|
|
std::string error;
|
|
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
|
|
INFO("load error: " << error);
|
|
CHECK(error.empty());
|
|
|
|
CHECK(manager.is_plugin_loaded("Echo_Plugin"));
|
|
CHECK(manager.get_plugin_load_error("Echo_Plugin").empty());
|
|
|
|
const auto capabilities = capabilities_of(manager, "Echo_Plugin");
|
|
REQUIRE(capabilities.size() == 1);
|
|
|
|
const auto& echo = capabilities.front();
|
|
CHECK(echo->name() == "Echo");
|
|
CHECK(echo->type() == PluginCapabilityType::Script);
|
|
CHECK(echo->is_enabled());
|
|
CHECK(echo->audit_plugin_key() == "Echo_Plugin");
|
|
|
|
CHECK(manager.get_plugin_capability({PluginCapabilityType::Script, "Echo", "Echo_Plugin"}) == echo);
|
|
|
|
manager.unload_plugin("Echo_Plugin");
|
|
}
|
|
|
|
TEST_CASE("A printer-agent plugin registers and deregisters with its lifecycle", "[PluginLifecycle][Python]")
|
|
{
|
|
ScopedPluginManager plugin_system;
|
|
if (!plugin_system.initialized)
|
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
|
|
|
ScopedDataDir data_dir_guard("printer-agent-load");
|
|
write_plugin(data_dir_guard, "LifecycleTestAgent", PRINTER_AGENT_PLUGIN_SOURCE);
|
|
|
|
PluginManager& manager = PluginManager::instance();
|
|
install_agent_registry_wiring();
|
|
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
|
|
|
PluginDescriptor descriptor;
|
|
REQUIRE(manager.try_get_valid_plugin_descriptor("LifecycleTestAgent", descriptor));
|
|
|
|
std::string error;
|
|
REQUIRE(load_and_wait(manager, "LifecycleTestAgent", error));
|
|
INFO("load error: " << error);
|
|
|
|
const auto capabilities = manager.get_plugin_capabilities(
|
|
"LifecycleTestAgent", PluginCapabilityType::PrinterConnection);
|
|
REQUIRE(capabilities.size() == 1);
|
|
NetworkAgentFactory::register_python_printer_agent("LifecycleTestAgent", capabilities.front()->name());
|
|
|
|
CHECK(NetworkAgentFactory::is_printer_agent_registered("lifecycle-test-agent"));
|
|
REQUIRE(manager.unload_plugin("LifecycleTestAgent"));
|
|
CHECK_FALSE(NetworkAgentFactory::is_printer_agent_registered("lifecycle-test-agent"));
|
|
}
|
|
|
|
TEST_CASE("A duplicate printer-agent id is rejected without clobbering its owner", "[PluginLifecycle][Python]")
|
|
{
|
|
ScopedPluginManager plugin_system;
|
|
if (!plugin_system.initialized)
|
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
|
|
|
const std::string key_a = "DuplicateIdAgentA";
|
|
const std::string key_b = "DuplicateIdAgentB";
|
|
const std::string dup_id = "duplicate-id-agent";
|
|
|
|
ScopedDataDir data_dir_guard("printer-agent-duplicate");
|
|
write_plugin(data_dir_guard, key_a, make_agent_plugin_source("A", "Duplicate Id Agent A", dup_id));
|
|
write_plugin(data_dir_guard, key_b, make_agent_plugin_source("B", "Duplicate Id Agent B", dup_id));
|
|
|
|
PluginManager& manager = PluginManager::instance();
|
|
install_agent_registry_wiring();
|
|
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
|
|
|
PluginDescriptor descriptor_a;
|
|
PluginDescriptor descriptor_b;
|
|
REQUIRE(manager.try_get_valid_plugin_descriptor(key_a, descriptor_a));
|
|
REQUIRE(manager.try_get_valid_plugin_descriptor(key_b, descriptor_b));
|
|
|
|
std::string error;
|
|
REQUIRE(load_and_wait(manager, key_a, error));
|
|
REQUIRE(load_and_wait(manager, key_b, error));
|
|
|
|
const auto capabilities_a = manager.get_plugin_capabilities(key_a, PluginCapabilityType::PrinterConnection);
|
|
const auto capabilities_b = manager.get_plugin_capabilities(key_b, PluginCapabilityType::PrinterConnection);
|
|
REQUIRE(capabilities_a.size() == 1);
|
|
REQUIRE(capabilities_b.size() == 1);
|
|
|
|
NetworkAgentFactory::register_python_printer_agent(key_a, capabilities_a.front()->name());
|
|
NetworkAgentFactory::register_python_printer_agent(key_b, capabilities_b.front()->name());
|
|
|
|
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);
|
|
CHECK(info->plugin_identifier.find(key_b) == std::string::npos);
|
|
|
|
REQUIRE(manager.unload_plugin(key_a));
|
|
CHECK_FALSE(NetworkAgentFactory::is_printer_agent_registered(dup_id));
|
|
manager.unload_plugin(key_b);
|
|
}
|
|
|
|
TEST_CASE("A printer-agent plugin cannot claim a built-in agent id", "[PluginLifecycle][Python]")
|
|
{
|
|
ScopedPluginManager plugin_system;
|
|
if (!plugin_system.initialized)
|
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
|
|
|
NetworkAgentFactory::register_all_agents();
|
|
REQUIRE(NetworkAgentFactory::is_printer_agent_registered(BBL_PRINTER_AGENT_ID));
|
|
|
|
const std::string plugin_key = "BuiltinClashAgent";
|
|
ScopedDataDir data_dir_guard("printer-agent-builtin-clash");
|
|
write_plugin(data_dir_guard, plugin_key,
|
|
make_agent_plugin_source("Clash", "Builtin Clash", BBL_PRINTER_AGENT_ID));
|
|
|
|
PluginManager& manager = PluginManager::instance();
|
|
install_agent_registry_wiring();
|
|
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
|
|
|
PluginDescriptor descriptor;
|
|
REQUIRE(manager.try_get_valid_plugin_descriptor(plugin_key, descriptor));
|
|
|
|
std::string error;
|
|
REQUIRE(load_and_wait(manager, plugin_key, error));
|
|
const auto capabilities = manager.get_plugin_capabilities(plugin_key, PluginCapabilityType::PrinterConnection);
|
|
REQUIRE(capabilities.size() == 1);
|
|
NetworkAgentFactory::register_python_printer_agent(plugin_key, capabilities.front()->name());
|
|
|
|
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);
|
|
|
|
manager.unload_plugin(plugin_key);
|
|
}
|
|
|
|
TEST_CASE("Re-registering a printer-agent capability preserves its registration", "[PluginLifecycle][Python]")
|
|
{
|
|
ScopedPluginManager plugin_system;
|
|
if (!plugin_system.initialized)
|
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
|
|
|
const std::string plugin_key = "ReRegisterAgent";
|
|
const std::string agent_id = "re-register-agent";
|
|
|
|
ScopedDataDir data_dir_guard("printer-agent-reregister");
|
|
write_plugin(data_dir_guard, plugin_key,
|
|
make_agent_plugin_source("Re", "Re Register", agent_id));
|
|
|
|
PluginManager& manager = PluginManager::instance();
|
|
install_agent_registry_wiring();
|
|
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
|
|
|
PluginDescriptor descriptor;
|
|
REQUIRE(manager.try_get_valid_plugin_descriptor(plugin_key, descriptor));
|
|
|
|
std::string error;
|
|
REQUIRE(load_and_wait(manager, plugin_key, error));
|
|
const auto capabilities = manager.get_plugin_capabilities(plugin_key, PluginCapabilityType::PrinterConnection);
|
|
REQUIRE(capabilities.size() == 1);
|
|
|
|
NetworkAgentFactory::register_python_printer_agent(plugin_key, capabilities.front()->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;
|
|
|
|
NetworkAgentFactory::register_python_printer_agent(plugin_key, capabilities.front()->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);
|
|
|
|
manager.unload_plugin(plugin_key);
|
|
}
|
|
|
|
TEST_CASE("Plugin manager can initialize again after shutdown", "[PluginLifecycle][Python]")
|
|
{
|
|
ScopedPluginManager plugin_system;
|
|
if (!plugin_system.initialized)
|
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
|
|
|
ScopedDataDir data_dir_guard("lifecycle-reinitialize");
|
|
write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
|
|
|
|
PluginManager& manager = PluginManager::instance();
|
|
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
|
manager.shutdown();
|
|
|
|
REQUIRE(manager.initialize());
|
|
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
|
|
|
std::string error;
|
|
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
|
|
CHECK(manager.is_plugin_loaded("Echo_Plugin"));
|
|
|
|
manager.unload_plugin("Echo_Plugin");
|
|
}
|
|
|
|
TEST_CASE("Duplicate discovered plugin keys are reported", "[PluginLifecycle][Python]")
|
|
{
|
|
ScopedPluginManager plugin_system;
|
|
if (!plugin_system.initialized)
|
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
|
|
|
ScopedDataDir data_dir_guard("lifecycle-duplicate-key");
|
|
for (const char* directory_name : {"first", "second"}) {
|
|
const fs::path plugin_dir = data_dir_guard.plugins_dir() / directory_name;
|
|
fs::create_directories(plugin_dir);
|
|
std::ofstream out((plugin_dir / "Shared.py").string(), std::ios::binary);
|
|
out << ECHO_PLUGIN_SOURCE;
|
|
}
|
|
|
|
PluginManager& manager = PluginManager::instance();
|
|
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
|
|
|
PluginDescriptor descriptor;
|
|
REQUIRE(manager.try_get_plugin_descriptor("Shared", descriptor));
|
|
CHECK(descriptor.has_error());
|
|
CHECK(descriptor.normalized_error().find("Duplicate plugin key") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("Unloading a plugin drops the package and its capabilities", "[PluginLifecycle][Python]")
|
|
{
|
|
ScopedPluginManager plugin_system;
|
|
if (!plugin_system.initialized)
|
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
|
|
|
ScopedDataDir data_dir_guard("lifecycle-unload");
|
|
write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
|
|
|
|
PluginManager& manager = PluginManager::instance();
|
|
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
|
|
|
std::string error;
|
|
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
|
|
REQUIRE(manager.is_plugin_loaded("Echo_Plugin"));
|
|
|
|
CHECK(manager.unload_plugin("Echo_Plugin"));
|
|
|
|
CHECK_FALSE(manager.is_plugin_loaded("Echo_Plugin"));
|
|
CHECK(manager.get_plugin_capabilities("Echo_Plugin").empty());
|
|
CHECK(manager.get_plugin_capability({PluginCapabilityType::Script, "Echo", "Echo_Plugin"}) == nullptr);
|
|
|
|
// The package stays discovered, but nothing capability-shaped survives the unload.
|
|
const PluginDescriptor descriptor = descriptor_of(manager, "Echo_Plugin");
|
|
CHECK(descriptor.plugin_key == "Echo_Plugin");
|
|
CHECK(capabilities_of(manager, "Echo_Plugin").empty());
|
|
}
|
|
|
|
TEST_CASE("Python module release removes package submodules and owned sys.path", "[PluginLifecycle][Python]")
|
|
{
|
|
ScopedPluginManager plugin_system;
|
|
if (!plugin_system.initialized)
|
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
|
|
|
ScopedDataDir data_dir_guard("module-release");
|
|
const fs::path package_root = data_dir_guard.dir / "reload_package";
|
|
fs::create_directories(package_root);
|
|
|
|
auto write_package = [&](const std::string& value) {
|
|
std::ofstream init((package_root / "__init__.py").string());
|
|
init << "import reload_helper\nfrom . import sub\nVALUE = sub.VALUE\n";
|
|
std::ofstream sub((package_root / "sub.py").string());
|
|
sub << "VALUE = " << value << "\n";
|
|
std::ofstream helper((package_root.parent_path() / "reload_helper.py").string());
|
|
helper << "VALUE = 'helper'\n";
|
|
};
|
|
|
|
write_package("'old'");
|
|
|
|
PythonInterpreter& interpreter = PythonInterpreter::instance();
|
|
std::vector<std::string> paths;
|
|
std::vector<std::string> modules;
|
|
std::string error;
|
|
PyObject* module = interpreter.load_module_from_directory(
|
|
package_root.parent_path().string(), "reload_package", error, &paths, &modules);
|
|
REQUIRE(module != nullptr);
|
|
INFO("module load error: " << error);
|
|
REQUIRE(error.empty());
|
|
REQUIRE(paths.size() == 1);
|
|
|
|
{
|
|
PythonGILState gil;
|
|
REQUIRE(gil);
|
|
PyObject* modules = PyImport_GetModuleDict();
|
|
REQUIRE(modules != nullptr);
|
|
CHECK(PyDict_GetItemString(modules, "reload_package") != nullptr);
|
|
CHECK(PyDict_GetItemString(modules, "reload_package.sub") != nullptr);
|
|
CHECK(PyDict_GetItemString(modules, "reload_helper") != nullptr);
|
|
}
|
|
|
|
Plugin loaded;
|
|
loaded.module = module;
|
|
loaded.module_name = "reload_package";
|
|
loaded.plugin_sys_paths = paths;
|
|
loaded.plugin_modules = modules;
|
|
loaded.release_module();
|
|
|
|
{
|
|
PythonGILState gil;
|
|
REQUIRE(gil);
|
|
PyObject* modules = PyImport_GetModuleDict();
|
|
REQUIRE(modules != nullptr);
|
|
CHECK(PyDict_GetItemString(modules, "reload_package") == nullptr);
|
|
CHECK(PyDict_GetItemString(modules, "reload_package.sub") == nullptr);
|
|
CHECK(PyDict_GetItemString(modules, "reload_helper") == nullptr);
|
|
|
|
PyObject* sys_path = PySys_GetObject("path");
|
|
REQUIRE(sys_path != nullptr);
|
|
PyObjectPtr path(PyUnicode_DecodeFSDefault(paths.front().c_str()));
|
|
REQUIRE(path);
|
|
CHECK(PySequence_Contains(sys_path, path.get()) == 0);
|
|
}
|
|
|
|
// Ensure the next import executes the new submodule rather than reusing a stale package child.
|
|
write_package("'new'");
|
|
boost::system::error_code ec;
|
|
fs::remove_all(package_root / "__pycache__", ec);
|
|
|
|
paths.clear();
|
|
modules.clear();
|
|
module = interpreter.load_module_from_directory(
|
|
package_root.parent_path().string(), "reload_package", error, &paths, &modules);
|
|
REQUIRE(module != nullptr);
|
|
REQUIRE(error.empty());
|
|
|
|
{
|
|
PythonGILState gil;
|
|
REQUIRE(gil);
|
|
PyObjectPtr value(PyObject_GetAttrString(module, "VALUE"));
|
|
REQUIRE(value);
|
|
CHECK(std::string(PyUnicode_AsUTF8(value.get())) == "new");
|
|
}
|
|
|
|
loaded.module = module;
|
|
loaded.module_name = "reload_package";
|
|
loaded.plugin_sys_paths = paths;
|
|
loaded.plugin_modules = modules;
|
|
loaded.release_module();
|
|
}
|
|
|
|
TEST_CASE("A capability disabled in the sidecar loads disabled", "[PluginLifecycle][Python]")
|
|
{
|
|
ScopedPluginManager plugin_system;
|
|
if (!plugin_system.initialized)
|
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
|
|
|
ScopedDataDir data_dir_guard("lifecycle-disabled");
|
|
const fs::path plugin_dir = write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
|
|
|
|
// Pre-seed the sidecar with the capability disabled, as a previous session would have.
|
|
PluginInstallState state;
|
|
state.installed_from = "local";
|
|
state.installed_version = "1.0";
|
|
state.plugin_name = "Echo Plugin";
|
|
state.enabled = true;
|
|
state.capabilities = {{"Echo", false}};
|
|
REQUIRE(write_install_state(plugin_dir, state));
|
|
|
|
PluginManager& manager = PluginManager::instance();
|
|
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
|
|
|
std::string error;
|
|
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
|
|
REQUIRE(manager.is_plugin_loaded("Echo_Plugin"));
|
|
|
|
// The capability still materializes — it is loaded, but logically disabled, so consumers skip it.
|
|
const auto echo = find_capability(manager, "Echo_Plugin", "Echo");
|
|
REQUIRE(echo != nullptr);
|
|
CHECK_FALSE(echo->is_enabled());
|
|
|
|
CHECK(manager.get_plugin_capabilities("Echo_Plugin", PluginCapabilityType::Unknown, /*only_enabled=*/true).empty());
|
|
CHECK(manager.get_plugin_capabilities("Echo_Plugin", PluginCapabilityType::Unknown, /*only_enabled=*/false).size() == 1);
|
|
|
|
// An empty load request must preserve the persisted disabled state even when the package is
|
|
// already loaded.
|
|
std::string no_request_error;
|
|
REQUIRE(load_and_wait(manager, "Echo_Plugin", no_request_error));
|
|
CHECK_FALSE(find_capability(manager, "Echo_Plugin", "Echo")->is_enabled());
|
|
|
|
manager.unload_plugin("Echo_Plugin");
|
|
}
|
|
|
|
TEST_CASE("Disabling a capability round-trips through the sidecar and survives a reload", "[PluginLifecycle][Python]")
|
|
{
|
|
ScopedPluginManager plugin_system;
|
|
if (!plugin_system.initialized)
|
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
|
|
|
ScopedDataDir data_dir_guard("lifecycle-roundtrip");
|
|
const fs::path plugin_dir = write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
|
|
|
|
PluginManager& manager = PluginManager::instance();
|
|
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
|
|
|
std::string error;
|
|
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
|
|
REQUIRE(find_capability(manager, "Echo_Plugin", "Echo")->is_enabled());
|
|
|
|
// Disabling writes the choice through to .install_state.json.
|
|
manager.set_capability_enabled({PluginCapabilityType::Unknown, "Echo", "Echo_Plugin"}, false);
|
|
CHECK_FALSE(find_capability(manager, "Echo_Plugin", "Echo")->is_enabled());
|
|
|
|
PluginInstallState persisted;
|
|
REQUIRE(read_install_state(plugin_dir, persisted));
|
|
REQUIRE(persisted.capabilities.size() == 1);
|
|
CHECK(persisted.capabilities.front().first == "Echo");
|
|
CHECK_FALSE(persisted.capabilities.front().second);
|
|
|
|
// Unload and reload: the user's choice must survive.
|
|
REQUIRE(manager.unload_plugin("Echo_Plugin"));
|
|
|
|
std::string reload_error;
|
|
REQUIRE(load_and_wait(manager, "Echo_Plugin", reload_error));
|
|
|
|
const auto echo = find_capability(manager, "Echo_Plugin", "Echo");
|
|
REQUIRE(echo != nullptr);
|
|
CHECK_FALSE(echo->is_enabled());
|
|
|
|
manager.unload_plugin("Echo_Plugin");
|
|
}
|
|
|
|
TEST_CASE("A capability disabled after load stays disabled when rediscovered and reloaded", "[PluginLifecycle][Python]")
|
|
{
|
|
ScopedPluginManager plugin_system;
|
|
if (!plugin_system.initialized)
|
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
|
|
|
ScopedDataDir data_dir_guard("lifecycle-reload-live");
|
|
write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
|
|
|
|
PluginManager& manager = PluginManager::instance();
|
|
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
|
|
|
std::string error;
|
|
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
|
|
|
|
manager.set_capability_enabled({PluginCapabilityType::Unknown, "Echo", "Echo_Plugin"}, false);
|
|
REQUIRE(manager.unload_plugin("Echo_Plugin"));
|
|
|
|
// Rediscover, as the app does when a plugin is toggled off and back on. The enable flags the
|
|
// loader seeds from must come from the sidecar just written, not from a stale cache.
|
|
manager.discover_plugins(/*async=*/false, /*clear=*/false);
|
|
|
|
std::string reload_error;
|
|
REQUIRE(load_and_wait(manager, "Echo_Plugin", reload_error));
|
|
|
|
const auto echo = find_capability(manager, "Echo_Plugin", "Echo");
|
|
REQUIRE(echo != nullptr);
|
|
CHECK_FALSE(echo->is_enabled());
|
|
|
|
manager.unload_plugin("Echo_Plugin");
|
|
}
|
|
|
|
TEST_CASE("Re-enabling a disabled capability writes the sidecar back", "[PluginLifecycle][Python]")
|
|
{
|
|
ScopedPluginManager plugin_system;
|
|
if (!plugin_system.initialized)
|
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
|
|
|
ScopedDataDir data_dir_guard("lifecycle-reenable");
|
|
const fs::path plugin_dir = write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
|
|
|
|
PluginInstallState state;
|
|
state.installed_from = "local";
|
|
state.plugin_name = "Echo Plugin";
|
|
state.enabled = true;
|
|
state.capabilities = {{"Echo", false}};
|
|
REQUIRE(write_install_state(plugin_dir, state));
|
|
|
|
PluginManager& manager = PluginManager::instance();
|
|
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
|
|
|
std::string error;
|
|
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
|
|
|
|
// An explicit request overrides the persisted disabled state, including on a fresh load.
|
|
REQUIRE(manager.unload_plugin("Echo_Plugin"));
|
|
std::string enable_error;
|
|
REQUIRE(load_and_wait(manager, "Echo_Plugin", enable_error, {"Echo"}));
|
|
|
|
const auto echo = find_capability(manager, "Echo_Plugin", "Echo");
|
|
REQUIRE(echo != nullptr);
|
|
CHECK(echo->is_enabled());
|
|
|
|
PluginInstallState persisted;
|
|
REQUIRE(read_install_state(plugin_dir, persisted));
|
|
REQUIRE(persisted.capabilities.size() == 1);
|
|
CHECK(persisted.capabilities.front().first == "Echo");
|
|
CHECK(persisted.capabilities.front().second);
|
|
|
|
manager.unload_plugin("Echo_Plugin");
|
|
}
|
|
|
|
TEST_CASE("Overwriting a local plugin unloads its live module", "[PluginLifecycle][Python]")
|
|
{
|
|
ScopedPluginManager plugin_system;
|
|
if (!plugin_system.initialized)
|
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
|
|
|
ScopedDataDir data_dir_guard("lifecycle-overwrite");
|
|
const fs::path package_dir = data_dir_guard.dir / "packages";
|
|
fs::create_directories(package_dir);
|
|
const fs::path package = package_dir / "Echo_Plugin.py";
|
|
{
|
|
std::ofstream out(package.string(), std::ios::binary);
|
|
out << ECHO_PLUGIN_SOURCE;
|
|
}
|
|
|
|
PluginManager& manager = PluginManager::instance();
|
|
std::string error;
|
|
REQUIRE(manager.install_plugin(package, error));
|
|
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
|
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
|
|
REQUIRE(manager.is_plugin_loaded("Echo_Plugin"));
|
|
|
|
REQUIRE(manager.install_plugin(package, error));
|
|
CHECK_FALSE(manager.is_plugin_loaded("Echo_Plugin"));
|
|
}
|
|
|
|
TEST_CASE("capabilities_to_enable selects which capabilities come up enabled", "[PluginLifecycle][Python]")
|
|
{
|
|
ScopedPluginManager plugin_system;
|
|
if (!plugin_system.initialized)
|
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
|
|
|
// Two capabilities in one package; only the second is requested.
|
|
const char* const two_cap_source = R"PY(# /// script
|
|
# requires-python = ">=3.12"
|
|
#
|
|
# [tool.orcaslicer.plugin]
|
|
# name = "Duo Plugin"
|
|
# version = "1.0"
|
|
# type = "script"
|
|
# ///
|
|
import orca
|
|
|
|
class Alpha(orca.script.ScriptPluginCapabilityBase):
|
|
def get_name(self):
|
|
return "Alpha"
|
|
|
|
def execute(self, ctx):
|
|
return orca.ExecutionResult.success()
|
|
|
|
class Beta(orca.script.ScriptPluginCapabilityBase):
|
|
def get_name(self):
|
|
return "Beta"
|
|
|
|
def execute(self, ctx):
|
|
return orca.ExecutionResult.success()
|
|
|
|
@orca.plugin
|
|
class DuoPackage(orca.base):
|
|
def register_capabilities(self):
|
|
orca.register_capability(Alpha)
|
|
orca.register_capability(Beta)
|
|
)PY";
|
|
|
|
ScopedDataDir data_dir_guard("lifecycle-select");
|
|
write_plugin(data_dir_guard, "Duo_Plugin", two_cap_source);
|
|
|
|
PluginManager& manager = PluginManager::instance();
|
|
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
|
|
|
std::string error;
|
|
REQUIRE(load_and_wait(manager, "Duo_Plugin", error, {"Beta"}));
|
|
|
|
REQUIRE(capabilities_of(manager, "Duo_Plugin").size() == 2);
|
|
|
|
const auto alpha = find_capability(manager, "Duo_Plugin", "Alpha");
|
|
const auto beta = find_capability(manager, "Duo_Plugin", "Beta");
|
|
REQUIRE(alpha != nullptr);
|
|
REQUIRE(beta != nullptr);
|
|
|
|
CHECK_FALSE(alpha->is_enabled());
|
|
CHECK(beta->is_enabled());
|
|
|
|
manager.unload_plugin("Duo_Plugin");
|
|
}
|
|
|
|
TEST_CASE("A cancelled load keeps blocking wait_for_all_plugin_loads until the worker exits", "[PluginLifecycle][Python]")
|
|
{
|
|
ScopedPluginManager plugin_system;
|
|
if (!plugin_system.initialized)
|
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
|
|
|
// Stalls inside the module import, so the detached load worker is still executing Python while
|
|
// the test cancels it.
|
|
const char* const slow_source = R"PY(# /// script
|
|
# requires-python = ">=3.12"
|
|
#
|
|
# [tool.orcaslicer.plugin]
|
|
# name = "Slow Load Plugin"
|
|
# version = "1.0"
|
|
# type = "script"
|
|
# ///
|
|
import time
|
|
|
|
import orca
|
|
|
|
time.sleep(2)
|
|
|
|
class Slow(orca.script.ScriptPluginCapabilityBase):
|
|
def get_name(self):
|
|
return "Slow"
|
|
|
|
def execute(self, ctx):
|
|
return orca.ExecutionResult.success()
|
|
|
|
@orca.plugin
|
|
class SlowPackage(orca.base):
|
|
def register_capabilities(self):
|
|
orca.register_capability(Slow)
|
|
)PY";
|
|
|
|
ScopedDataDir data_dir_guard("lifecycle-cancel");
|
|
write_plugin(data_dir_guard, "Slow_Load_Plugin", slow_source);
|
|
|
|
PluginManager& manager = PluginManager::instance();
|
|
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
|
|
|
manager.load_plugin("Slow_Load_Plugin", /*skip_deps=*/true);
|
|
|
|
// The key is registered before the worker is spawned, so this is not a race.
|
|
REQUIRE(manager.is_plugin_load_in_progress("Slow_Load_Plugin"));
|
|
REQUIRE(manager.cancel_plugin_load("Slow_Load_Plugin"));
|
|
|
|
// Cancelling must not release the worker's slot. shutdown() unloads everything and GUI_App
|
|
// finalizes the interpreter as soon as this wait returns, so reporting "no loads in progress"
|
|
// while the worker is still inside Python is how the app crashes on exit.
|
|
CHECK(manager.is_plugin_load_in_progress("Slow_Load_Plugin"));
|
|
CHECK_FALSE(manager.wait_for_all_plugin_loads(std::chrono::milliseconds(0)));
|
|
|
|
// The worker releases the slot itself, once it has unwound.
|
|
CHECK(manager.wait_for_all_plugin_loads(std::chrono::seconds(60)));
|
|
CHECK_FALSE(manager.is_plugin_load_in_progress("Slow_Load_Plugin"));
|
|
CHECK_FALSE(manager.is_plugin_loaded("Slow_Load_Plugin"));
|
|
}
|
|
|
|
TEST_CASE("Loading an unknown plugin key records an error instead of crashing", "[PluginLifecycle][Python]")
|
|
{
|
|
// discover_plugins() initializes the plugin system (and with it the interpreter), so this
|
|
// needs the same explicit teardown as the load tests.
|
|
ScopedPluginManager plugin_system;
|
|
if (!plugin_system.initialized)
|
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
|
|
|
ScopedDataDir data_dir_guard("lifecycle-missing");
|
|
|
|
PluginManager& manager = PluginManager::instance();
|
|
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
|
|
|
// Rejected synchronously: no worker thread is spawned for an unknown key.
|
|
manager.load_plugin("No_Such_Plugin", /*skip_deps=*/true);
|
|
|
|
CHECK_FALSE(manager.is_plugin_loaded("No_Such_Plugin"));
|
|
CHECK(manager.get_plugin_load_error("No_Such_Plugin") == "Plugin not found: No_Such_Plugin");
|
|
CHECK(manager.get_plugin_capabilities("No_Such_Plugin").empty());
|
|
}
|
|
|
|
TEST_CASE("The startup auto-load list only contains packages whose sidecar enables them", "[PluginLifecycle][Python]")
|
|
{
|
|
ScopedPluginManager plugin_system;
|
|
if (!plugin_system.initialized)
|
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
|
|
|
ScopedDataDir data_dir_guard("lifecycle-autoload");
|
|
|
|
// No sidecar at all: never installed through Orca, so it carries no auto-load intent.
|
|
write_plugin(data_dir_guard, "Bare_Plugin", ECHO_PLUGIN_SOURCE);
|
|
|
|
// Sidecar with enabled = true: auto-loads.
|
|
const fs::path on_dir = write_plugin(data_dir_guard, "Enabled_Plugin", ECHO_PLUGIN_SOURCE);
|
|
PluginInstallState on_state;
|
|
on_state.installed_from = "local";
|
|
on_state.enabled = true;
|
|
REQUIRE(write_install_state(on_dir, on_state));
|
|
|
|
// Sidecar with enabled = false: the user turned it off.
|
|
const fs::path off_dir = write_plugin(data_dir_guard, "Disabled_Plugin", ECHO_PLUGIN_SOURCE);
|
|
PluginInstallState off_state;
|
|
off_state.installed_from = "local";
|
|
off_state.enabled = false;
|
|
REQUIRE(write_install_state(off_dir, off_state));
|
|
|
|
PluginManager& manager = PluginManager::instance();
|
|
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
|
|
|
const std::vector<std::string> keys = manager.get_enabled_plugin_keys();
|
|
|
|
CHECK(std::find(keys.begin(), keys.end(), "Enabled_Plugin") != keys.end());
|
|
CHECK(std::find(keys.begin(), keys.end(), "Disabled_Plugin") == keys.end());
|
|
CHECK(std::find(keys.begin(), keys.end(), "Bare_Plugin") == keys.end());
|
|
}
|
|
|
|
TEST_CASE("Signing out drops every cloud plugin row, installed or not", "[PluginLifecycle][Python]")
|
|
{
|
|
ScopedPluginManager plugin_system;
|
|
if (!plugin_system.initialized)
|
|
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
|
|
|
ScopedDataDir data_dir_guard("lifecycle-signout");
|
|
|
|
// A local package, which must survive sign-out.
|
|
write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
|
|
|
|
PluginManager& manager = PluginManager::instance();
|
|
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
|
|
|
// Two cloud rows: one merely available (nothing installed), one with a local package behind it —
|
|
// the case that used to linger, unloaded but still listed, until the user hit refresh.
|
|
PluginDescriptor available;
|
|
available.plugin_key = "11111111-1111-1111-1111-111111111111";
|
|
available.name = "Available Cloud Plugin";
|
|
available.cloud = CloudPluginState{available.plugin_key, /*installed=*/false, false, false, false};
|
|
|
|
PluginDescriptor installed;
|
|
installed.plugin_key = "22222222-2222-2222-2222-222222222222";
|
|
installed.name = "Installed Cloud Plugin";
|
|
installed.plugin_root = (data_dir_guard.plugins_dir() / "_subscribed" / "user" / installed.plugin_key).string();
|
|
installed.cloud = CloudPluginState{installed.plugin_key, /*installed=*/true, false, false, false};
|
|
|
|
manager.update_cloud_metadata({available, installed});
|
|
|
|
const auto has_key = [&manager](const std::string& key) {
|
|
PluginDescriptor descriptor;
|
|
return manager.try_get_plugin_descriptor(key, descriptor);
|
|
};
|
|
|
|
REQUIRE(has_key(available.plugin_key));
|
|
REQUIRE(has_key(installed.plugin_key));
|
|
REQUIRE(has_key("Echo_Plugin"));
|
|
|
|
// Sign out. The per-user _subscribed directory stops being scanned, so both cloud rows are now
|
|
// stale and must go — not just the one with nothing installed behind it.
|
|
manager.unload_cloud_plugins();
|
|
manager.clear_cloud_plugin_metadata();
|
|
manager.set_cloud_user("");
|
|
|
|
CHECK_FALSE(has_key(available.plugin_key));
|
|
CHECK_FALSE(has_key(installed.plugin_key));
|
|
CHECK(has_key("Echo_Plugin"));
|
|
}
|
|
|
|
TEST_CASE("Unloading a plugin that is not loaded is a no-op", "[PluginLifecycle]")
|
|
{
|
|
ScopedDataDir data_dir_guard("lifecycle-noop-unload");
|
|
|
|
PluginManager& manager = PluginManager::instance();
|
|
|
|
// Current behavior: unloading an unknown key succeeds (it fires the unload callbacks and
|
|
// reports success) rather than reporting "nothing to unload".
|
|
CHECK(manager.unload_plugin("No_Such_Plugin"));
|
|
CHECK_FALSE(manager.is_plugin_loaded("No_Such_Plugin"));
|
|
}
|