diff --git a/src/slic3r/plugin/PluginDescriptor.hpp b/src/slic3r/plugin/PluginDescriptor.hpp index e15dda2666..e2c9a51cf9 100644 --- a/src/slic3r/plugin/PluginDescriptor.hpp +++ b/src/slic3r/plugin/PluginDescriptor.hpp @@ -157,6 +157,12 @@ inline void apply_plugin_metadata_fallbacks(PluginDescriptor& target, const Plug target.entry_package = fallback.entry_package; if (target.dependencies.empty()) target.dependencies = fallback.dependencies; + // [tool.orcaslicer.plugin.settings] lives only in the local package's PEP-723 header; + // cloud catalog records never carry it. Without this, every cloud-metadata merge wipes + // the parsed settings and plugins silently run on their built-in defaults (ctx.params + // arrives empty). + if (target.settings.empty()) + target.settings = fallback.settings; } // Sanitize a value for use as a filesystem name and as a local plugin_key: diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index ead5846c11..d3b6af5858 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -5,7 +5,10 @@ add_executable(${_TEST_NAME}_tests test_plugin_install.cpp test_plugin_lifecycle.cpp test_slicing_pipeline_bindings.cpp + test_slicing_pipeline_params.cpp test_plugin_sort.cpp + test_plugin_cloud_metadata.cpp + ../fff_print/test_helpers.cpp ) if (MSVC) diff --git a/tests/slic3rutils/test_plugin_cloud_metadata.cpp b/tests/slic3rutils/test_plugin_cloud_metadata.cpp new file mode 100644 index 0000000000..2fac559c1d --- /dev/null +++ b/tests/slic3rutils/test_plugin_cloud_metadata.cpp @@ -0,0 +1,133 @@ +#include + +#include +#include +#include +#include +#include + +#include + +#include +#include + +using namespace Slic3r; +namespace fs = boost::filesystem; + +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 +// test_plugin_lifecycle.cpp). No initialize() needed: discovery and the cloud-metadata +// merge never touch Python, but manager shutdown instantiates the interpreter singleton. +struct ScopedManagerShutdown +{ + ~ScopedManagerShutdown() + { + PluginManager::instance().shutdown(); + PythonInterpreter::instance().shutdown(); + } +}; + +// 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 +# requires-python = ">=3.12" +# +# [tool.orcaslicer.plugin] +# name = "Settings 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]") +{ + ScopedManagerShutdown manager_shutdown_guard; // declared first: destroyed last + ScopedDataDir data_dir_guard("cloud-meta-settings"); + + // A locally-installed cloud plugin: package .py with a settings header, plus an + // install-state sidecar carrying the cloud identity. + const std::string uuid = "11111111-2222-3333-4444-555555555555"; + const fs::path plugin_dir = data_dir_guard.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; + } + PluginDescriptor sidecar; + sidecar.name = "Settings Cloud Plugin"; + sidecar.installed_version = "1.0"; + sidecar.cloud = CloudPluginState{uuid, true, false, false, false}; + REQUIRE(write_install_state(plugin_dir, sidecar)); + + PluginManager& manager = PluginManager::instance(); + manager.discover_plugins(/*async=*/false, /*clear=*/true); + + const auto find_by_uuid = [&manager, &uuid]() -> PluginDescriptor { + for (const PluginDescriptor& d : manager.get_plugin_descriptors(/*include_invalid=*/true)) + if (d.cloud_uuid() == uuid) + return d; + 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"); + + // A cloud catalog refresh for the same plugin: the record knows name/version/uuid but has + // no settings, no local paths. + PluginDescriptor cloud_record; + cloud_record.name = "Settings 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}); + + 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); +} diff --git a/tests/slic3rutils/test_slicing_pipeline_params.cpp b/tests/slic3rutils/test_slicing_pipeline_params.cpp new file mode 100644 index 0000000000..808baba48d --- /dev/null +++ b/tests/slic3rutils/test_slicing_pipeline_params.cpp @@ -0,0 +1,160 @@ +#include + +#include +#include +#include +#include + +#include "fff_print/test_helpers.hpp" + +#include +#include + +#include +#include + +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 = ""; + { + PythonGILState gil; + REQUIRE(static_cast(gil)); + pybind11::module_ orca = pybind11::module_::import("orca"); + if (pybind11::hasattr(orca, "_probe_params")) + observed = orca.attr("_probe_params").cast(); + } + 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"); +}