Merge branch 'feat/plugin-feature' into feature/speed-dial

Adapt the speed dial's ActionRegistry to the collapsed
get_plugin_capability(PluginCapabilityId) overload, and restore the
script success/skipped status message the dialog lost when its
PluginScriptRunner refactor was superseded by ActionRegistry.
This commit is contained in:
SoftFever
2026-07-17 19:32:44 +08:00
58 changed files with 4375 additions and 671 deletions

View File

@@ -3,10 +3,13 @@ add_executable(${_TEST_NAME}_tests
${_TEST_NAME}_tests_main.cpp
test_action_source.cpp
test_plugin_host_api.cpp
test_plugin_capability_config.cpp
test_plugin_config.cpp
test_plugin_capabilities_in_use.cpp
test_plugin_install.cpp
test_plugin_lifecycle.cpp
test_slicing_pipeline_bindings.cpp
test_slicing_pipeline_params.cpp
test_slicing_pipeline_config.cpp
test_plugin_sort.cpp
test_plugin_cloud_metadata.cpp
../fff_print/test_helpers.cpp

View File

@@ -0,0 +1,39 @@
#pragma once
#include <libslic3r/Utils.hpp>
#include <boost/filesystem.hpp>
#include <string>
namespace Slic3r {
// Point data_dir() at a throwaway directory for the lifetime of a test and
// restore the previous value afterwards, so code under test writes into a
// disposable tree and tests don't leak state into each other.
struct ScopedDataDir
{
std::string previous;
boost::filesystem::path dir;
explicit ScopedDataDir(const std::string& tag)
{
namespace fs = boost::filesystem;
previous = data_dir();
dir = fs::temp_directory_path() / fs::unique_path("orca-" + tag + "-%%%%-%%%%");
fs::create_directories(dir);
set_data_dir(dir.string());
}
~ScopedDataDir()
{
set_data_dir(previous);
boost::system::error_code ec;
boost::filesystem::remove_all(dir, ec);
}
ScopedDataDir(const ScopedDataDir&) = delete;
ScopedDataDir& operator=(const ScopedDataDir&) = delete;
};
} // namespace Slic3r

View File

@@ -0,0 +1,74 @@
#include <catch2/catch_all.hpp>
#include <libslic3r/Preset.hpp>
#include <libslic3r/PrintConfig.hpp>
#include <slic3r/plugin/PluginResolver.hpp>
#include <memory>
#include <string>
#include <vector>
using namespace Slic3r;
namespace {
// A print preset carrying a "plugins" manifest and the one plugin-backed print option.
Preset make_print_preset(const std::vector<std::string>& manifest, const std::vector<std::string>& pipeline)
{
Preset preset(Preset::TYPE_PRINT, "test-print");
const std::unique_ptr<DynamicPrintConfig> defaults(
DynamicPrintConfig::new_from_defaults_keys({"plugins", "slicing_pipeline_plugin"}));
preset.config = *defaults;
preset.config.option<ConfigOptionStrings>("plugins")->values = manifest;
preset.config.option<ConfigOptionStrings>("slicing_pipeline_plugin")->values = pipeline;
return preset;
}
std::vector<std::string> capability_names(const std::vector<PluginCapabilityRef>& refs)
{
std::vector<std::string> names;
for (const PluginCapabilityRef& ref : refs)
names.push_back(ref.capability_name);
return names;
}
} // namespace
TEST_CASE("referenced_capabilities keeps only manifest entries an option points at", "[PluginResolver]")
{
// CapB is declared in the manifest but no option references it, so it is not in use.
const Preset preset = make_print_preset({"acme;;CapA", "acme;;CapB"}, {"CapA"});
CHECK(capability_names(referenced_capabilities(Preset::TYPE_PRINT, preset)) == std::vector<std::string>{"CapA"});
}
TEST_CASE("referenced_capabilities matches every value of a vector option", "[PluginResolver]")
{
const Preset preset = make_print_preset({"acme;;CapA", "acme;;CapB", "acme;;CapC"}, {"CapA", "CapC"});
CHECK(capability_names(referenced_capabilities(Preset::TYPE_PRINT, preset)) ==
std::vector<std::string>{"CapA", "CapC"});
}
TEST_CASE("referenced_capabilities is empty when the manifest is empty", "[PluginResolver]")
{
const Preset preset = make_print_preset({}, {"CapA"});
CHECK(referenced_capabilities(Preset::TYPE_PRINT, preset).empty());
}
TEST_CASE("referenced_capabilities ignores untracked preset types", "[PluginResolver]")
{
Preset preset = make_print_preset({"acme;;CapA"}, {"CapA"});
preset.type = Preset::TYPE_SLA_PRINT;
CHECK(referenced_capabilities(Preset::TYPE_SLA_PRINT, preset).empty());
}
TEST_CASE("referenced_capabilities skips malformed manifest entries", "[PluginResolver]")
{
// parse_capability_ref rejects entries that are not "name;uuid;capability".
const Preset preset = make_print_preset({"garbage", "acme;;CapA"}, {"CapA"});
CHECK(capability_names(referenced_capabilities(Preset::TYPE_PRINT, preset)) == std::vector<std::string>{"CapA"});
}

View File

@@ -0,0 +1,374 @@
#include <catch2/catch_all.hpp>
#include <libslic3r/Utils.hpp>
#include <slic3r/plugin/PluginConfig.hpp>
#include <slic3r/plugin/PluginManager.hpp>
#include <slic3r/plugin/PythonPluginBridge.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include "plugin_test_utils.hpp"
#include <nlohmann/json.hpp>
#include <pybind11/embed.h>
#include <pybind11/pybind11.h>
#include <memory>
#include <string>
namespace py = pybind11;
using namespace Slic3r;
using json = nlohmann::json;
namespace {
void ensure_python_initialized()
{
// As in test_plugin_host_api.cpp: `orca` is compiled into this binary, so a bare interpreter is
// enough and does not need the bundled Python home.
if (!Py_IsInitialized()) {
static py::scoped_interpreter interpreter;
(void) interpreter;
}
}
py::module_ import_orca_module()
{
ensure_python_initialized();
(void) PythonPluginBridge::instance(); // force the embedded module registration into the binary
return py::module_::import("orca");
}
// Builds a Python capability the way PluginLoader does: the audit identity is stamped on by the
// host, never supplied by the plugin, and it scopes every config call to this one capability.
py::object make_capability(const std::string& class_name,
const std::string& body,
const std::string& plugin_key,
const std::string& capability_name,
PluginCapabilityType type = PluginCapabilityType::Script)
{
// Import first: it brings the interpreter up, and any py:: object built before it would touch a
// Python that does not exist yet.
py::module_ orca = import_orca_module();
py::dict globals;
globals["orca"] = orca;
py::exec("class " + class_name + "(orca.PythonPluginBase):\n" + body, globals);
py::object instance = globals[class_name.c_str()]();
if (!plugin_key.empty()) {
auto iface = instance.cast<std::shared_ptr<PluginCapabilityInterface>>();
iface->set_audit_plugin_key(plugin_key);
iface->set_resolved_identity(capability_name, type);
}
return instance;
}
std::shared_ptr<PluginCapabilityInterface> as_interface(const py::object& instance)
{
return instance.cast<std::shared_ptr<PluginCapabilityInterface>>();
}
// The Python API writes through the PluginManager singleton, so that is where assertions read from.
PluginConfig& host_config() { return PluginManager::instance().get_config(); }
// The Python config API speaks JSON text, not dicts; these helpers keep the tests in terms of values.
json py_get_config(const py::object& cap) { return json::parse(cap.attr("get_config")().cast<std::string>()); }
bool py_save_config(const py::object& cap, const json& value) { return cap.attr("save_config")(value.dump()).cast<bool>(); }
PluginCapabilityId capability_id(PluginCapabilityType type, const char* name, const char* plugin_key)
{
return {type, name, plugin_key};
}
} // namespace
TEST_CASE("Capability config API is exposed on every Python capability", "[PluginConfig][Python]")
{
py::module_ orca = import_orca_module();
REQUIRE(py::hasattr(orca, "PythonPluginBase"));
py::object base = orca.attr("PythonPluginBase");
// Host-provided: every capability has a config, so there is no hook to opt out of being
// configurable.
CHECK(py::hasattr(base, "get_config"));
CHECK(py::hasattr(base, "save_config"));
CHECK(py::hasattr(base, "get_config_version"));
// Plugin-provided (the host calls these). All optional.
CHECK(py::hasattr(base, "has_config_ui"));
CHECK(py::hasattr(base, "get_config_ui"));
CHECK(py::hasattr(base, "get_default_config"));
// Config is reached only through the capability, never as a free orca.config.* function, so a
// capability cannot name — and cannot touch — a config that is not its own.
CHECK_FALSE(py::hasattr(orca, "config"));
}
TEST_CASE("get_config returns only cap_config and save_config persists it", "[PluginConfig][Python]")
{
ScopedDataDir data_dir_guard("plugin-config-py-roundtrip");
host_config().load(); // reset the singleton's in-memory store against the empty temp dir
py::object cap = make_capability("RoundTripCap", " def get_name(self): return 'cap_a'\n", "plugin_a", "cap_a");
// Nothing stored yet: the JSON text of an empty object, not None, so a plugin can json.loads() it
// unconditionally.
py::object initial = cap.attr("get_config")();
REQUIRE(py::isinstance<py::str>(initial));
CHECK(json::parse(initial.cast<std::string>()) == json::object());
CHECK(cap.attr("get_config_version")().cast<std::string>().empty());
REQUIRE(py_save_config(cap, json{{"speed", 5}, {"name", "fast"}}));
const auto stored = host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"));
REQUIRE(stored);
CHECK(stored->id == capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"));
CHECK(stored->config == json{{"speed", 5}, {"name", "fast"}});
// Python reads back exactly cap_config — no host metadata.
const json reloaded = py_get_config(cap);
CHECK(reloaded.size() == 2);
CHECK(reloaded.contains("speed"));
CHECK_FALSE(reloaded.contains("plugin_key"));
CHECK_FALSE(reloaded.contains("capability"));
CHECK_FALSE(reloaded.contains("cap_config"));
CHECK_FALSE(reloaded.contains("plugin_version"));
}
TEST_CASE("save_config rejects a string that is not valid JSON", "[PluginConfig][Python]")
{
ScopedDataDir data_dir_guard("plugin-config-py-badjson");
host_config().load();
py::object cap = make_capability("BadJsonCap", " def get_name(self): return 'cap_a'\n", "plugin_a", "cap_a");
REQUIRE(py_save_config(cap, json{{"keep", "me"}}));
// Refusing unparseable text must leave the previously stored config alone.
CHECK_FALSE(cap.attr("save_config")("{not json").cast<bool>());
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"keep", "me"}});
}
TEST_CASE("Saving one capability's config does not touch another's", "[PluginConfig][Python]")
{
ScopedDataDir data_dir_guard("plugin-config-py-isolation");
host_config().load();
const std::string body = " def get_name(self): return 'cap'\n";
// Same capability name under two plugins, plus a second capability of plugin_a: each addresses
// only the entry matching its own stamped identity.
py::object a_cap1 = make_capability("IsoCapA1", body, "plugin_a", "cap_a");
py::object a_cap2 = make_capability("IsoCapA2", body, "plugin_a", "cap_b");
py::object b_cap1 = make_capability("IsoCapB1", body, "plugin_b", "cap_a");
REQUIRE(py_save_config(a_cap1, json{{"value", 1}}));
REQUIRE(py_save_config(a_cap2, json{{"value", 2}}));
REQUIRE(py_save_config(b_cap1, json{{"value", 3}}));
REQUIRE(py_save_config(a_cap1, json{{"value", 99}}));
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"value", 99}});
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_b", "plugin_a"))->config == json{{"value", 2}});
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_b"))->config == json{{"value", 3}});
// A shared name under one plugin must remain isolated when the capability type differs.
py::object importer = make_capability("IsoCapImporter", body, "plugin_a", "cap_a", PluginCapabilityType::Importer);
REQUIRE(py_save_config(importer, json{{"value", 4}}));
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"value", 99}});
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Importer, "cap_a", "plugin_a"))->config == json{{"value", 4}});
host_config().load();
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"value", 99}});
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Importer, "cap_a", "plugin_a"))->config == json{{"value", 4}});
CHECK(py_get_config(a_cap2).at("value") == 2);
CHECK(py_get_config(b_cap1).at("value") == 3);
}
TEST_CASE("Config API refuses a capability the host never materialized", "[PluginConfig][Python]")
{
ScopedDataDir data_dir_guard("plugin-config-py-unowned");
host_config().load();
// No audit identity: never loaded by the host, so it has no config to address. Refused rather
// than served from, or written to, some arbitrary entry.
py::object orphan = make_capability("OrphanCap", " def get_name(self): return 'cap'\n", "", "");
CHECK_THROWS(orphan.attr("get_config")());
CHECK_THROWS(orphan.attr("get_config_version")());
CHECK_THROWS(orphan.attr("save_config")(json::object().dump()));
}
TEST_CASE("Custom config UI hooks dispatch to the Python override", "[PluginConfig][Python]")
{
py::object cap = make_capability("CustomUiCap",
" def get_name(self): return 'cap_a'\n"
" def has_config_ui(self): return True\n"
" def get_config_ui(self): return '<p>hello</p>'\n",
"plugin_a", "cap_a");
auto iface = as_interface(cap);
REQUIRE(iface);
CHECK(iface->has_config_ui());
CHECK(iface->get_config_ui() == "<p>hello</p>");
}
TEST_CASE("A capability that omits the config UI hooks gets the default editor", "[PluginConfig][Python]")
{
ScopedDataDir data_dir_guard("plugin-config-py-bare");
host_config().load();
// Both hooks are optional and only choose the editor: a capability that overrides neither is
// still configurable, it just gets the host's JSON editor. There is no way to opt out.
py::object bare = make_capability("BareCap", " def get_name(self): return 'cap_a'\n", "plugin_a", "cap_a");
auto iface = as_interface(bare);
REQUIRE(iface);
CHECK_FALSE(iface->has_config_ui()); // -> default JSON editor
CHECK(iface->get_config_ui().empty());
REQUIRE(py_save_config(bare, json{{"speed", 5}}));
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"speed", 5}});
}
TEST_CASE("get_default_config supplies the value Restore defaults writes back", "[PluginConfig][Python]")
{
SECTION("not overridden -> an empty config")
{
// Already "restore defaults" for a capability that keeps its stored config sparse and applies
// its own defaults on read.
py::object bare = make_capability("NoDefaultsCap", " def get_name(self): return 'cap_a'\n", "plugin_a", "cap_a");
auto iface = as_interface(bare);
REQUIRE(iface);
CHECK(iface->get_default_config() == json::object());
}
SECTION("overridden -> exactly what the plugin returns")
{
py::object cap = make_capability("DefaultsCap",
" def get_name(self): return 'cap_a'\n"
" def get_default_config(self):\n"
" return {'speed': 5, 'nested': {'on': True}, 'items': [1, 2]}\n",
"plugin_a", "cap_a");
auto iface = as_interface(cap);
REQUIRE(iface);
// Round-trips through py_to_json untouched: the host does not reshape or validate it.
CHECK(iface->get_default_config() == json{{"speed", 5}, {"nested", {{"on", true}}}, {"items", {1, 2}}});
}
SECTION("overridden but returns None -> an empty config, never a null")
{
// `def get_default_config(self): pass` is the easy mistake, and it must not store
// "cap_config": null.
py::object cap = make_capability("NoneDefaultsCap",
" def get_name(self): return 'cap_a'\n"
" def get_default_config(self): pass\n",
"plugin_a", "cap_a");
auto iface = as_interface(cap);
REQUIRE(iface);
const json restored = iface->get_default_config();
CHECK(restored == json::object());
CHECK_FALSE(restored.is_null());
}
SECTION("overridden but returns a non-object -> an empty config")
{
py::object cap = make_capability("ScalarDefaultsCap",
" def get_name(self): return 'cap_a'\n"
" def get_default_config(self): return [1, 2, 3]\n",
"plugin_a", "cap_a");
auto iface = as_interface(cap);
REQUIRE(iface);
CHECK(iface->get_default_config() == json::object());
}
}
TEST_CASE("Restoring defaults overwrites only the target capability", "[PluginConfig][Python]")
{
ScopedDataDir data_dir_guard("plugin-config-py-restore");
host_config().load();
const std::string defaults_body = " def get_name(self): return 'cap'\n"
" def get_default_config(self): return {'speed': 1}\n";
py::object target = make_capability("RestoreTargetCap", defaults_body, "plugin_a", "cap_a");
py::object bystander = make_capability("RestoreBystanderCap", defaults_body, "plugin_b", "cap_a");
const json edited = json{{"speed", 99}};
REQUIRE(py_save_config(target, edited));
REQUIRE(py_save_config(bystander, edited));
// What PluginsDialog::restore_capability_config does: ask the capability, store the answer.
auto iface = as_interface(target);
REQUIRE(host_config().store_capability_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"), iface->get_default_config()));
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"speed", 1}});
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_b"))->config == json{{"speed", 99}});
}
TEST_CASE("A raising get_default_config leaves the stored config untouched", "[PluginConfig][Python]")
{
ScopedDataDir data_dir_guard("plugin-config-py-restore-raise");
host_config().load();
py::object cap = make_capability("RaisingDefaultsCap",
" def get_name(self): return 'cap_a'\n"
" def get_default_config(self): raise RuntimeError('boom')\n",
"plugin_a", "cap_a");
REQUIRE(py_save_config(cap, json{{"keep", "me"}}));
auto iface = as_interface(cap);
REQUIRE(iface);
CHECK_THROWS_AS(iface->get_default_config(), py::error_already_set);
// The dialog stores nothing when the hook throws: a broken plugin must not wipe user settings.
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"keep", "me"}});
}
TEST_CASE("A raising config UI hook surfaces as an exception the host can catch", "[PluginConfig][Python]")
{
py::object cap = make_capability("RaisingCap",
" def get_name(self): return 'cap_a'\n"
" def has_config_ui(self): return True\n"
" def get_config_ui(self): raise RuntimeError('boom')\n",
"plugin_a", "cap_a");
auto iface = as_interface(cap);
REQUIRE(iface);
// The trampoline rethrows; callers catch it and fall back to the default JSON editor.
CHECK_THROWS_AS(iface->get_config_ui(), py::error_already_set);
// Catching it leaves the interpreter usable.
CHECK(iface->get_name() == "cap_a");
}
TEST_CASE("A config UI hook returning the wrong type does not crash the host", "[PluginConfig][Python]")
{
// has_config_ui() is plugin-authored, so it can return anything; the host must survive the call.
py::object cap = make_capability("BadTypeCap",
" def get_name(self): return 'cap_a'\n"
" def has_config_ui(self): return 'not a bool'\n",
"plugin_a", "cap_a");
auto iface = as_interface(cap);
REQUIRE(iface);
// Deliberately not REQUIRE_THROWS: pybind may coerce or reject the value, and both are fine.
// What must hold is that the call is survivable — PluginLoader's guard turns a throw into
// "no custom UI".
try {
(void) iface->has_config_ui();
} catch (const std::exception&) {
}
// The capability is still usable afterwards.
CHECK(iface->get_name() == "cap_a");
CHECK(iface->get_config_ui().empty());
}

View File

@@ -1,46 +1,27 @@
#include <catch2/catch_all.hpp>
#include <libslic3r/Utils.hpp>
#include <slic3r/plugin/PluginConfig.hpp>
#include <slic3r/plugin/PluginDescriptor.hpp>
#include <slic3r/plugin/PluginFsUtils.hpp>
#include <slic3r/plugin/PluginManager.hpp>
#include <slic3r/plugin/PythonInterpreter.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include "plugin_test_utils.hpp"
#include <boost/filesystem.hpp>
#include <nlohmann/json.hpp>
#include <fstream>
#include <string>
using namespace Slic3r;
namespace fs = boost::filesystem;
using json = nlohmann::json;
namespace {
// Point data_dir() at a throwaway directory for the lifetime of a test and restore the previous
// value afterwards (same pattern as test_plugin_lifecycle.cpp).
struct ScopedDataDir
{
std::string previous;
fs::path dir;
explicit ScopedDataDir(const std::string& tag)
{
previous = data_dir();
dir = fs::temp_directory_path() / fs::unique_path("orca-" + tag + "-%%%%-%%%%");
fs::create_directories(dir);
set_data_dir(dir.string());
}
~ScopedDataDir()
{
set_data_dir(previous);
boost::system::error_code ec;
fs::remove_all(dir, ec);
}
fs::path plugins_dir() const { return dir / "orca_plugins"; }
};
// Shut both singletons down while boost::log is still alive; left to their static
// destructors, shutdown()'s logging runs after boost::log tears down its thread-local
// storage and crashes the process on exit (same reason ScopedPluginManager exists in
@@ -55,46 +36,46 @@ struct ScopedManagerShutdown
}
};
// A plugin whose per-plugin settings live in the PEP-723 header — the only place they exist.
const char* const SETTINGS_PLUGIN_SOURCE = R"PY(# /// script
const char* const CLOUD_PLUGIN_SOURCE = R"PY(# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# name = "Settings Cloud Plugin"
# name = "Config Cloud Plugin"
# type = "slicing-pipeline"
# version = "1.0"
#
# [tool.orcaslicer.plugin.settings]
# twist_deg_per_mm = "1.0"
# taper_per_mm = "0.0"
# ///
print('ok')
)PY";
} // namespace
// Regression: update_cloud_metadata() replaces a matched entry's descriptor with the cloud
// catalog record. Cloud records never carry [tool.orcaslicer.plugin.settings] (it exists only
// in the local package's PEP-723 header, parsed at discovery), so the merge must preserve the
// locally-parsed settings. When it does not, get_plugin_settings() serves an empty map and
// plugins silently fall back to their built-in defaults (found via Twistify running with its
// demo defaults instead of the header values, 2026-07-17).
TEST_CASE("cloud metadata refresh preserves locally-parsed plugin settings", "[PluginCloudMetadata]")
// Regression: update_cloud_metadata() replaces a matched entry's descriptor wholesale with the
// cloud catalog record (`entry = cloud_entry`). Configuration used to ride on the descriptor, so
// that overwrite silently wiped it and plugins fell back to their built-in defaults (found via
// Twistify running with its demo defaults instead of the configured values, 2026-07-17).
// Configuration now lives in PluginConfig, keyed by the capability identity and kept off the
// descriptor entirely, so the merge cannot reach it. This asserts that end to end: a stored
// config survives the same refresh path, while the descriptor fields the refresh owns do update.
//
// The capability need not exist for this to be meaningful: what is pinned is the architectural
// invariant that config never rides on the descriptor again. Anyone reintroducing it there, or
// adding a cloud-refresh path that prunes config, fails here.
TEST_CASE("cloud metadata refresh preserves a plugin's stored config", "[PluginCloudMetadata]")
{
ScopedManagerShutdown manager_shutdown_guard; // declared first: destroyed last
ScopedDataDir data_dir_guard("cloud-meta-settings");
ScopedDataDir data_dir_guard("cloud-meta-config");
// A locally-installed cloud plugin: package .py with a settings header, plus an
// install-state sidecar carrying the cloud identity.
// A locally-installed cloud plugin: package .py plus an install-state sidecar carrying the
// cloud identity. Discovery derives plugin_key from the cloud UUID.
const std::string uuid = "11111111-2222-3333-4444-555555555555";
const fs::path plugin_dir = data_dir_guard.plugins_dir() / uuid;
const fs::path plugin_dir = fs::path(get_orca_plugins_dir()) / uuid;
fs::create_directories(plugin_dir);
{
std::ofstream out((plugin_dir / "cloud_plugin-test.py").string(), std::ios::binary);
out << SETTINGS_PLUGIN_SOURCE;
out << CLOUD_PLUGIN_SOURCE;
}
PluginDescriptor sidecar;
sidecar.name = "Settings Cloud Plugin";
sidecar.name = "Config Cloud Plugin";
sidecar.installed_version = "1.0";
sidecar.cloud = CloudPluginState{uuid, true, false, false, false};
REQUIRE(write_install_state(plugin_dir, sidecar));
@@ -109,25 +90,38 @@ TEST_CASE("cloud metadata refresh preserves locally-parsed plugin settings", "[P
return {};
};
// Discovery parsed the header settings (premise).
PluginDescriptor discovered = find_by_uuid();
REQUIRE(discovered.settings.count("twist_deg_per_mm") == 1);
CHECK(discovered.settings.at("twist_deg_per_mm") == "1.0");
const PluginDescriptor discovered = find_by_uuid();
REQUIRE(discovered.plugin_key == uuid);
REQUIRE(discovered.version == "1.0");
// A cloud catalog refresh for the same plugin: the record knows name/version/uuid but has
// no settings, no local paths.
// The user has configured the plugin's capability (premise).
const PluginCapabilityId id{PluginCapabilityType::SlicingPipeline, "Twist", uuid};
const json configured{{"twist_deg_per_mm", 1.0}, {"taper_per_mm", 0.0}};
REQUIRE(manager.get_config().store_capability_config(id, configured));
// A cloud catalog refresh for the same plugin: the record knows name/version/uuid and knows
// nothing about local config or local paths.
PluginDescriptor cloud_record;
cloud_record.name = "Settings Cloud Plugin";
cloud_record.name = "Config Cloud Plugin";
cloud_record.plugin_key = uuid;
cloud_record.version = "1.1";
cloud_record.cloud = CloudPluginState{uuid, false, false, false, false};
manager.update_cloud_metadata({cloud_record});
// Cloud metadata landed on the descriptor...
const PluginDescriptor refreshed = find_by_uuid();
// Cloud metadata landed...
CHECK(refreshed.version == "1.1");
// ...and the locally-parsed settings survived the merge.
REQUIRE(refreshed.settings.count("twist_deg_per_mm") == 1);
CHECK(refreshed.settings.at("twist_deg_per_mm") == "1.0");
CHECK(refreshed.settings.count("taper_per_mm") == 1);
CHECK(refreshed.plugin_key == uuid);
CHECK(refreshed.installed_version == "1.0");
// ...and the stored config is untouched, both in the live store...
const auto stored = manager.get_config().get_config(id);
REQUIRE(stored);
CHECK(stored->config == configured);
// ...and on disk, which is what the next run reads back.
PluginConfig reloaded;
reloaded.load();
REQUIRE(reloaded.has_config(id));
CHECK(reloaded.get_config(id)->config == configured);
}

View File

@@ -0,0 +1,264 @@
#include <catch2/catch_all.hpp>
#include <libslic3r/Utils.hpp>
#include <slic3r/plugin/PluginConfig.hpp>
#include <slic3r/plugin/PluginManager.hpp>
#include "plugin_test_utils.hpp"
#include <boost/filesystem.hpp>
#include <boost/nowide/fstream.hpp>
#include <nlohmann/json.hpp>
#include <string>
using namespace Slic3r;
namespace fs = boost::filesystem;
using json = nlohmann::json;
namespace {
PluginCapabilityId capability_id(PluginCapabilityType type, const char* name, const char* plugin_key)
{
return {type, name, plugin_key};
}
json read_config_file()
{
boost::nowide::ifstream ifs(PluginConfig::plugin_config_file().c_str());
json root;
ifs >> root;
return root;
}
void write_config_file(const std::string& contents)
{
const fs::path path(PluginConfig::plugin_config_file());
fs::create_directories(path.parent_path());
boost::nowide::ofstream ofs(path.string().c_str(), std::ios::out | std::ios::trunc);
ofs << contents;
}
} // namespace
TEST_CASE("PluginConfig creates, reads back and persists a capability config", "[PluginConfig]")
{
ScopedDataDir data_dir_guard("plugin-config-roundtrip");
PluginConfig config;
const PluginCapabilityId id = capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a");
// A capability nobody has configured yet reads as an empty record rather than throwing.
CHECK_FALSE(config.has_config(id));
CHECK_FALSE(config.get_config(id));
REQUIRE(config.store_capability_config(id, json{{"speed", 5}}));
const auto stored = config.get_config(id);
REQUIRE(stored);
CHECK(stored->id == id);
CHECK(stored->config == json{{"speed", 5}});
CHECK(config.has_config(id));
// store_capability_config writes through, so a fresh instance (a restart, in effect) sees it.
PluginConfig reloaded;
reloaded.load();
CHECK(reloaded.get_config(id)->config == json{{"speed", 5}});
}
TEST_CASE("PluginConfig updates only the target capability's cap_config", "[PluginConfig]")
{
ScopedDataDir data_dir_guard("plugin-config-isolation");
PluginConfig config;
const PluginCapabilityId a_a = capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a");
const PluginCapabilityId a_b = capability_id(PluginCapabilityType::Script, "cap_b", "plugin_a");
const PluginCapabilityId b_a = capability_id(PluginCapabilityType::Script, "cap_a", "plugin_b");
// The identity is the full (type, capability, plugin_key) tuple, so all three below are separate records.
REQUIRE(config.store_capability_config(a_a, json{{"value", 1}}));
REQUIRE(config.store_capability_config(a_b, json{{"value", 2}}));
REQUIRE(config.store_capability_config(b_a, json{{"value", 3}}));
REQUIRE(config.store_capability_config(a_a, json{{"value", 99}}));
CHECK(config.get_config(a_a)->config == json{{"value", 99}});
CHECK(config.get_config(a_b)->config == json{{"value", 2}});
CHECK(config.get_config(b_a)->config == json{{"value", 3}});
// The same holds on disk, not just in memory.
PluginConfig reloaded;
reloaded.load();
CHECK(reloaded.get_config(a_a)->config == json{{"value", 99}});
CHECK(reloaded.get_config(a_b)->config == json{{"value", 2}});
CHECK(reloaded.get_config(b_a)->config == json{{"value", 3}});
}
TEST_CASE("PluginConfig isolates same-name capabilities by type", "[PluginConfig]")
{
ScopedDataDir data_dir_guard("plugin-config-type-isolation");
const PluginCapabilityId script = capability_id(PluginCapabilityType::Script, "shared", "plugin_a");
const PluginCapabilityId importer = capability_id(PluginCapabilityType::Importer, "shared", "plugin_a");
PluginConfig config;
REQUIRE(config.store_capability_config(script, json{{"value", "script"}}));
REQUIRE(config.store_capability_config(importer, json{{"value", "importer"}}));
CHECK(config.get_config(script)->config == json{{"value", "script"}});
CHECK(config.get_config(importer)->config == json{{"value", "importer"}});
PluginConfig reloaded;
reloaded.load();
CHECK(reloaded.get_config(script)->config == json{{"value", "script"}});
CHECK(reloaded.get_config(importer)->config == json{{"value", "importer"}});
}
TEST_CASE("PluginConfig serializes the documented on-disk schema", "[PluginConfig]")
{
ScopedDataDir data_dir_guard("plugin-config-schema");
PluginConfig config;
const PluginCapabilityId id = capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a");
REQUIRE(config.store_capability_config(id, json{{"speed", 5}}));
// Locks the field names: an existing config.json must keep loading after any future change.
const json root = read_config_file();
REQUIRE(root.contains("config"));
REQUIRE(root.at("config").is_array());
REQUIRE(root.at("config").size() == 1);
const json& entry = root.at("config").front();
CHECK(entry.at("plugin_key") == "plugin_a");
CHECK(entry.at("capability") == "cap_a");
CHECK(entry.at("capability_type") == "script");
CHECK(entry.at("cap_config") == json{{"speed", 5}});
CHECK(entry.contains("plugin_version"));
// Only cap_config is user data; the rest of the record is host-managed.
CHECK(entry.size() == 5);
}
TEST_CASE("PluginConfig keeps a capability's config after its plugin goes away", "[PluginConfig]")
{
ScopedDataDir data_dir_guard("plugin-config-retention");
{
PluginConfig config;
REQUIRE(config.store_capability_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"), json{{"token", "keep me"}}));
}
// config.json is deliberately not keyed to installed plugins: a record outlives its plugin and is
// still there on reinstall. Asserts no cleanup path silently drops it.
PluginConfig after_removal;
after_removal.load();
CHECK(after_removal.get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"token", "keep me"}});
}
TEST_CASE("PluginConfig treats a missing config file as an empty store", "[PluginConfig]")
{
ScopedDataDir data_dir_guard("plugin-config-missing");
REQUIRE_FALSE(fs::exists(PluginConfig::plugin_config_file()));
PluginConfig config;
REQUIRE_NOTHROW(config.load());
CHECK_FALSE(config.has_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a")));
CHECK_FALSE(config.dirty());
}
TEST_CASE("PluginConfig survives a malformed config file", "[PluginConfig]")
{
SECTION("not JSON at all")
{
ScopedDataDir data_dir_guard("plugin-config-garbage");
write_config_file("this is not json {{{");
PluginConfig config;
REQUIRE_NOTHROW(config.load()); // a bad config must not block startup
CHECK_FALSE(config.has_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a")));
}
SECTION("valid JSON without the entries array")
{
ScopedDataDir data_dir_guard("plugin-config-noarray");
write_config_file(R"({"config": {"not": "an array"}})");
PluginConfig config;
REQUIRE_NOTHROW(config.load());
CHECK_FALSE(config.has_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a")));
}
SECTION("entries without an identity are skipped, the rest still load")
{
ScopedDataDir data_dir_guard("plugin-config-partial");
write_config_file(R"({"config": [
{"cap_config": {"orphan": true}},
{"plugin_key": "plugin_a", "capability": "cap_a", "capability_type": "script", "plugin_version": "1.0.0", "cap_config": {"kept": true}}
]})");
PluginConfig config;
REQUIRE_NOTHROW(config.load());
CHECK(config.get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"kept", true}});
CHECK(config.get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->plugin_version == "1.0.0");
}
SECTION("an entry with no cap_config reads as an empty object")
{
ScopedDataDir data_dir_guard("plugin-config-nocap");
write_config_file(R"({"config": [
{"plugin_key": "plugin_a", "capability": "cap_a", "capability_type": "script", "plugin_version": "1.0.0"}
]})");
PluginConfig config;
REQUIRE_NOTHROW(config.load());
REQUIRE(config.has_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a")));
CHECK(config.get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json::object());
}
SECTION("legacy entries without capability_type remain addressable")
{
ScopedDataDir data_dir_guard("plugin-config-notype");
write_config_file(R"({"config": [
{"plugin_key": "plugin_a", "capability": "cap_a", "plugin_version": "1.0.0", "cap_config": {"old": true}}
]})");
PluginConfig config;
REQUIRE_NOTHROW(config.load());
const auto id = capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a");
REQUIRE(config.has_config(id));
CHECK(config.get_config(id)->config == json{{"old", true}});
REQUIRE(config.store_capability_config(id, json{{"migrated", true}}));
const json root = read_config_file();
REQUIRE(root.at("config").size() == 1);
CHECK(root.at("config").front().at("capability_type") == "script");
CHECK(root.at("config").front().at("cap_config") == json{{"migrated", true}});
}
}
TEST_CASE("PluginConfig refuses to store a record without an identity", "[PluginConfig]")
{
ScopedDataDir data_dir_guard("plugin-config-identity");
PluginConfig config;
config.save_config(CapabilityConfigEntry{capability_id(PluginCapabilityType::Script, "cap_a", ""), "1.0.0", json::object()});
config.save_config(CapabilityConfigEntry{capability_id(PluginCapabilityType::Script, "", "plugin_a"), "1.0.0", json::object()});
// Neither could ever be looked up again, so neither is kept.
CHECK_FALSE(config.has_config(capability_id(PluginCapabilityType::Script, "cap_a", "")));
CHECK_FALSE(config.has_config(capability_id(PluginCapabilityType::Script, "", "plugin_a")));
CHECK_FALSE(config.dirty());
}
TEST_CASE("PluginConfig preserves unknown keys inside cap_config", "[PluginConfig]")
{
ScopedDataDir data_dir_guard("plugin-config-unknown");
// The host never interprets cap_config, so a nested/odd shape must round-trip untouched.
const json nested = json{{"nested", {{"deep", json::array({1, 2, 3})}}}, {"flag", false}, {"name", "x"}};
PluginConfig config;
const PluginCapabilityId id = capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a");
REQUIRE(config.store_capability_config(id, nested));
PluginConfig reloaded;
reloaded.load();
CHECK(reloaded.get_config(id)->config == nested);
}

View File

@@ -5,6 +5,8 @@
#include <slic3r/plugin/PluginDescriptor.hpp>
#include <slic3r/plugin/PluginFsUtils.hpp>
#include "plugin_test_utils.hpp"
#include <boost/filesystem.hpp>
#include <fstream>
@@ -15,30 +17,6 @@ namespace fs = boost::filesystem;
namespace {
// Point data_dir() at a throwaway directory for the lifetime of a test and
// restore the previous value afterwards, so install_plugin() writes into a
// disposable tree and tests don't leak state into each other.
struct ScopedDataDir
{
std::string previous;
fs::path dir;
explicit ScopedDataDir(const std::string& tag)
{
previous = data_dir();
dir = fs::temp_directory_path() / fs::unique_path("orca-" + tag + "-%%%%-%%%%");
fs::create_directories(dir);
set_data_dir(dir.string());
}
~ScopedDataDir()
{
set_data_dir(previous);
boost::system::error_code ec;
fs::remove_all(dir, ec);
}
};
fs::path write_py_file(const fs::path& dir, const std::string& filename, const std::string& contents)
{
fs::create_directories(dir);
@@ -142,38 +120,4 @@ TEST_CASE("install-state sidecar is the source of truth for a cloud plugin's ins
scanned.version = "1.0.0"; // as parsed from the unchanged PEP723 header
read_install_state(plugin_dir, scanned);
CHECK(scanned.installed_version == "1.2.0");
}
TEST_CASE("install_plugin parses [tool.orcaslicer.plugin.settings] into descriptor.settings", "[PluginInstall]")
{
ScopedDataDir data_dir_guard("plugin-settings");
// A PEP-723 header with a per-plugin settings sub-table. Values stay strings; the plugin
// parses what it needs (ctx.params). This is the source Twistify reads its knobs from.
const std::string contents =
"# /// script\n"
"# requires-python = \">=3.12\"\n"
"#\n"
"# [tool.orcaslicer.plugin]\n"
"# name = \"Settings Plugin\"\n"
"# type = \"slicing-pipeline\"\n"
"#\n"
"# [tool.orcaslicer.plugin.settings]\n"
"# twist_deg_per_mm = \"1.5\"\n"
"# taper_per_mm = \"-0.004\"\n"
"# ///\n"
"print('ok')\n";
const fs::path py = write_py_file(data_dir_guard.dir / "src", "settings.py", contents);
PluginDescriptor descriptor;
std::string error;
const bool installed = plugin_loader::install_plugin(py, /*cloud_user_id=*/"", descriptor, error);
REQUIRE(installed);
CHECK(error.empty());
REQUIRE(descriptor.settings.count("twist_deg_per_mm") == 1);
CHECK(descriptor.settings.at("twist_deg_per_mm") == "1.5");
CHECK(descriptor.settings.at("taper_per_mm") == "-0.004");
// Identity keys are NOT captured as settings (they belong to [tool.orcaslicer.plugin]).
CHECK(descriptor.settings.count("name") == 0);
}
}

View File

@@ -122,9 +122,7 @@ bool load_and_wait(PluginManager& manager,
std::shared_ptr<PluginCapabilityInterface> find_capability(PluginManager& manager, const std::string& plugin_key,
const std::string& name)
{
return manager.get_plugin_capability(plugin_key, name, PluginCapabilityType::Unknown, /*only_enabled=*/false);
}
{ 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)
{
@@ -173,7 +171,7 @@ TEST_CASE("A discovered script plugin loads and materializes its capability", "[
CHECK(echo->is_enabled());
CHECK(echo->audit_plugin_key() == "Echo_Plugin");
CHECK(manager.get_plugin_capability("Echo_Plugin", "Echo", PluginCapabilityType::Script) == echo);
CHECK(manager.get_plugin_capability({PluginCapabilityType::Script, "Echo", "Echo_Plugin"}) == echo);
manager.unload_plugin("Echo_Plugin");
}
@@ -244,7 +242,7 @@ TEST_CASE("Unloading a plugin drops the package and its capabilities", "[PluginL
CHECK_FALSE(manager.is_plugin_loaded("Echo_Plugin"));
CHECK(manager.get_plugin_capabilities("Echo_Plugin").empty());
CHECK(manager.get_plugin_capability("Echo_Plugin", "Echo", PluginCapabilityType::Script) == nullptr);
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");
@@ -403,7 +401,7 @@ TEST_CASE("Disabling a capability round-trips through the sidecar and survives a
REQUIRE(find_capability(manager, "Echo_Plugin", "Echo")->is_enabled());
// Disabling writes the choice through to .install_state.json.
manager.set_capability_enabled("Echo_Plugin", "Echo", false);
manager.set_capability_enabled({PluginCapabilityType::Unknown, "Echo", "Echo_Plugin"}, false);
CHECK_FALSE(find_capability(manager, "Echo_Plugin", "Echo")->is_enabled());
PluginInstallState persisted;
@@ -440,7 +438,7 @@ TEST_CASE("A capability disabled after load stays disabled when rediscovered and
std::string error;
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
manager.set_capability_enabled("Echo_Plugin", "Echo", false);
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

View File

@@ -106,7 +106,7 @@ TEST_CASE("orca.slicing is workflow-only: context exposes raw print/object; view
py::object slicing = orca.attr("slicing");
// Context surface: raw graph entry points + workflow accessors.
for (const char* name : { "print", "object", "params", "config_value", "cancelled",
for (const char* name : { "print", "object", "config_value", "cancelled",
"orca_version", "step" })
CHECK(py::hasattr(slicing.attr("SlicingPipelineContext"), name));

View File

@@ -0,0 +1,153 @@
#include <catch2/catch_all.hpp>
#include <libslic3r/Utils.hpp>
#include <slic3r/plugin/PluginConfig.hpp>
#include <slic3r/plugin/PluginManager.hpp>
#include <slic3r/plugin/PythonInterpreter.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include "fff_print/test_helpers.hpp"
#include "plugin_test_utils.hpp"
#include <boost/filesystem.hpp>
#include <nlohmann/json.hpp>
#include <pybind11/embed.h>
#include <fstream>
#include <string>
using namespace Slic3r;
using namespace Slic3r::Test;
namespace fs = boost::filesystem;
using json = nlohmann::json;
// End-to-end coverage of a slicing-pipeline capability reading its own config: the loader seeds the
// store from the capability's get_default_config() hook, and the real dispatch
// (execute_capabilities_from_refs -> hook -> GIL -> trampoline) lets the plugin read back whatever
// the host has stored, through self.get_config(). A break anywhere in that chain makes plugins
// silently run on their built-in defaults, which is invisible to the plugin author (Twistify
// incident, 2026-07-17).
//
// Note this is deliberately NOT ctx.config_value(): that reads the slicer's print config, not the
// plugin's own config.
namespace {
struct ScopedPluginManager
{
bool initialized = false;
ScopedPluginManager() { initialized = PluginManager::instance().initialize(); }
~ScopedPluginManager()
{
PluginManager::instance().shutdown();
PythonInterpreter::instance().shutdown();
}
};
const char* const CONFIG_PROBE_SOURCE = R"PY(# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# name = "Config Probe"
# description = "Echoes its own config back to the test"
# author = "OrcaSlicer"
# version = "1.0"
# type = "slicing-pipeline"
# ///
import json
import orca
class ConfigEcho(orca.slicing.SlicingPipelineCapabilityBase):
def get_name(self):
return "ConfigEcho"
def get_default_config(self):
return {"alpha": "1.25", "beta": "hello"}
def execute(self, ctx):
if ctx.step != orca.slicing.Step.posSlice or ctx.object is None:
return orca.ExecutionResult.success()
try:
text = repr(sorted(json.loads(self.get_config()).items()))
except Exception as e: # what plugins' defaults-fallback code swallows silently
text = "config-error: " + repr(e)
orca._probe_config = text # read back by the test through pybind
return orca.ExecutionResult.success("config probed")
@orca.plugin
class ConfigProbePackage(orca.base):
def register_capabilities(self):
orca.register_capability(ConfigEcho)
)PY";
fs::path write_plugin(const std::string& stem, const std::string& source)
{
const fs::path plugin_dir = fs::path(get_orca_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;
}
} // namespace
TEST_CASE("slicing-pipeline dispatch delivers the stored config to self.get_config()", "[slicing_pipeline][PluginConfig][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("pipeline-config");
write_plugin("ConfigProbe", CONFIG_PROBE_SOURCE);
PluginManager& manager = PluginManager::instance();
manager.get_config().load(); // reset the singleton's store against the empty temp data dir
manager.discover_plugins(/*async=*/false, /*clear=*/true);
std::string error;
manager.load_plugin("ConfigProbe", /*skip_deps=*/true, {});
REQUIRE(manager.wait_for_plugin_load("ConfigProbe", std::chrono::seconds(120), error));
INFO("load error: " << error);
REQUIRE(manager.is_plugin_loaded("ConfigProbe"));
const PluginCapabilityId id{PluginCapabilityType::SlicingPipeline, "ConfigEcho", "ConfigProbe"};
// Loading seeds the store from the capability's get_default_config() hook, so a plugin has a
// config before anyone has opened the Config tab.
const auto seeded = manager.get_config().get_config(id);
REQUIRE(seeded);
CHECK(seeded->config == json({{"alpha", "1.25"}, {"beta", "hello"}}));
// What editing the config in the Config tab does: the value the plugin must actually run on.
REQUIRE(manager.get_config().store_capability_config(id, json({{"alpha", "9.5"}, {"beta", "hello"}})));
// Slice with the capability selected, exactly as a preset would reference it.
Print print;
Model model;
auto config = DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new ConfigOptionStrings({"ConfigEcho"}));
config.set_key_value("plugins", new ConfigOptionStrings({"ConfigProbe;;ConfigEcho"}));
init_print({cube(20)}, print, model, config);
print.process();
std::string observed = "<capability never executed>";
{
PythonGILState gil;
REQUIRE(static_cast<bool>(gil));
pybind11::module_ orca = pybind11::module_::import("orca");
if (pybind11::hasattr(orca, "_probe_config"))
observed = orca.attr("_probe_config").cast<std::string>();
}
INFO("config observed by Python: " << observed);
// The edited value arrived, not the seeded default: the host's store is what reaches the plugin.
CHECK(observed.find("'alpha', '9.5'") != std::string::npos);
CHECK(observed.find("'beta', 'hello'") != std::string::npos);
CHECK(observed.find("1.25") == std::string::npos);
manager.unload_plugin("ConfigProbe");
}

View File

@@ -1,160 +0,0 @@
#include <catch2/catch_all.hpp>
#include <libslic3r/Utils.hpp>
#include <slic3r/plugin/PluginDescriptor.hpp>
#include <slic3r/plugin/PluginManager.hpp>
#include <slic3r/plugin/PythonInterpreter.hpp>
#include "fff_print/test_helpers.hpp"
#include <boost/filesystem.hpp>
#include <pybind11/embed.h>
#include <fstream>
#include <string>
using namespace Slic3r;
using namespace Slic3r::Test;
namespace fs = boost::filesystem;
// End-to-end coverage of ctx.params for slicing-pipeline capabilities: discovery parses
// [tool.orcaslicer.plugin.settings] from the PEP-723 header, and the real dispatch
// (execute_capabilities_from_refs -> hook -> GIL -> trampoline) hands it to the plugin.
// A break anywhere in that chain makes plugins silently run on their built-in defaults,
// which is invisible to the plugin author (Twistify incident, 2026-07-17).
namespace {
struct ScopedDataDir
{
std::string previous;
fs::path dir;
explicit ScopedDataDir(const std::string& tag)
{
previous = data_dir();
// canonical(): the plugin audit canonicalizes its allowed roots, so a path through
// the macOS /var -> /private/var symlink would be rejected as "outside allowed root".
dir = fs::canonical(fs::temp_directory_path()) / fs::unique_path("orca-" + tag + "-%%%%-%%%%");
fs::create_directories(dir);
set_data_dir(dir.string());
}
~ScopedDataDir()
{
set_data_dir(previous);
boost::system::error_code ec;
fs::remove_all(dir, ec);
}
fs::path plugins_dir() const { return dir / "orca_plugins"; }
};
struct ScopedPluginManager
{
bool initialized = false;
ScopedPluginManager() { initialized = PluginManager::instance().initialize(); }
~ScopedPluginManager()
{
PluginManager::instance().shutdown();
PythonInterpreter::instance().shutdown();
}
};
const char* const PARAM_PROBE_SOURCE = R"PY(# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# name = "Param Probe"
# description = "Echoes ctx.params back to the test"
# author = "OrcaSlicer"
# version = "1.0"
# type = "slicing-pipeline"
#
# [tool.orcaslicer.plugin.settings]
# alpha = "1.25"
# beta = "hello"
# ///
import orca
class ParamEcho(orca.slicing.SlicingPipelineCapabilityBase):
def get_name(self):
return "ParamEcho"
def execute(self, ctx):
if ctx.step != orca.slicing.Step.posSlice or ctx.object is None:
return orca.ExecutionResult.success()
try:
text = repr(sorted(dict(ctx.params).items()))
except Exception as e: # what plugins' defaults-fallback code swallows silently
text = "params-error: " + repr(e)
orca._probe_params = text # read back by the test through pybind
return orca.ExecutionResult.success("params probed")
@orca.plugin
class ParamProbePackage(orca.base):
def register_capabilities(self):
orca.register_capability(ParamEcho)
)PY";
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;
}
} // namespace
TEST_CASE("slicing-pipeline dispatch delivers PEP-723 settings as ctx.params", "[slicing_pipeline][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("pipeline-params");
write_plugin(data_dir_guard, "ParamProbe", PARAM_PROBE_SOURCE);
PluginManager& manager = PluginManager::instance();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
std::string error;
manager.load_plugin("ParamProbe", /*skip_deps=*/true, {});
REQUIRE(manager.wait_for_plugin_load("ParamProbe", std::chrono::seconds(120), error));
INFO("load error: " << error);
REQUIRE(manager.is_plugin_loaded("ParamProbe"));
// The manager serves the header settings for the key the dispatch resolves by.
const auto settings = manager.get_plugin_settings("ParamProbe");
REQUIRE(settings.count("alpha") == 1);
CHECK(settings.at("alpha") == "1.25");
// Slice with the capability selected, exactly as a preset would reference it.
Print print;
Model model;
auto config = DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new ConfigOptionStrings({"ParamEcho"}));
config.set_key_value("plugins", new ConfigOptionStrings({"ParamProbe;;ParamEcho"}));
init_print({cube(20)}, print, model, config);
print.process();
std::string observed = "<capability never executed>";
{
PythonGILState gil;
REQUIRE(static_cast<bool>(gil));
pybind11::module_ orca = pybind11::module_::import("orca");
if (pybind11::hasattr(orca, "_probe_params"))
observed = orca.attr("_probe_params").cast<std::string>();
}
INFO("ctx.params observed by Python: " << observed);
CHECK(observed.find("'alpha', '1.25'") != std::string::npos);
CHECK(observed.find("'beta', 'hello'") != std::string::npos);
manager.unload_plugin("ParamProbe");
}