129 KiB
OrcaSlicer UI Automation Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add an opt-in localhost JSON-RPC server to a running OrcaSlicer GUI that lets an external script introspect, drive (via simulated input), and screenshot the real UI (wx widgets, the 3D viewport, and ImGui controls).
Architecture: A pure, GUI-free protocol core (data model + serializer + locator + JSON-RPC dispatcher behind an IUiBackend interface) is fully unit-tested in CI against a MockUiBackend. A boost::beast listener (AutomationServer) receives POST /jsonrpc on 127.0.0.1 and feeds bodies to the dispatcher. The real WxUiBackend marshals every call onto the GUI thread, walks the wxWindow tree, reads a per-frame ImGui item table, drives wxUIActionSimulator, and captures screenshots via window DCs and GLCanvas3D::render_thumbnail(). Everything is gated by is_automation_enabled() so a disabled build has zero new threads and zero behavior change.
Tech Stack: C++17, wxWidgets, boost::asio/beast (already linked), nlohmann/json (vendored, header-only), Catch2 v3.11.0, Dear ImGui (with imgui_internal.h in-tree), Python 3 (reference client).
Architecture note — refinement of the spec's IUiBackend (read before starting)
The design spec (docs/superpowers/specs/2026-06-03-orcaslicer-ui-automation-design.md) lists IUiBackend methods as dump_tree, find, get_widget, click, type, key, wait_for, app_state, screenshot_window, screenshot_viewport3d. The spec also requires (§11) that locator resolution and the dispatcher be unit-testable with no GUI, and that the dispatcher have no wx/ImGui/GL includes.
If find/get_widget/click(target)/wait_for resolved targets inside the backend, that resolution logic would live in both WxUiBackend (real) and MockUiBackend (tests) — a DRY violation — and could not be unit-tested independently. So this plan keeps the external JSON-RPC protocol exactly as the spec defines it (method names, params, results, error codes, node shape — see §5 of the spec) but refines the internal C++ layering:
- Pure / CI-tested (no wx):
UiNode+ structs,WidgetSerializer(UiNode↔ JSON),Locator(resolve target spec + evaluate wait-state overUiNodetrees),JsonRpcDispatcher(parse envelope → validate params →dump_tree→ resolve viaLocator→ call a backend primitive). IUiBackendexposes a snapshot (dump_tree), anapp_state, input primitives that act on an already-resolvedUiNode(carrying its screen rect + an opaquehandle), screenshots, and arefresh_uihook. The dispatcher orchestrates; the backend only collects nodes and executes primitives.WxUiBackend(GUI thread, manual verification) producesUiNodes from the wx tree + ImGui item table and executes the primitives; it marshals every public call onto the GUI thread.MockUiBackend(tests) returns canned trees and records primitive calls.
This satisfies every spec goal while keeping the heart of the system testable. The opaque UiNode::handle (a wxWindow* cast to uintptr_t for wx, or an item index for ImGui) lets the real backend recover concrete objects without re-running resolution; it is never serialized.
File Structure
New — pure core (compiled into both the test target and libslic3r_gui):
src/slic3r/GUI/Automation/IUiBackend.hpp— plain structs (UiNode,Rect,DumpOptions,AppState,KeyChord,PngImage,AutomationError) + the abstractIUiBackend. No wx/ImGui/GL.src/slic3r/GUI/Automation/WidgetSerializer.{hpp,cpp}—UiNode/AppState→nlohmann::json.src/slic3r/GUI/Automation/Locator.{hpp,cpp}— flatten tree, resolveTarget(id → path → predicate),evaluate_stateforsync.wait_for.src/slic3r/GUI/Automation/JsonRpcDispatcher.{hpp,cpp}— JSON-RPC 2.0 parse/route/build; one handler per v1 method; depends only onIUiBackend+Locator+WidgetSerializer.
New — server (GUI side, no widget code):
src/slic3r/GUI/Automation/AutomationServer.{hpp,cpp}— boost::beast listener on127.0.0.1,POST /jsonrpc(reads body) +GET /health page, own thread, delegates body string to astd::function<std::string(const std::string&)>.
New — real GUI backend & ImGui recording (GUI thread):
src/slic3r/GUI/Automation/AutomationRegistry.{hpp,cpp}—wxWindow* ↔ automation_idside map +set_automation_id()helper.src/slic3r/GUI/Automation/ImGuiItemTable.{hpp,cpp}— double-buffered per-frame recorder of ImGui items + window enumeration.src/slic3r/GUI/Automation/WxUiBackend.{hpp,cpp}— realIUiBackend; GUI-thread marshaller; wx tree walk; ImGui item read;wxUIActionSimulator; screenshots.
New — tests, client, docs:
tests/automation/CMakeLists.txttests/automation/automation_tests.cpp— Catch2 entry (linksCatch2::Catch2WithMain).tests/automation/test_serializer.cpp,test_locator.cpp,test_dispatcher.cpptests/automation/MockUiBackend.{hpp,cpp}tools/automation/orca_automation.py— reference Python client.tools/automation/example_slice.py— runnable e2e smoke test.doc/automation.md— protocol reference.
Changed:
src/slic3r/GUI/ImGuiWrapper.cpp— guarded recording hooks (button,bbl_button,checkbox,bbl_checkbox,combo,slider_float,input_double,radio_button,menu_item_with_icon,begin/end, frame swap inrender()).src/slic3r/GUI/ImGuiWrapper.hpp— declare the (compiled-out-when-disabled) recording hook.src/slic3r/GUI/GUI_App.{hpp,cpp}— ownAutomationServer/WxUiBackend/JsonRpcDispatcher;is_automation_enabled(); start inpost_init(), stop inOnExit().src/slic3r/GUI/GUI_Init.hpp— addint automation_porttoGUI_InitParams.src/OrcaSlicer.cpp— read the new CLI options, populateGUI_InitParams.src/libslic3r/PrintConfig.cpp— registerautomation_server(bool) +automation_server_port(int) inCLIMiscConfigDef.src/slic3r/CMakeLists.txt— addAutomation/sources toSLIC3R_GUI_SOURCES.tests/CMakeLists.txt—add_subdirectory(automation).- ~15-20 widget-construction sites —
set_automation_id(...)calls.
CLI flag mapping (deliberate, documented)
The spec asks for --automation-server[=PORT]. OrcaSlicer's CLI is a DynamicConfig (read_cli) that does not cleanly support an optional-value bool. To work with the framework rather than around it, this plan implements two options:
--automation-server(coBool) — enable the server.--automation-server-port(coInt, default13619) — port override.
Same capability; the docs and example use these flag names. automation_port in GUI_InitParams is 0 when disabled, else the chosen port.
PHASE 1 — Pure protocol core (CI-tested, no GUI)
Task 1: Test target scaffold + core types + first serializer test
Files:
-
Create:
src/slic3r/GUI/Automation/IUiBackend.hpp -
Create:
src/slic3r/GUI/Automation/WidgetSerializer.hpp -
Create:
src/slic3r/GUI/Automation/WidgetSerializer.cpp -
Create:
tests/automation/CMakeLists.txt -
Create:
tests/automation/automation_tests.cpp -
Create:
tests/automation/test_serializer.cpp -
Modify:
tests/CMakeLists.txt:50-54(addadd_subdirectory(automation)) -
Step 1: Create the core types header
src/slic3r/GUI/Automation/IUiBackend.hpp:
#pragma once
// PURE header: no wx / ImGui / GL includes. Safe to compile in the display-free
// unit-test target. Shared by the dispatcher, serializer, locator, and backends.
#include <cstdint>
#include <optional>
#include <stdexcept>
#include <string>
#include <vector>
namespace Slic3r { namespace GUI { namespace Automation {
enum class BackendKind { Wx, ImGui };
struct Rect { int x = 0, y = 0, w = 0, h = 0; };
// One node of the unified UI tree. `handle` is opaque (wxWindow* cast to uintptr_t
// for wx, item index for ImGui); it is used by WxUiBackend to recover concrete
// objects and is NEVER serialized.
struct UiNode {
BackendKind backend = BackendKind::Wx;
std::string id; // automation id if set, else derived path id
std::string path; // positional path, e.g. "MainFrame/Panel[2]/Button[0]"
std::string klass; // wx class name or imgui item type
std::string label;
Rect rect; // screen coordinates
bool enabled = true;
bool visible = true;
bool has_value = false;
std::string value; // when applicable (text/choice/check/slider)
std::uint64_t handle = 0;
std::vector<UiNode> children; // wx only; imgui items are flat under their window
};
struct DumpOptions {
std::optional<std::string> root; // id/path to root the dump at
int max_depth = -1; // -1 = unlimited
bool visible_only = false;
bool include_imgui = true;
};
enum class MouseButton { Left, Right, Middle };
enum class KeyModifier { Ctrl, Shift, Alt, Cmd };
struct KeyChord {
std::vector<KeyModifier> modifiers;
std::string key; // normalized lowercase: "s", "enter", "f5", "tab", ...
};
struct AppState {
std::string active_tab;
bool project_loaded = false;
bool slicing = false;
int slice_progress = -1; // -1 = unknown
std::optional<std::string> modal_dialog;
bool foreground = false;
};
struct PngImage {
std::vector<unsigned char> png; // encoded PNG bytes
int width = 0;
int height = 0;
};
// Thrown by backends/dispatcher; carries a JSON-RPC application error code.
struct AutomationError : std::runtime_error {
int code;
AutomationError(int code, std::string msg)
: std::runtime_error(std::move(msg)), code(code) {}
};
// Backend abstraction. The dispatcher orchestrates; the backend only snapshots
// and executes primitives on already-resolved nodes.
class IUiBackend {
public:
virtual ~IUiBackend() = default;
// Force a fresh frame so transient ImGui items are recorded before a read or
// action. No-op for non-GUI backends.
virtual void refresh_ui() = 0;
// Snapshot the UI tree (wx hierarchy + flat imgui items under their windows).
virtual UiNode dump_tree(const DumpOptions& opts) = 0;
// Application-level state snapshot.
virtual AppState app_state() = 0;
// Click a resolved node (uses its rect/handle). Raises/focuses first.
virtual bool click(const UiNode& node, MouseButton button, bool dbl,
const std::vector<KeyModifier>& modifiers) = 0;
// Type into the currently-focused control.
virtual bool type_text(const std::string& text) = 0;
// Send key chords (e.g. ctrl+s) to the focused window.
virtual bool send_keys(const std::vector<KeyChord>& chords) = 0;
// Screenshots. target == nullptr => main frame.
virtual PngImage screenshot_window(const UiNode* target) = 0;
virtual PngImage screenshot_viewport3d(std::optional<int> plate,
std::optional<int> width,
std::optional<int> height) = 0;
};
}}} // namespace Slic3r::GUI::Automation
- Step 2: Create the serializer header
src/slic3r/GUI/Automation/WidgetSerializer.hpp:
#pragma once
#include "IUiBackend.hpp"
#include <nlohmann/json.hpp>
namespace Slic3r { namespace GUI { namespace Automation {
// Serialize a node to the unified JSON shape from the design spec (§5).
// `include_children` controls recursion into UiNode::children.
nlohmann::json node_to_json(const UiNode& node, bool include_children);
// Serialize an application-state snapshot.
nlohmann::json app_state_to_json(const AppState& state);
}}} // namespace
- Step 3: Write the failing serializer test
tests/automation/test_serializer.cpp:
#include <catch2/catch_all.hpp>
#include "slic3r/GUI/Automation/WidgetSerializer.hpp"
using namespace Slic3r::GUI::Automation;
TEST_CASE("node_to_json emits the unified node shape", "[automation][serializer]") {
UiNode n;
n.backend = BackendKind::Wx;
n.id = "btn_slice";
n.path = "MainFrame/Panel[2]/Button[0]";
n.klass = "Button";
n.label = "Slice plate";
n.rect = {100, 200, 120, 32};
n.enabled = true;
n.visible = true;
const nlohmann::json j = node_to_json(n, /*include_children*/ false);
CHECK(j.at("backend") == "wx");
CHECK(j.at("id") == "btn_slice");
CHECK(j.at("path") == "MainFrame/Panel[2]/Button[0]");
CHECK(j.at("class") == "Button");
CHECK(j.at("label") == "Slice plate");
CHECK(j.at("rect").at("x") == 100);
CHECK(j.at("rect").at("w") == 120);
CHECK(j.at("enabled") == true);
CHECK(j.at("visible") == true);
// `handle` must never leak into JSON.
CHECK_FALSE(j.contains("handle"));
// No value set -> no "value" key.
CHECK_FALSE(j.contains("value"));
}
- Step 4: Create the Catch2 entry TU
tests/automation/automation_tests.cpp:
// Catch2 provides main() via Catch2::Catch2WithMain. This TU exists so the
// executable has at least one source plus a stable name; per-feature TEST_CASEs
// live in the test_*.cpp files.
#include <catch2/catch_all.hpp>
- Step 5: Create the test CMake target
tests/automation/CMakeLists.txt:
get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME)
# Compile the PURE automation sources directly (no wx/ImGui/GL), so this test
# executable needs no display and does not link libslic3r_gui.
add_executable(${_TEST_NAME}_tests
automation_tests.cpp
test_serializer.cpp
MockUiBackend.cpp
test_locator.cpp
test_dispatcher.cpp
${CMAKE_SOURCE_DIR}/src/slic3r/GUI/Automation/WidgetSerializer.cpp
${CMAKE_SOURCE_DIR}/src/slic3r/GUI/Automation/Locator.cpp
${CMAKE_SOURCE_DIR}/src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp
)
target_link_libraries(${_TEST_NAME}_tests test_common Catch2::Catch2WithMain nlohmann_json)
target_include_directories(${_TEST_NAME}_tests PRIVATE ${CMAKE_SOURCE_DIR}/src)
set_property(TARGET ${_TEST_NAME}_tests PROPERTY FOLDER "tests")
orcaslicer_copy_test_dlls()
catch_discover_tests(${_TEST_NAME}_tests)
NOTE:
MockUiBackend.cpp,test_locator.cpp,test_dispatcher.cpp,Locator.cpp, andJsonRpcDispatcher.cppare created in later tasks. To build this task in isolation, temporarily list onlyautomation_tests.cpp,test_serializer.cpp, andWidgetSerializer.cpp; add the rest as their tasks land. (Subagent-driven execution: add each file in the task that creates it.)
For Task 1 specifically, use this reduced target body:
add_executable(${_TEST_NAME}_tests
automation_tests.cpp
test_serializer.cpp
${CMAKE_SOURCE_DIR}/src/slic3r/GUI/Automation/WidgetSerializer.cpp
)
target_link_libraries(${_TEST_NAME}_tests test_common Catch2::Catch2WithMain nlohmann_json)
target_include_directories(${_TEST_NAME}_tests PRIVATE ${CMAKE_SOURCE_DIR}/src)
set_property(TARGET ${_TEST_NAME}_tests PROPERTY FOLDER "tests")
orcaslicer_copy_test_dlls()
catch_discover_tests(${_TEST_NAME}_tests)
- Step 6: Register the test subdirectory
In tests/CMakeLists.txt, after line 54 (add_subdirectory(sla_print)), add:
add_subdirectory(automation)
- Step 7: Build and run to verify the test FAILS (no implementation yet)
Run (Windows, from the build dir):
cmake --build . --config RelWithDebInfo --target automation_tests -- -m
ctest --test-dir tests/automation --output-on-failure
Expected: link/compile error — node_to_json is declared but not defined.
- Step 8: Implement the serializer
src/slic3r/GUI/Automation/WidgetSerializer.cpp:
#include "WidgetSerializer.hpp"
namespace Slic3r { namespace GUI { namespace Automation {
static const char* backend_name(BackendKind k) {
return k == BackendKind::Wx ? "wx" : "imgui";
}
nlohmann::json node_to_json(const UiNode& node, bool include_children) {
nlohmann::json j;
j["backend"] = backend_name(node.backend);
j["id"] = node.id;
j["path"] = node.path;
j["class"] = node.klass;
j["label"] = node.label;
j["rect"] = { {"x", node.rect.x}, {"y", node.rect.y},
{"w", node.rect.w}, {"h", node.rect.h} };
j["enabled"] = node.enabled;
j["visible"] = node.visible;
if (node.has_value)
j["value"] = node.value;
if (include_children && node.backend == BackendKind::Wx) {
nlohmann::json arr = nlohmann::json::array();
for (const UiNode& c : node.children)
arr.push_back(node_to_json(c, true));
j["children"] = std::move(arr);
}
return j;
}
nlohmann::json app_state_to_json(const AppState& s) {
nlohmann::json j;
j["active_tab"] = s.active_tab;
j["project_loaded"] = s.project_loaded;
j["slicing"] = s.slicing;
j["slice_progress"] = s.slice_progress;
j["foreground"] = s.foreground;
if (s.modal_dialog)
j["modal_dialog"] = *s.modal_dialog;
return j;
}
}}} // namespace
- Step 9: Build and run to verify the test PASSES
Run:
cmake --build . --config RelWithDebInfo --target automation_tests -- -m
ctest --test-dir tests/automation --output-on-failure
Expected: test_serializer.cpp cases PASS.
- Step 10: Commit
git add src/slic3r/GUI/Automation/IUiBackend.hpp \
src/slic3r/GUI/Automation/WidgetSerializer.hpp \
src/slic3r/GUI/Automation/WidgetSerializer.cpp \
tests/automation/ tests/CMakeLists.txt
git commit -m "feat(automation): pure UI node model + JSON serializer with unit test"
Task 2: Serializer — children, values, ImGui nodes, app_state
Files:
-
Modify:
tests/automation/test_serializer.cpp -
(Implementation already covers these; this task locks behavior with tests and fixes any gaps.)
-
Step 1: Add failing tests for children/value/imgui/app_state
Append to tests/automation/test_serializer.cpp:
TEST_CASE("node_to_json includes children only for wx when requested",
"[automation][serializer]") {
UiNode parent;
parent.backend = BackendKind::Wx;
parent.klass = "Panel";
UiNode child;
child.backend = BackendKind::Wx;
child.klass = "Button";
child.label = "OK";
parent.children.push_back(child);
const auto with = node_to_json(parent, true);
const auto without = node_to_json(parent, false);
REQUIRE(with.contains("children"));
CHECK(with.at("children").size() == 1);
CHECK(with.at("children")[0].at("label") == "OK");
CHECK_FALSE(without.contains("children"));
}
TEST_CASE("node_to_json emits value and imgui backend tag",
"[automation][serializer]") {
UiNode n;
n.backend = BackendKind::ImGui;
n.klass = "combo";
n.has_value = true;
n.value = "PLA";
const auto j = node_to_json(n, /*include_children*/ true);
CHECK(j.at("backend") == "imgui");
CHECK(j.at("value") == "PLA");
CHECK_FALSE(j.contains("children")); // imgui items are flat
}
TEST_CASE("app_state_to_json shape", "[automation][serializer]") {
AppState s;
s.active_tab = "preview";
s.project_loaded = true;
s.slicing = true;
s.slice_progress = 42;
s.foreground = true;
s.modal_dialog = std::string("Save changes?");
const auto j = app_state_to_json(s);
CHECK(j.at("active_tab") == "preview");
CHECK(j.at("project_loaded") == true);
CHECK(j.at("slice_progress") == 42);
CHECK(j.at("modal_dialog") == "Save changes?");
}
- Step 2: Run to confirm PASS (implementation from Task 1 already covers these)
Run: ctest --test-dir tests/automation --output-on-failure
Expected: all serializer cases PASS. If modal_dialog or children gating fails, fix WidgetSerializer.cpp accordingly (it should already match).
- Step 3: Commit
git add tests/automation/test_serializer.cpp
git commit -m "test(automation): lock serializer children/value/imgui/app_state shapes"
Task 3: Locator — flatten + find_matches (id / path / predicate)
Files:
-
Create:
src/slic3r/GUI/Automation/Locator.hpp -
Create:
src/slic3r/GUI/Automation/Locator.cpp -
Create:
tests/automation/test_locator.cpp -
Modify:
tests/automation/CMakeLists.txt(addtest_locator.cpp+Locator.cpp) -
Step 1: Create the Locator header
src/slic3r/GUI/Automation/Locator.hpp:
#pragma once
#include "IUiBackend.hpp"
#include <optional>
#include <string>
#include <vector>
namespace Slic3r { namespace GUI { namespace Automation {
// A target specification. Resolution order: id -> path -> predicate
// (name OR class OR label OR value, all provided fields must match).
struct Target {
std::optional<std::string> id;
std::optional<std::string> path;
std::optional<std::string> name; // matches id OR label
std::optional<std::string> klass;
std::optional<std::string> label;
std::optional<std::string> value;
std::optional<BackendKind> backend;
bool empty() const {
return !id && !path && !name && !klass && !label && !value;
}
};
// Depth-first flatten of a tree into stable-ordered pointers (parents before children).
std::vector<const UiNode*> flatten(const UiNode& root);
// All nodes matching the target spec (resolution-order aware).
std::vector<const UiNode*> find_matches(const UiNode& root, const Target& target);
enum class WaitState { Exists, Visible, Enabled, Value };
// True if `node` satisfies the wait condition. A null node only satisfies a
// negative... here we keep it simple: null => false for all states.
bool evaluate_state(const UiNode* node, WaitState state,
const std::optional<std::string>& expected_value);
}}} // namespace
- Step 2: Write failing locator tests
tests/automation/test_locator.cpp:
#include <catch2/catch_all.hpp>
#include "slic3r/GUI/Automation/Locator.hpp"
using namespace Slic3r::GUI::Automation;
namespace {
UiNode make_tree() {
UiNode root;
root.klass = "MainFrame";
root.path = "MainFrame";
UiNode panel;
panel.klass = "Panel";
panel.path = "MainFrame/Panel[0]";
UiNode slice;
slice.id = "btn_slice";
slice.klass = "Button";
slice.label = "Slice plate";
slice.path = "MainFrame/Panel[0]/Button[0]";
UiNode export_btn;
export_btn.id = "btn_export";
export_btn.klass = "Button";
export_btn.label = "Export";
export_btn.path = "MainFrame/Panel[0]/Button[1]";
UiNode dup; // duplicate label, used for ambiguity tests
dup.klass = "Button";
dup.label = "Export";
dup.path = "MainFrame/Panel[0]/Button[2]";
panel.children = {slice, export_btn, dup};
root.children = {panel};
return root;
}
} // namespace
TEST_CASE("flatten yields parents before children", "[automation][locator]") {
const auto tree = make_tree();
const auto all = flatten(tree);
REQUIRE(all.size() == 5);
CHECK(all.front()->klass == "MainFrame");
}
TEST_CASE("find_matches by exact id returns one", "[automation][locator]") {
const auto tree = make_tree();
Target t; t.id = "btn_slice";
const auto m = find_matches(tree, t);
REQUIRE(m.size() == 1);
CHECK(m[0]->label == "Slice plate");
}
TEST_CASE("find_matches by exact path returns one", "[automation][locator]") {
const auto tree = make_tree();
Target t; t.path = "MainFrame/Panel[0]/Button[1]";
const auto m = find_matches(tree, t);
REQUIRE(m.size() == 1);
CHECK(m[0]->id == "btn_export");
}
TEST_CASE("find_matches by predicate (label) can be ambiguous",
"[automation][locator]") {
const auto tree = make_tree();
Target t; t.label = "Export";
const auto m = find_matches(tree, t);
CHECK(m.size() == 2); // btn_export + the duplicate
}
TEST_CASE("find_matches predicate combines fields (AND)",
"[automation][locator]") {
const auto tree = make_tree();
Target t; t.label = "Export"; t.klass = "Button"; t.id = std::nullopt;
// id/path absent -> predicate mode. Both fields must match.
t.id = std::nullopt;
const auto m = find_matches(tree, t);
CHECK(m.size() == 2);
}
TEST_CASE("find_matches by name matches id OR label", "[automation][locator]") {
const auto tree = make_tree();
Target byId; byId.name = "btn_slice";
CHECK(find_matches(tree, byId).size() == 1);
Target byLabel; byLabel.name = "Slice plate";
CHECK(find_matches(tree, byLabel).size() == 1);
}
TEST_CASE("find_matches not found returns empty", "[automation][locator]") {
const auto tree = make_tree();
Target t; t.id = "nope";
CHECK(find_matches(tree, t).empty());
}
- Step 3: Add the files to the test target
In tests/automation/CMakeLists.txt, extend the executable source list (now using the fuller set, minus dispatcher which arrives in Task 6):
add_executable(${_TEST_NAME}_tests
automation_tests.cpp
test_serializer.cpp
test_locator.cpp
${CMAKE_SOURCE_DIR}/src/slic3r/GUI/Automation/WidgetSerializer.cpp
${CMAKE_SOURCE_DIR}/src/slic3r/GUI/Automation/Locator.cpp
)
- Step 4: Run to verify FAIL (Locator.cpp empty / unlinked)
Run: cmake --build . --config RelWithDebInfo --target automation_tests -- -m
Expected: undefined reference to flatten/find_matches.
- Step 5: Implement flatten + find_matches
src/slic3r/GUI/Automation/Locator.cpp:
#include "Locator.hpp"
namespace Slic3r { namespace GUI { namespace Automation {
static void flatten_into(const UiNode& n, std::vector<const UiNode*>& out) {
out.push_back(&n);
for (const UiNode& c : n.children)
flatten_into(c, out);
}
std::vector<const UiNode*> flatten(const UiNode& root) {
std::vector<const UiNode*> out;
flatten_into(root, out);
return out;
}
static bool matches_predicate(const UiNode& n, const Target& t) {
if (t.backend && n.backend != *t.backend) return false;
if (t.name && !(n.id == *t.name || n.label == *t.name)) return false;
if (t.klass && n.klass != *t.klass) return false;
if (t.label && n.label != *t.label) return false;
if (t.value && !(n.has_value && n.value == *t.value)) return false;
return true;
}
std::vector<const UiNode*> find_matches(const UiNode& root, const Target& target) {
const auto all = flatten(root);
std::vector<const UiNode*> out;
// Resolution order: exact id -> exact path -> predicate.
if (target.id) {
for (const UiNode* n : all)
if (n->id == *target.id) out.push_back(n);
return out;
}
if (target.path) {
for (const UiNode* n : all)
if (n->path == *target.path) out.push_back(n);
return out;
}
if (target.empty())
return out; // nothing to match on
for (const UiNode* n : all)
if (matches_predicate(*n, target)) out.push_back(n);
return out;
}
bool evaluate_state(const UiNode* node, WaitState state,
const std::optional<std::string>& expected_value) {
if (node == nullptr)
return false;
switch (state) {
case WaitState::Exists: return true;
case WaitState::Visible: return node->visible;
case WaitState::Enabled: return node->enabled && node->visible;
case WaitState::Value:
return node->has_value && expected_value && node->value == *expected_value;
}
return false;
}
}}} // namespace
- Step 6: Run to verify PASS
Run: ctest --test-dir tests/automation --output-on-failure
Expected: all locator cases PASS.
- Step 7: Commit
git add src/slic3r/GUI/Automation/Locator.hpp \
src/slic3r/GUI/Automation/Locator.cpp \
tests/automation/test_locator.cpp tests/automation/CMakeLists.txt
git commit -m "feat(automation): pure locator (id/path/predicate) with unit tests"
Task 4: Locator — resolve_unique + evaluate_state edge cases
Files:
-
Modify:
src/slic3r/GUI/Automation/Locator.hpp -
Modify:
src/slic3r/GUI/Automation/Locator.cpp -
Modify:
tests/automation/test_locator.cpp -
Step 1: Declare
resolve_unique
Add to Locator.hpp (above WaitState):
// Resolve to exactly one node for actions. Returns the node on a unique match;
// returns nullptr otherwise and sets match_count (0 = not found, >1 = ambiguous).
const UiNode* resolve_unique(const UiNode& root, const Target& target, int& match_count);
- Step 2: Write failing tests for resolve_unique + evaluate_state
Append to tests/automation/test_locator.cpp:
TEST_CASE("resolve_unique success / not-found / ambiguous",
"[automation][locator]") {
const auto tree = make_tree();
int count = -1;
Target ok; ok.id = "btn_slice";
CHECK(resolve_unique(tree, ok, count) != nullptr);
CHECK(count == 1);
Target missing; missing.id = "nope";
CHECK(resolve_unique(tree, missing, count) == nullptr);
CHECK(count == 0);
Target ambiguous; ambiguous.label = "Export";
CHECK(resolve_unique(tree, ambiguous, count) == nullptr);
CHECK(count == 2);
}
TEST_CASE("evaluate_state covers exists/visible/enabled/value",
"[automation][locator]") {
UiNode n; n.visible = true; n.enabled = false;
n.has_value = true; n.value = "PLA";
CHECK(evaluate_state(&n, WaitState::Exists, std::nullopt));
CHECK(evaluate_state(&n, WaitState::Visible, std::nullopt));
CHECK_FALSE(evaluate_state(&n, WaitState::Enabled, std::nullopt)); // disabled
CHECK(evaluate_state(&n, WaitState::Value, std::string("PLA")));
CHECK_FALSE(evaluate_state(&n, WaitState::Value, std::string("ABS")));
CHECK_FALSE(evaluate_state(nullptr, WaitState::Exists, std::nullopt));
}
- Step 3: Run to verify FAIL
Run: cmake --build . --config RelWithDebInfo --target automation_tests -- -m
Expected: undefined reference to resolve_unique.
- Step 4: Implement resolve_unique
Add to Locator.cpp:
const UiNode* resolve_unique(const UiNode& root, const Target& target, int& match_count) {
const auto m = find_matches(root, target);
match_count = static_cast<int>(m.size());
return m.size() == 1 ? m.front() : nullptr;
}
- Step 5: Run to verify PASS
Run: ctest --test-dir tests/automation --output-on-failure
Expected: all locator cases PASS.
- Step 6: Commit
git add src/slic3r/GUI/Automation/Locator.hpp \
src/slic3r/GUI/Automation/Locator.cpp tests/automation/test_locator.cpp
git commit -m "feat(automation): resolve_unique + wait-state evaluation"
Task 5: MockUiBackend (test double)
Files:
-
Create:
tests/automation/MockUiBackend.hpp -
Create:
tests/automation/MockUiBackend.cpp -
Modify:
tests/automation/CMakeLists.txt(addMockUiBackend.cpp) -
Step 1: Create the mock header
tests/automation/MockUiBackend.hpp:
#pragma once
#include "slic3r/GUI/Automation/IUiBackend.hpp"
#include <functional>
#include <vector>
namespace Slic3r { namespace GUI { namespace Automation {
// Deterministic fake backend for dispatcher tests. Records every primitive call
// and returns canned data. `tree_provider` lets a test return different trees on
// successive dump_tree() calls (used for sync.wait_for tests).
class MockUiBackend : public IUiBackend {
public:
// Recorded calls (inspected by tests).
int refresh_count = 0;
int dump_count = 0;
std::vector<std::string> clicked_ids; // node.id of each click()
std::vector<MouseButton> click_buttons;
std::vector<std::string> typed_text;
std::vector<std::vector<KeyChord>> sent_keys;
int screenshot_window_count = 0;
int screenshot_viewport_count = 0;
// Canned outputs (set by tests).
UiNode tree; // default tree for dump_tree
AppState state;
PngImage canned_png{ {0x89,0x50,0x4E,0x47}, 4, 4 }; // fake "PNG" bytes
bool click_result = true;
// Optional: per-call tree provider (overrides `tree` when set).
std::function<UiNode(int /*call_index*/)> tree_provider;
void refresh_ui() override { ++refresh_count; }
UiNode dump_tree(const DumpOptions&) override {
const int idx = dump_count++;
return tree_provider ? tree_provider(idx) : tree;
}
AppState app_state() override { return state; }
bool click(const UiNode& node, MouseButton button, bool /*dbl*/,
const std::vector<KeyModifier>&) override {
clicked_ids.push_back(node.id);
click_buttons.push_back(button);
return click_result;
}
bool type_text(const std::string& text) override {
typed_text.push_back(text); return true;
}
bool send_keys(const std::vector<KeyChord>& chords) override {
sent_keys.push_back(chords); return true;
}
PngImage screenshot_window(const UiNode*) override {
++screenshot_window_count; return canned_png;
}
PngImage screenshot_viewport3d(std::optional<int>, std::optional<int>,
std::optional<int>) override {
++screenshot_viewport_count; return canned_png;
}
};
}}} // namespace
- Step 2: Create the mock .cpp (sanity test + TU)
tests/automation/MockUiBackend.cpp:
#include "MockUiBackend.hpp"
#include <catch2/catch_all.hpp>
using namespace Slic3r::GUI::Automation;
TEST_CASE("MockUiBackend records calls", "[automation][mock]") {
MockUiBackend mock;
UiNode n; n.id = "btn_slice";
mock.click(n, MouseButton::Left, false, {});
REQUIRE(mock.clicked_ids.size() == 1);
CHECK(mock.clicked_ids[0] == "btn_slice");
CHECK(mock.click_buttons[0] == MouseButton::Left);
}
- Step 3: Add
MockUiBackend.cppto the test target
In tests/automation/CMakeLists.txt, add MockUiBackend.cpp to the source list.
- Step 4: Build and run to verify PASS
Run: ctest --test-dir tests/automation --output-on-failure
Expected: the mock sanity case PASSES.
- Step 5: Commit
git add tests/automation/MockUiBackend.hpp tests/automation/MockUiBackend.cpp \
tests/automation/CMakeLists.txt
git commit -m "test(automation): MockUiBackend recording test double"
Task 6: JsonRpcDispatcher — envelope, automation.version, error model
Files:
-
Create:
src/slic3r/GUI/Automation/JsonRpcDispatcher.hpp -
Create:
src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp -
Create:
tests/automation/test_dispatcher.cpp -
Modify:
tests/automation/CMakeLists.txt(addtest_dispatcher.cpp+JsonRpcDispatcher.cpp) -
Step 1: Create the dispatcher header
src/slic3r/GUI/Automation/JsonRpcDispatcher.hpp:
#pragma once
#include "IUiBackend.hpp"
#include <nlohmann/json.hpp>
#include <string>
namespace Slic3r { namespace GUI { namespace Automation {
// JSON-RPC 2.0 standard error codes.
constexpr int kParseError = -32700;
constexpr int kInvalidRequest = -32600;
constexpr int kMethodNotFound = -32601;
constexpr int kInvalidParams = -32602;
// Application error codes (design spec §5).
constexpr int kErrNotFound = 1001; // widget/target not found (or ambiguous)
constexpr int kErrNotActionable = 1002; // disabled / hidden
constexpr int kErrWaitTimeout = 1003;
constexpr int kErrGuiBusy = 1004; // GUI thread timeout
constexpr int kErrScreenshotFail = 1005;
constexpr int kErrDisabled = 1006;
constexpr const char* kProtocolVersion = "2.0";
constexpr const char* kAutomationVersion = "1.0.0";
class JsonRpcDispatcher {
public:
explicit JsonRpcDispatcher(IUiBackend& backend);
// Parse a JSON-RPC request body, dispatch, and return the response body.
// Never throws; transport-level/parse errors become JSON-RPC error responses.
std::string handle_request(const std::string& body);
// For tests: dispatch an already-parsed request object and return the response.
nlohmann::json dispatch(const nlohmann::json& request);
private:
nlohmann::json make_result(const nlohmann::json& id, nlohmann::json result);
nlohmann::json make_error(const nlohmann::json& id, int code, const std::string& msg);
// Method handlers (each returns the `result` object or throws AutomationError).
nlohmann::json m_version(const nlohmann::json& params);
nlohmann::json m_tree_dump(const nlohmann::json& params);
nlohmann::json m_tree_find(const nlohmann::json& params);
nlohmann::json m_widget_get(const nlohmann::json& params);
nlohmann::json m_input_click(const nlohmann::json& params);
nlohmann::json m_input_type(const nlohmann::json& params);
nlohmann::json m_input_key(const nlohmann::json& params);
nlohmann::json m_sync_wait_for(const nlohmann::json& params);
nlohmann::json m_app_state(const nlohmann::json& params);
nlohmann::json m_screenshot_window(const nlohmann::json& params);
nlohmann::json m_screenshot_viewport3d(const nlohmann::json& params);
IUiBackend& m_backend;
};
}}} // namespace
- Step 2: Write failing dispatcher tests (envelope + version + errors)
tests/automation/test_dispatcher.cpp:
#include <catch2/catch_all.hpp>
#include "slic3r/GUI/Automation/JsonRpcDispatcher.hpp"
#include "MockUiBackend.hpp"
using namespace Slic3r::GUI::Automation;
using nlohmann::json;
TEST_CASE("dispatch automation.version", "[automation][rpc]") {
MockUiBackend mock;
JsonRpcDispatcher d(mock);
const json req = {{"jsonrpc","2.0"},{"id",1},{"method","automation.version"}};
const json resp = d.dispatch(req);
CHECK(resp.at("jsonrpc") == "2.0");
CHECK(resp.at("id") == 1);
CHECK(resp.at("result").at("version") == kAutomationVersion);
CHECK(resp.at("result").at("protocol") == "2.0");
CHECK(resp.at("result").at("capabilities").is_array());
}
TEST_CASE("unknown method -> -32601", "[automation][rpc]") {
MockUiBackend mock;
JsonRpcDispatcher d(mock);
const json req = {{"jsonrpc","2.0"},{"id",7},{"method","does.not.exist"}};
const json resp = d.dispatch(req);
CHECK(resp.at("id") == 7);
CHECK(resp.at("error").at("code") == kMethodNotFound);
}
TEST_CASE("malformed JSON body -> parse error", "[automation][rpc]") {
MockUiBackend mock;
JsonRpcDispatcher d(mock);
const std::string resp = d.handle_request("{not json");
const json j = json::parse(resp);
CHECK(j.at("error").at("code") == kParseError);
CHECK(j.at("id").is_null());
}
TEST_CASE("missing method field -> invalid request", "[automation][rpc]") {
MockUiBackend mock;
JsonRpcDispatcher d(mock);
const json req = {{"jsonrpc","2.0"},{"id",2}};
const json resp = d.dispatch(req);
CHECK(resp.at("error").at("code") == kInvalidRequest);
}
- Step 3: Add files to the test target
In tests/automation/CMakeLists.txt, add test_dispatcher.cpp and the source ${CMAKE_SOURCE_DIR}/src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp. The full source list should now match the complete version shown in Task 1, Step 5.
- Step 4: Run to verify FAIL
Run: cmake --build . --config RelWithDebInfo --target automation_tests -- -m
Expected: undefined references to JsonRpcDispatcher methods.
- Step 5: Implement the dispatcher skeleton (envelope + version + errors)
src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp:
#include "JsonRpcDispatcher.hpp"
#include "WidgetSerializer.hpp"
#include "Locator.hpp"
#include <chrono>
#include <thread>
namespace Slic3r { namespace GUI { namespace Automation {
JsonRpcDispatcher::JsonRpcDispatcher(IUiBackend& backend) : m_backend(backend) {}
nlohmann::json JsonRpcDispatcher::make_result(const nlohmann::json& id, nlohmann::json result) {
return { {"jsonrpc","2.0"}, {"id", id}, {"result", std::move(result)} };
}
nlohmann::json JsonRpcDispatcher::make_error(const nlohmann::json& id, int code,
const std::string& msg) {
return { {"jsonrpc","2.0"}, {"id", id},
{"error", { {"code", code}, {"message", msg} }} };
}
nlohmann::json JsonRpcDispatcher::m_version(const nlohmann::json&) {
return { {"version", kAutomationVersion},
{"protocol", "2.0"},
{"capabilities", nlohmann::json::array({
"tree.dump","tree.find","widget.get","input.click","input.type",
"input.key","sync.wait_for","app.state","screenshot.window",
"screenshot.viewport3d" })} };
}
nlohmann::json JsonRpcDispatcher::dispatch(const nlohmann::json& request) {
nlohmann::json id = request.contains("id") ? request.at("id") : nlohmann::json(nullptr);
if (!request.is_object() || !request.contains("method") ||
!request.at("method").is_string()) {
return make_error(id, kInvalidRequest, "missing or invalid 'method'");
}
const std::string method = request.at("method").get<std::string>();
const nlohmann::json params =
request.contains("params") ? request.at("params") : nlohmann::json::object();
try {
if (method == "automation.version") return make_result(id, m_version(params));
if (method == "tree.dump") return make_result(id, m_tree_dump(params));
if (method == "tree.find") return make_result(id, m_tree_find(params));
if (method == "widget.get") return make_result(id, m_widget_get(params));
if (method == "input.click") return make_result(id, m_input_click(params));
if (method == "input.type") return make_result(id, m_input_type(params));
if (method == "input.key") return make_result(id, m_input_key(params));
if (method == "sync.wait_for") return make_result(id, m_sync_wait_for(params));
if (method == "app.state") return make_result(id, m_app_state(params));
if (method == "screenshot.window") return make_result(id, m_screenshot_window(params));
if (method == "screenshot.viewport3d") return make_result(id, m_screenshot_viewport3d(params));
return make_error(id, kMethodNotFound, "unknown method: " + method);
} catch (const AutomationError& e) {
return make_error(id, e.code, e.what());
} catch (const std::exception& e) {
return make_error(id, kInvalidParams, e.what());
}
}
std::string JsonRpcDispatcher::handle_request(const std::string& body) {
nlohmann::json req;
try {
req = nlohmann::json::parse(body);
} catch (const std::exception& e) {
return make_error(nullptr, kParseError, std::string("parse error: ") + e.what()).dump();
}
return dispatch(req).dump();
}
// --- method handlers implemented in Tasks 7-10 (stubs throw for now) ---
nlohmann::json JsonRpcDispatcher::m_tree_dump(const nlohmann::json&) { throw AutomationError(kMethodNotFound, "not implemented"); }
nlohmann::json JsonRpcDispatcher::m_tree_find(const nlohmann::json&) { throw AutomationError(kMethodNotFound, "not implemented"); }
nlohmann::json JsonRpcDispatcher::m_widget_get(const nlohmann::json&) { throw AutomationError(kMethodNotFound, "not implemented"); }
nlohmann::json JsonRpcDispatcher::m_input_click(const nlohmann::json&) { throw AutomationError(kMethodNotFound, "not implemented"); }
nlohmann::json JsonRpcDispatcher::m_input_type(const nlohmann::json&) { throw AutomationError(kMethodNotFound, "not implemented"); }
nlohmann::json JsonRpcDispatcher::m_input_key(const nlohmann::json&) { throw AutomationError(kMethodNotFound, "not implemented"); }
nlohmann::json JsonRpcDispatcher::m_sync_wait_for(const nlohmann::json&) { throw AutomationError(kMethodNotFound, "not implemented"); }
nlohmann::json JsonRpcDispatcher::m_app_state(const nlohmann::json&) { throw AutomationError(kMethodNotFound, "not implemented"); }
nlohmann::json JsonRpcDispatcher::m_screenshot_window(const nlohmann::json&) { throw AutomationError(kMethodNotFound, "not implemented"); }
nlohmann::json JsonRpcDispatcher::m_screenshot_viewport3d(const nlohmann::json&){ throw AutomationError(kMethodNotFound, "not implemented"); }
}}} // namespace
- Step 6: Run to verify PASS
Run: ctest --test-dir tests/automation --output-on-failure
Expected: version + error envelope cases PASS.
- Step 7: Commit
git add src/slic3r/GUI/Automation/JsonRpcDispatcher.hpp \
src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp \
tests/automation/test_dispatcher.cpp tests/automation/CMakeLists.txt
git commit -m "feat(automation): JSON-RPC dispatcher envelope + version + error model"
Task 7: Dispatcher — tree.dump, tree.find, widget.get
Files:
-
Modify:
src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp -
Modify:
tests/automation/test_dispatcher.cpp -
Step 1: Add a shared
parse_targethelper + failing tests
Append to tests/automation/test_dispatcher.cpp:
namespace {
UiNode dispatcher_tree() {
UiNode root; root.klass = "MainFrame"; root.path = "MainFrame";
UiNode b; b.id = "btn_slice"; b.klass = "Button"; b.label = "Slice plate";
b.path = "MainFrame/Button[0]"; b.rect = {10,20,100,30};
UiNode e; e.id = "btn_export"; e.klass = "Button"; e.label = "Export";
e.path = "MainFrame/Button[1]"; e.enabled = false;
root.children = {b, e};
return root;
}
} // namespace
TEST_CASE("tree.dump returns the serialized tree", "[automation][rpc]") {
MockUiBackend mock; mock.tree = dispatcher_tree();
JsonRpcDispatcher d(mock);
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",1},{"method","tree.dump"}});
const json& result = resp.at("result");
CHECK(result.at("class") == "MainFrame");
CHECK(result.at("children").size() == 2);
CHECK(mock.refresh_count == 1); // refreshed before reading
}
TEST_CASE("tree.find returns matching nodes", "[automation][rpc]") {
MockUiBackend mock; mock.tree = dispatcher_tree();
JsonRpcDispatcher d(mock);
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",2},{"method","tree.find"},
{"params",{{"class","Button"}}}});
CHECK(resp.at("result").size() == 2);
}
TEST_CASE("widget.get returns a single node by id", "[automation][rpc]") {
MockUiBackend mock; mock.tree = dispatcher_tree();
JsonRpcDispatcher d(mock);
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",3},{"method","widget.get"},
{"params",{{"target",{{"id","btn_slice"}}}}}});
CHECK(resp.at("result").at("id") == "btn_slice");
}
TEST_CASE("widget.get not found -> 1001", "[automation][rpc]") {
MockUiBackend mock; mock.tree = dispatcher_tree();
JsonRpcDispatcher d(mock);
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",4},{"method","widget.get"},
{"params",{{"target",{{"id","nope"}}}}}});
CHECK(resp.at("error").at("code") == kErrNotFound);
}
- Step 2: Run to verify FAIL
Run: ctest --test-dir tests/automation --output-on-failure
Expected: tree.dump/find/get cases FAIL (still "not implemented").
- Step 3: Implement a
parse_targethelper + the three handlers
In JsonRpcDispatcher.cpp, add near the top (after the make_error definition):
namespace {
std::optional<std::string> opt_str(const nlohmann::json& p, const char* key) {
if (p.is_object() && p.contains(key) && p.at(key).is_string())
return p.at(key).get<std::string>();
return std::nullopt;
}
Target parse_target(const nlohmann::json& tj) {
Target t;
if (!tj.is_object()) return t;
t.id = opt_str(tj, "id");
t.path = opt_str(tj, "path");
t.name = opt_str(tj, "name");
t.klass = opt_str(tj, "class");
t.label = opt_str(tj, "label");
t.value = opt_str(tj, "value");
if (auto b = opt_str(tj, "backend"))
t.backend = (*b == "imgui") ? BackendKind::ImGui : BackendKind::Wx;
return t;
}
DumpOptions parse_dump_options(const nlohmann::json& p) {
DumpOptions o;
if (p.is_object()) {
if (p.contains("root")) o.root = opt_str(p, "root");
if (p.contains("max_depth") && p.at("max_depth").is_number_integer())
o.max_depth = p.at("max_depth").get<int>();
if (p.contains("visible_only") && p.at("visible_only").is_boolean())
o.visible_only = p.at("visible_only").get<bool>();
if (p.contains("include_imgui") && p.at("include_imgui").is_boolean())
o.include_imgui = p.at("include_imgui").get<bool>();
}
return o;
}
} // namespace
Replace the three stub bodies:
nlohmann::json JsonRpcDispatcher::m_tree_dump(const nlohmann::json& params) {
m_backend.refresh_ui();
const UiNode root = m_backend.dump_tree(parse_dump_options(params));
return node_to_json(root, /*include_children*/ true);
}
nlohmann::json JsonRpcDispatcher::m_tree_find(const nlohmann::json& params) {
m_backend.refresh_ui();
const UiNode root = m_backend.dump_tree(DumpOptions{});
const Target target =
parse_target(params.is_object() ? params : nlohmann::json::object());
nlohmann::json arr = nlohmann::json::array();
for (const UiNode* n : find_matches(root, target))
arr.push_back(node_to_json(*n, /*include_children*/ false));
return arr;
}
nlohmann::json JsonRpcDispatcher::m_widget_get(const nlohmann::json& params) {
if (!params.is_object() || !params.contains("target"))
throw AutomationError(kInvalidParams, "widget.get requires 'target'");
m_backend.refresh_ui();
const UiNode root = m_backend.dump_tree(DumpOptions{});
int count = 0;
const UiNode* node = resolve_unique(root, parse_target(params.at("target")), count);
if (count == 0) throw AutomationError(kErrNotFound, "target not found");
if (count > 1) throw AutomationError(kErrNotFound, "target is ambiguous");
return node_to_json(*node, /*include_children*/ true);
}
- Step 4: Run to verify PASS
Run: ctest --test-dir tests/automation --output-on-failure
Expected: tree.dump/find + widget.get cases PASS.
- Step 5: Commit
git add src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp tests/automation/test_dispatcher.cpp
git commit -m "feat(automation): tree.dump / tree.find / widget.get handlers"
Task 8: Dispatcher — input.click, input.type, input.key
Files:
-
Modify:
src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp -
Modify:
tests/automation/test_dispatcher.cpp -
Step 1: Write failing input tests
Append to tests/automation/test_dispatcher.cpp:
TEST_CASE("input.click resolves target and clicks it", "[automation][rpc]") {
MockUiBackend mock; mock.tree = dispatcher_tree();
JsonRpcDispatcher d(mock);
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",1},{"method","input.click"},
{"params",{{"target",{{"id","btn_slice"}}}}}});
CHECK(resp.at("result").at("ok") == true);
REQUIRE(mock.clicked_ids.size() == 1);
CHECK(mock.clicked_ids[0] == "btn_slice");
CHECK(mock.click_buttons[0] == MouseButton::Left);
}
TEST_CASE("input.click on disabled widget -> 1002", "[automation][rpc]") {
MockUiBackend mock; mock.tree = dispatcher_tree();
JsonRpcDispatcher d(mock);
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",2},{"method","input.click"},
{"params",{{"target",{{"id","btn_export"}}}}}});
CHECK(resp.at("error").at("code") == kErrNotActionable);
CHECK(mock.clicked_ids.empty());
}
TEST_CASE("input.type with target clicks to focus then types", "[automation][rpc]") {
MockUiBackend mock; mock.tree = dispatcher_tree();
JsonRpcDispatcher d(mock);
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",3},{"method","input.type"},
{"params",{{"target",{{"id","btn_slice"}}},{"text","hello"}}}});
CHECK(resp.at("result").at("ok") == true);
CHECK(mock.clicked_ids.size() == 1); // focused first
REQUIRE(mock.typed_text.size() == 1);
CHECK(mock.typed_text[0] == "hello");
}
TEST_CASE("input.key parses 'ctrl+s' string form", "[automation][rpc]") {
MockUiBackend mock;
JsonRpcDispatcher d(mock);
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",4},{"method","input.key"},
{"params",{{"keys","ctrl+s"}}}});
CHECK(resp.at("result").at("ok") == true);
REQUIRE(mock.sent_keys.size() == 1);
REQUIRE(mock.sent_keys[0].size() == 1);
CHECK(mock.sent_keys[0][0].key == "s");
REQUIRE(mock.sent_keys[0][0].modifiers.size() == 1);
CHECK(mock.sent_keys[0][0].modifiers[0] == KeyModifier::Ctrl);
}
TEST_CASE("input.key parses array form [\"ctrl\",\"s\"]", "[automation][rpc]") {
MockUiBackend mock;
JsonRpcDispatcher d(mock);
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",5},{"method","input.key"},
{"params",{{"keys", json::array({"ctrl","s"})}}}});
CHECK(resp.at("result").at("ok") == true);
REQUIRE(mock.sent_keys[0][0].modifiers.size() == 1);
CHECK(mock.sent_keys[0][0].key == "s");
}
- Step 2: Run to verify FAIL
Run: ctest --test-dir tests/automation --output-on-failure
Expected: input cases FAIL.
- Step 3: Implement input handlers + a key parser
In JsonRpcDispatcher.cpp, add to the anonymous namespace:
namespace {
MouseButton parse_button(const nlohmann::json& p) {
auto b = opt_str(p, "button");
if (b && *b == "right") return MouseButton::Right;
if (b && *b == "middle") return MouseButton::Middle;
return MouseButton::Left;
}
std::vector<KeyModifier> parse_modifiers(const nlohmann::json& p) {
std::vector<KeyModifier> mods;
if (p.is_object() && p.contains("modifiers") && p.at("modifiers").is_array()) {
for (const auto& m : p.at("modifiers")) {
if (!m.is_string()) continue;
const std::string s = m.get<std::string>();
if (s == "ctrl") mods.push_back(KeyModifier::Ctrl);
else if (s == "shift") mods.push_back(KeyModifier::Shift);
else if (s == "alt") mods.push_back(KeyModifier::Alt);
else if (s == "cmd" || s == "meta") mods.push_back(KeyModifier::Cmd);
}
}
return mods;
}
// Parse one chord token list (already split): the last token is the key, the
// earlier ones are modifiers.
KeyChord chord_from_tokens(const std::vector<std::string>& tokens) {
KeyChord c;
for (size_t i = 0; i < tokens.size(); ++i) {
const std::string& t = tokens[i];
const bool is_mod = (t == "ctrl" || t == "shift" || t == "alt" ||
t == "cmd" || t == "meta");
if (is_mod && i + 1 < tokens.size()) {
if (t == "ctrl") c.modifiers.push_back(KeyModifier::Ctrl);
else if (t == "shift") c.modifiers.push_back(KeyModifier::Shift);
else if (t == "alt") c.modifiers.push_back(KeyModifier::Alt);
else c.modifiers.push_back(KeyModifier::Cmd);
} else {
c.key = t; // last token (or a lone token) is the key
}
}
return c;
}
std::vector<std::string> split(const std::string& s, char delim) {
std::vector<std::string> out;
std::string cur;
for (char ch : s) {
if (ch == delim) { if (!cur.empty()) out.push_back(cur); cur.clear(); }
else cur.push_back(ch);
}
if (!cur.empty()) out.push_back(cur);
return out;
}
// "keys" may be a string ("ctrl+s") or an array (["ctrl","s"]). Returns one chord.
std::vector<KeyChord> parse_keys(const nlohmann::json& params) {
if (!params.is_object() || !params.contains("keys"))
throw AutomationError(kInvalidParams, "input.key requires 'keys'");
const auto& k = params.at("keys");
std::vector<std::string> tokens;
if (k.is_string()) {
tokens = split(k.get<std::string>(), '+');
} else if (k.is_array()) {
for (const auto& e : k)
if (e.is_string()) tokens.push_back(e.get<std::string>());
} else {
throw AutomationError(kInvalidParams, "'keys' must be string or array");
}
if (tokens.empty())
throw AutomationError(kInvalidParams, "'keys' is empty");
return { chord_from_tokens(tokens) };
}
// Resolve a unique, actionable node from params["target"], or throw.
} // namespace
Add a private resolver method. First declare it in JsonRpcDispatcher.hpp (in the private: section):
const UiNode resolve_actionable(const nlohmann::json& params, UiNode& tree_out);
Then implement in JsonRpcDispatcher.cpp:
const UiNode JsonRpcDispatcher::resolve_actionable(const nlohmann::json& params,
UiNode& tree_out) {
if (!params.is_object() || !params.contains("target"))
throw AutomationError(kInvalidParams, "missing 'target'");
m_backend.refresh_ui();
tree_out = m_backend.dump_tree(DumpOptions{});
int count = 0;
const UiNode* node = resolve_unique(tree_out, parse_target(params.at("target")), count);
if (count == 0) throw AutomationError(kErrNotFound, "target not found");
if (count > 1) throw AutomationError(kErrNotFound, "target is ambiguous");
if (!node->enabled || !node->visible)
throw AutomationError(kErrNotActionable, "target is disabled or hidden");
return *node; // copy: stable even though tree_out outlives this call
}
nlohmann::json JsonRpcDispatcher::m_input_click(const nlohmann::json& params) {
UiNode tree;
const UiNode node = resolve_actionable(params, tree);
const bool dbl = params.contains("double") && params.at("double").is_boolean()
&& params.at("double").get<bool>();
const bool ok = m_backend.click(node, parse_button(params), dbl, parse_modifiers(params));
return { {"ok", ok} };
}
nlohmann::json JsonRpcDispatcher::m_input_type(const nlohmann::json& params) {
if (!params.is_object() || !params.contains("text") || !params.at("text").is_string())
throw AutomationError(kInvalidParams, "input.type requires string 'text'");
const std::string text = params.at("text").get<std::string>();
// Optional target: click to focus first.
if (params.contains("target")) {
UiNode tree;
const UiNode node = resolve_actionable(params, tree);
m_backend.click(node, MouseButton::Left, false, {});
}
const bool ok = m_backend.type_text(text);
return { {"ok", ok} };
}
nlohmann::json JsonRpcDispatcher::m_input_key(const nlohmann::json& params) {
const bool ok = m_backend.send_keys(parse_keys(params));
return { {"ok", ok} };
}
- Step 4: Run to verify PASS
Run: ctest --test-dir tests/automation --output-on-failure
Expected: input cases PASS.
- Step 5: Commit
git add src/slic3r/GUI/Automation/JsonRpcDispatcher.hpp \
src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp tests/automation/test_dispatcher.cpp
git commit -m "feat(automation): input.click / input.type / input.key handlers"
Task 9: Dispatcher — app.state, screenshot.window, screenshot.viewport3d
Files:
-
Modify:
src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp -
Modify:
tests/automation/test_dispatcher.cpp -
Step 1: Write failing tests
Append to tests/automation/test_dispatcher.cpp:
#include <catch2/catch_all.hpp> // already included; harmless if duplicated guard
TEST_CASE("app.state returns serialized state", "[automation][rpc]") {
MockUiBackend mock;
mock.state.active_tab = "prepare"; mock.state.project_loaded = true;
JsonRpcDispatcher d(mock);
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",1},{"method","app.state"}});
CHECK(resp.at("result").at("active_tab") == "prepare");
CHECK(resp.at("result").at("project_loaded") == true);
}
TEST_CASE("screenshot.window returns base64 + dims", "[automation][rpc]") {
MockUiBackend mock;
JsonRpcDispatcher d(mock);
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",2},{"method","screenshot.window"}});
CHECK(mock.screenshot_window_count == 1);
CHECK(resp.at("result").at("width") == 4);
CHECK(resp.at("result").at("png_base64").is_string());
CHECK_FALSE(resp.at("result").at("png_base64").get<std::string>().empty());
}
TEST_CASE("screenshot.viewport3d returns base64 + dims", "[automation][rpc]") {
MockUiBackend mock;
JsonRpcDispatcher d(mock);
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",3},{"method","screenshot.viewport3d"},
{"params",{{"width",256},{"height",256}}}});
CHECK(mock.screenshot_viewport_count == 1);
CHECK(resp.at("result").at("png_base64").is_string());
}
- Step 2: Run to verify FAIL
Run: ctest --test-dir tests/automation --output-on-failure
Expected: app.state/screenshot cases FAIL.
- Step 3: Implement handlers + a base64 encoder
In JsonRpcDispatcher.cpp anonymous namespace, add a small base64 encoder (self-contained — avoids a new dependency):
namespace {
std::string base64_encode(const std::vector<unsigned char>& data) {
static const char* tbl =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string out;
out.reserve(((data.size() + 2) / 3) * 4);
size_t i = 0;
for (; i + 2 < data.size(); i += 3) {
const unsigned n = (data[i] << 16) | (data[i+1] << 8) | data[i+2];
out.push_back(tbl[(n >> 18) & 63]);
out.push_back(tbl[(n >> 12) & 63]);
out.push_back(tbl[(n >> 6) & 63]);
out.push_back(tbl[n & 63]);
}
if (i < data.size()) {
unsigned n = data[i] << 16;
const bool two = (i + 1 < data.size());
if (two) n |= data[i+1] << 8;
out.push_back(tbl[(n >> 18) & 63]);
out.push_back(tbl[(n >> 12) & 63]);
out.push_back(two ? tbl[(n >> 6) & 63] : '=');
out.push_back('=');
}
return out;
}
std::optional<int> opt_int(const nlohmann::json& p, const char* key) {
if (p.is_object() && p.contains(key) && p.at(key).is_number_integer())
return p.at(key).get<int>();
return std::nullopt;
}
nlohmann::json image_to_json(const PngImage& img) {
if (img.png.empty())
throw AutomationError(kErrScreenshotFail, "screenshot produced no data");
return { {"png_base64", base64_encode(img.png)},
{"width", img.width}, {"height", img.height} };
}
} // namespace
Replace the stub bodies:
nlohmann::json JsonRpcDispatcher::m_app_state(const nlohmann::json&) {
return app_state_to_json(m_backend.app_state());
}
nlohmann::json JsonRpcDispatcher::m_screenshot_window(const nlohmann::json& params) {
m_backend.refresh_ui();
const UiNode* target_ptr = nullptr;
UiNode resolved;
if (params.is_object() && params.contains("target")) {
UiNode tree = m_backend.dump_tree(DumpOptions{});
int count = 0;
const UiNode* n = resolve_unique(tree, parse_target(params.at("target")), count);
if (count == 0) throw AutomationError(kErrNotFound, "target not found");
if (count > 1) throw AutomationError(kErrNotFound, "target is ambiguous");
resolved = *n;
target_ptr = &resolved;
}
return image_to_json(m_backend.screenshot_window(target_ptr));
}
nlohmann::json JsonRpcDispatcher::m_screenshot_viewport3d(const nlohmann::json& params) {
return image_to_json(m_backend.screenshot_viewport3d(
opt_int(params, "plate"), opt_int(params, "width"), opt_int(params, "height")));
}
- Step 4: Run to verify PASS
Run: ctest --test-dir tests/automation --output-on-failure
Expected: app.state/screenshot cases PASS.
- Step 5: Commit
git add src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp tests/automation/test_dispatcher.cpp
git commit -m "feat(automation): app.state + screenshot handlers with base64"
Task 10: Dispatcher — sync.wait_for (poll loop)
Files:
-
Modify:
src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp -
Modify:
tests/automation/test_dispatcher.cpp -
Step 1: Write failing tests using a scripted tree provider
Append to tests/automation/test_dispatcher.cpp:
TEST_CASE("sync.wait_for succeeds once the condition holds", "[automation][rpc]") {
MockUiBackend mock;
// First 2 polls: btn disabled. 3rd poll: enabled.
mock.tree_provider = [](int call) {
UiNode root; root.klass = "MainFrame"; root.path = "MainFrame";
UiNode b; b.id = "btn_slice"; b.klass = "Button"; b.path = "MainFrame/Button[0]";
b.visible = true; b.enabled = (call >= 2);
root.children = {b};
return root;
};
JsonRpcDispatcher d(mock);
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",1},{"method","sync.wait_for"},
{"params",{{"target",{{"id","btn_slice"}}},{"state","enabled"},
{"timeout_ms",2000},{"poll_ms",1}}}});
CHECK(resp.at("result").at("ok") == true);
CHECK(mock.dump_count >= 3);
}
TEST_CASE("sync.wait_for times out -> 1003", "[automation][rpc]") {
MockUiBackend mock;
mock.tree_provider = [](int) {
UiNode root; root.klass = "MainFrame"; root.path = "MainFrame";
UiNode b; b.id = "btn_slice"; b.visible = true; b.enabled = false;
b.path = "MainFrame/Button[0]";
root.children = {b};
return root;
};
JsonRpcDispatcher d(mock);
const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",2},{"method","sync.wait_for"},
{"params",{{"target",{{"id","btn_slice"}}},{"state","enabled"},
{"timeout_ms",30},{"poll_ms",5}}}});
CHECK(resp.at("error").at("code") == kErrWaitTimeout);
}
- Step 2: Run to verify FAIL
Run: ctest --test-dir tests/automation --output-on-failure
Expected: wait_for cases FAIL.
- Step 3: Implement
m_sync_wait_for
Replace the stub in JsonRpcDispatcher.cpp:
nlohmann::json JsonRpcDispatcher::m_sync_wait_for(const nlohmann::json& params) {
if (!params.is_object() || !params.contains("target") || !params.contains("state"))
throw AutomationError(kInvalidParams, "sync.wait_for requires 'target' and 'state'");
const Target target = parse_target(params.at("target"));
const std::string state_s = params.at("state").get<std::string>();
WaitState state;
if (state_s == "exists") state = WaitState::Exists;
else if (state_s == "visible") state = WaitState::Visible;
else if (state_s == "enabled") state = WaitState::Enabled;
else if (state_s == "value") state = WaitState::Value;
else throw AutomationError(kInvalidParams, "unknown state: " + state_s);
std::optional<std::string> expected = opt_str(params, "value");
const int timeout_ms = params.contains("timeout_ms") && params.at("timeout_ms").is_number_integer()
? params.at("timeout_ms").get<int>() : 5000;
const int poll_ms = params.contains("poll_ms") && params.at("poll_ms").is_number_integer()
? std::max(1, params.at("poll_ms").get<int>()) : 100;
const auto start = std::chrono::steady_clock::now();
for (;;) {
m_backend.refresh_ui();
const UiNode root = m_backend.dump_tree(DumpOptions{});
int count = 0;
const UiNode* node = resolve_unique(root, target, &count == nullptr ? count : count);
// (count is filled by resolve_unique; node is null when not unique)
if (evaluate_state(node, state, expected)) {
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start).count();
return { {"ok", true}, {"elapsed_ms", static_cast<int>(elapsed)} };
}
const auto now = std::chrono::steady_clock::now();
const auto elapsed_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
now - start).count();
if (elapsed_ms >= timeout_ms)
throw AutomationError(kErrWaitTimeout, "wait_for timed out for state: " + state_s);
std::this_thread::sleep_for(std::chrono::milliseconds(poll_ms));
}
}
NOTE: simplify the
resolve_uniquecall toconst UiNode* node = resolve_unique(root, target, count);— the inline ternary above is a no-op artifact; write it cleanly:int count = 0; const UiNode* node = resolve_unique(root, target, count);
- Step 4: Run to verify PASS
Run: ctest --test-dir tests/automation --output-on-failure
Expected: wait_for cases PASS, full automation suite green.
- Step 5: Commit
git add src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp tests/automation/test_dispatcher.cpp
git commit -m "feat(automation): sync.wait_for poll loop"
PHASE 2 — AutomationServer (boost::beast, localhost POST + body)
Task 11: AutomationServer
Files:
- Create:
src/slic3r/GUI/Automation/AutomationServer.hpp - Create:
src/slic3r/GUI/Automation/AutomationServer.cpp
This component owns a boost::asio thread and a beast acceptor; it is verified by the e2e example (
example_slice.py) and by a manualcurlsmoke test (Step 4). Unlike the authHttpServer, it uses beast's request parser so the POST body is available. Modeled onHttpServer(src/slic3r/GUI/HttpServer.cpp:110-171) for the accept/thread lifecycle, but useshttp::read/http::writefor a clean request/response cycle.
- Step 1: Create the header
src/slic3r/GUI/Automation/AutomationServer.hpp:
#pragma once
#include <boost/asio.hpp>
#include <boost/thread.hpp>
#include <atomic>
#include <functional>
#include <memory>
#include <string>
namespace Slic3r { namespace GUI { namespace Automation {
// Localhost-only HTTP/1.1 server. POST /jsonrpc -> handler(body) -> response body.
// GET / -> a tiny health/version page. The handler runs on the server's own
// io thread; it is responsible for any further thread marshaling.
class AutomationServer {
public:
using RequestHandler = std::function<std::string(const std::string& body)>;
explicit AutomationServer(unsigned short port);
~AutomationServer();
void set_handler(RequestHandler handler) { m_handler = std::move(handler); }
void set_health_text(std::string text) { m_health = std::move(text); }
void start(); // binds to 127.0.0.1:port, starts the io thread
void stop(); // stops the io thread, joins
bool is_started() const { return m_started; }
unsigned short port() const { return m_port; }
private:
void do_accept();
void handle_session(boost::asio::ip::tcp::socket socket);
unsigned short m_port;
std::atomic<bool> m_started{false};
RequestHandler m_handler;
std::string m_health{"OrcaSlicer automation server"};
std::unique_ptr<boost::asio::io_context> m_ioc;
std::unique_ptr<boost::asio::ip::tcp::acceptor> m_acceptor;
boost::thread m_thread;
};
}}} // namespace
- Step 2: Implement the server
src/slic3r/GUI/Automation/AutomationServer.cpp:
#include "AutomationServer.hpp"
#include "libslic3r/Utils.hpp" // create_thread / set_current_thread_name
#include <boost/beast/core.hpp>
#include <boost/beast/http.hpp>
#include <boost/beast/version.hpp>
#include <boost/log/trivial.hpp>
namespace beast = boost::beast;
namespace http = beast::http;
namespace net = boost::asio;
using tcp = net::ip::tcp;
namespace Slic3r { namespace GUI { namespace Automation {
AutomationServer::AutomationServer(unsigned short port) : m_port(port) {}
AutomationServer::~AutomationServer() { stop(); }
void AutomationServer::start() {
if (m_started) return;
m_ioc = std::make_unique<net::io_context>(1);
// Bind to loopback ONLY.
tcp::endpoint endpoint(net::ip::make_address("127.0.0.1"), m_port);
m_acceptor = std::make_unique<tcp::acceptor>(*m_ioc);
m_acceptor->open(endpoint.protocol());
m_acceptor->set_option(net::socket_base::reuse_address(true));
m_acceptor->bind(endpoint);
m_acceptor->listen(net::socket_base::max_listen_connections);
m_started = true;
do_accept();
net::io_context* ioc = m_ioc.get();
m_thread = create_thread([ioc] {
set_current_thread_name("orca_automation");
ioc->run();
});
BOOST_LOG_TRIVIAL(info) << "AutomationServer listening on 127.0.0.1:" << m_port;
}
void AutomationServer::stop() {
if (!m_started) return;
m_started = false;
if (m_ioc) m_ioc->stop();
if (m_thread.joinable()) m_thread.join();
m_acceptor.reset();
m_ioc.reset();
}
void AutomationServer::do_accept() {
m_acceptor->async_accept([this](beast::error_code ec, tcp::socket socket) {
if (!ec) {
// v1: single-client, serialized — handle synchronously on the io thread.
handle_session(std::move(socket));
}
if (m_started && m_acceptor && m_acceptor->is_open())
do_accept();
});
}
void AutomationServer::handle_session(tcp::socket socket) {
beast::error_code ec;
beast::flat_buffer buffer;
http::request<http::string_body> req;
http::read(socket, buffer, req, ec);
if (ec) { socket.shutdown(tcp::socket::shutdown_send, ec); return; }
http::response<http::string_body> res;
res.version(req.version());
res.keep_alive(false);
if (req.method() == http::verb::post && req.target() == "/jsonrpc") {
std::string body_out;
try {
body_out = m_handler ? m_handler(req.body())
: R"({"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"no handler"}})";
} catch (const std::exception& e) {
body_out = std::string(R"({"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":")")
+ e.what() + R"("}})";
}
res.result(http::status::ok);
res.set(http::field::content_type, "application/json");
res.body() = std::move(body_out);
} else if (req.method() == http::verb::get && req.target() == "/") {
res.result(http::status::ok);
res.set(http::field::content_type, "text/plain");
res.body() = m_health;
} else {
res.result(http::status::not_found);
res.set(http::field::content_type, "text/plain");
res.body() = "not found";
}
res.set(http::field::server, "OrcaSlicer/automation");
res.prepare_payload();
http::write(socket, res, ec);
socket.shutdown(tcp::socket::shutdown_send, ec);
}
}}} // namespace
- Step 3: Wire into the GUI build (production target)
In src/slic3r/CMakeLists.txt, locate the SLIC3R_GUI_SOURCES list (the GUI source list around the Gizmos/Jobs entries, parent file confirmed at src/slic3r/CMakeLists.txt). Add these lines alongside the other GUI/... entries:
GUI/Automation/IUiBackend.hpp
GUI/Automation/WidgetSerializer.cpp
GUI/Automation/WidgetSerializer.hpp
GUI/Automation/Locator.cpp
GUI/Automation/Locator.hpp
GUI/Automation/JsonRpcDispatcher.cpp
GUI/Automation/JsonRpcDispatcher.hpp
GUI/Automation/AutomationServer.cpp
GUI/Automation/AutomationServer.hpp
(The remaining WxUiBackend, AutomationRegistry, ImGuiItemTable sources are added in Tasks 12–16.)
- Step 4: Build the GUI target and manually smoke-test the server
Build OrcaSlicer (or just the GUI lib) — it should compile and link with the new server. The server is not yet started by the app (that comes in Task 17), so verify compilation only here:
cmake --build . --config RelWithDebInfo --target OrcaSlicer -- -m
Expected: clean build (no link errors). Full server smoke test (curl) happens after Task 17.
- Step 5: Commit
git add src/slic3r/GUI/Automation/AutomationServer.hpp \
src/slic3r/GUI/Automation/AutomationServer.cpp src/slic3r/CMakeLists.txt
git commit -m "feat(automation): localhost beast POST /jsonrpc server"
PHASE 3 — GUI backend, ImGui recording, lifecycle (manual verification; needs display)
Tasks 12–19 touch live wx/ImGui/GL code and cannot run in the display-free CI unit tests. Each is verified by building and by the manual e2e in Task 22. Keep all new behavior behind
wxGetApp().is_automation_enabled()so a disabled build is unchanged (verified in Task 24).
Task 12: AutomationRegistry
Files:
-
Create:
src/slic3r/GUI/Automation/AutomationRegistry.hpp -
Create:
src/slic3r/GUI/Automation/AutomationRegistry.cpp -
Modify:
src/slic3r/CMakeLists.txt(add the two files toSLIC3R_GUI_SOURCES) -
Step 1: Create the header
src/slic3r/GUI/Automation/AutomationRegistry.hpp:
#pragma once
#include <cstdint>
#include <string>
class wxWindow;
namespace Slic3r { namespace GUI { namespace Automation {
// Process-wide wxWindow* <-> automation_id side map. Header is dependency-light so
// widget-construction code can call set_automation_id() unconditionally — it is a
// cheap, safe registration that no-ops when the window is null.
//
// Registration is pruned automatically when the window is destroyed (bound to
// wxEVT_DESTROY inside set_automation_id).
void set_automation_id(wxWindow* window, const std::string& id);
std::string automation_id_of(const wxWindow* window); // "" if none
wxWindow* window_for_automation_id(const std::string& id); // nullptr if none
}}} // namespace
- Step 2: Implement the registry
src/slic3r/GUI/Automation/AutomationRegistry.cpp:
#include "AutomationRegistry.hpp"
#include <wx/window.h>
#include <wx/event.h>
#include <mutex>
#include <unordered_map>
namespace Slic3r { namespace GUI { namespace Automation {
namespace {
std::mutex& mtx() { static std::mutex m; return m; }
std::unordered_map<const wxWindow*, std::string>& fwd() {
static std::unordered_map<const wxWindow*, std::string> m; return m;
}
std::unordered_map<std::string, wxWindow*>& rev() {
static std::unordered_map<std::string, wxWindow*> m; return m;
}
void erase_window(const wxWindow* w) {
std::lock_guard<std::mutex> lk(mtx());
auto it = fwd().find(w);
if (it != fwd().end()) { rev().erase(it->second); fwd().erase(it); }
}
} // namespace
void set_automation_id(wxWindow* window, const std::string& id) {
if (window == nullptr || id.empty()) return;
{
std::lock_guard<std::mutex> lk(mtx());
fwd()[window] = id;
rev()[id] = window;
}
// Prune on destruction.
window->Bind(wxEVT_DESTROY, [window](wxWindowDestroyEvent& e) {
erase_window(window);
e.Skip();
});
}
std::string automation_id_of(const wxWindow* window) {
std::lock_guard<std::mutex> lk(mtx());
auto it = fwd().find(window);
return it == fwd().end() ? std::string() : it->second;
}
wxWindow* window_for_automation_id(const std::string& id) {
std::lock_guard<std::mutex> lk(mtx());
auto it = rev().find(id);
return it == rev().end() ? nullptr : it->second;
}
}}} // namespace
- Step 3: Add to the GUI build
In src/slic3r/CMakeLists.txt, add to SLIC3R_GUI_SOURCES:
GUI/Automation/AutomationRegistry.cpp
GUI/Automation/AutomationRegistry.hpp
- Step 4: Build to verify it compiles/links
Run: cmake --build . --config RelWithDebInfo --target OrcaSlicer -- -m
Expected: clean build.
- Step 5: Commit
git add src/slic3r/GUI/Automation/AutomationRegistry.hpp \
src/slic3r/GUI/Automation/AutomationRegistry.cpp src/slic3r/CMakeLists.txt
git commit -m "feat(automation): wxWindow automation-id registry"
Task 13: ImGuiItemTable (double-buffered per-frame recorder)
Files:
-
Create:
src/slic3r/GUI/Automation/ImGuiItemTable.hpp -
Create:
src/slic3r/GUI/Automation/ImGuiItemTable.cpp -
Modify:
src/slic3r/CMakeLists.txt -
Step 1: Create the header
src/slic3r/GUI/Automation/ImGuiItemTable.hpp:
#pragma once
#include <mutex>
#include <string>
#include <vector>
namespace Slic3r { namespace GUI { namespace Automation {
// One recorded ImGui item. Rect is in ImGui display coords; WxUiBackend maps it
// to screen coords using the canvas client origin + DPI scale.
struct ImGuiItemRecord {
std::string window_name;
std::string label; // visible label / id
std::string type; // "button", "checkbox", "combo", "slider", "input", ...
float x = 0, y = 0, w = 0, h = 0;
bool enabled = true;
bool has_value = false;
std::string value;
};
// A complete recorded frame: items + window-level info.
struct ImGuiWindowRecord {
std::string name;
float x = 0, y = 0, w = 0, h = 0;
bool visible = true;
};
struct ImGuiFrameRecord {
std::vector<ImGuiItemRecord> items;
std::vector<ImGuiWindowRecord> windows;
};
// Double-buffered recorder. The drawing code appends to the "back" frame; render()
// swaps it to "front" at frame end. Readers (GUI thread, after marshaling) read the
// front frame. All access is on the GUI thread, but we guard with a mutex anyway
// because the automation read may happen between frames.
class ImGuiItemTable {
public:
static ImGuiItemTable& instance();
// Called from ImGuiWrapper drawing hooks (GUI thread). No-op cheap append.
void record_item(ImGuiItemRecord rec);
void record_window(ImGuiWindowRecord rec);
// Called at frame end (ImGuiWrapper::render). Promotes back -> front, clears back.
void swap_frame();
// Snapshot the latest complete frame for the backend to read.
ImGuiFrameRecord snapshot() const;
private:
mutable std::mutex m_mutex;
ImGuiFrameRecord m_back; // accumulating
ImGuiFrameRecord m_front; // last complete
};
}}} // namespace
- Step 2: Implement it
src/slic3r/GUI/Automation/ImGuiItemTable.cpp:
#include "ImGuiItemTable.hpp"
namespace Slic3r { namespace GUI { namespace Automation {
ImGuiItemTable& ImGuiItemTable::instance() {
static ImGuiItemTable t;
return t;
}
void ImGuiItemTable::record_item(ImGuiItemRecord rec) {
std::lock_guard<std::mutex> lk(m_mutex);
m_back.items.push_back(std::move(rec));
}
void ImGuiItemTable::record_window(ImGuiWindowRecord rec) {
std::lock_guard<std::mutex> lk(m_mutex);
m_back.windows.push_back(std::move(rec));
}
void ImGuiItemTable::swap_frame() {
std::lock_guard<std::mutex> lk(m_mutex);
m_front = std::move(m_back);
m_back = ImGuiFrameRecord{};
}
ImGuiFrameRecord ImGuiItemTable::snapshot() const {
std::lock_guard<std::mutex> lk(m_mutex);
return m_front;
}
}}} // namespace
- Step 3: Add to the GUI build
In src/slic3r/CMakeLists.txt, add:
GUI/Automation/ImGuiItemTable.cpp
GUI/Automation/ImGuiItemTable.hpp
- Step 4: Build to verify
Run: cmake --build . --config RelWithDebInfo --target OrcaSlicer -- -m
Expected: clean build.
- Step 5: Commit
git add src/slic3r/GUI/Automation/ImGuiItemTable.hpp \
src/slic3r/GUI/Automation/ImGuiItemTable.cpp src/slic3r/CMakeLists.txt
git commit -m "feat(automation): double-buffered ImGui item table"
Task 14: ImGuiWrapper recording hooks (guarded)
Files:
- Modify:
src/slic3r/GUI/ImGuiWrapper.hpp(declare a private helper) - Modify:
src/slic3r/GUI/ImGuiWrapper.cpp(hooks in wrapped methods +render())
All hooks are guarded by
wxGetApp().is_automation_enabled()(added in Task 17). When automation is off, the guard short-circuits to a single bool check — no allocation, no behavior change (spec §10, verified in Task 24). Insertion points are the exact post-widget locations confirmed in the codebase:button(ImGuiWrapper.cpp:872),checkbox(:1003),combo(:1326),slider_float(:1149),render()(:573-578).
- Step 1: Declare the recording helper in the header
In src/slic3r/GUI/ImGuiWrapper.hpp, inside the private: section of class ImGuiWrapper (near the other private members around line 389), add:
// Automation recording: appends the most-recently-drawn ImGui item to the
// automation item table. No-op (single bool check) when automation is disabled.
void automation_record_last_item(const char* type, const std::string& label,
bool has_value, const std::string& value);
- Step 2: Implement the helper in the .cpp
At the top of src/slic3r/GUI/ImGuiWrapper.cpp, add includes near the existing ones (after the imgui_internal.h include at line 26):
#include "slic3r/GUI/Automation/ImGuiItemTable.hpp"
#include "slic3r/GUI/GUI_App.hpp"
Then add the helper implementation (place it next to render()):
void ImGuiWrapper::automation_record_last_item(const char* type, const std::string& label,
bool has_value, const std::string& value) {
if (!wxGetApp().is_automation_enabled())
return;
using namespace Slic3r::GUI::Automation;
const ImVec2 mn = ImGui::GetItemRectMin();
const ImVec2 mx = ImGui::GetItemRectMax();
ImGuiItemRecord rec;
ImGuiContext* ctx = ImGui::GetCurrentContext();
rec.window_name = (ctx && ctx->CurrentWindow) ? ctx->CurrentWindow->Name : "";
rec.label = label;
rec.type = type;
rec.x = mn.x; rec.y = mn.y; rec.w = mx.x - mn.x; rec.h = mx.y - mn.y;
rec.enabled = !ImGui::GetCurrentContext()->CurrentItemFlags & ImGuiItemFlags_Disabled
? true : true; // keep simple: enabled unless explicitly disabled
rec.has_value = has_value;
rec.value = value;
ImGuiItemTable::instance().record_item(std::move(rec));
}
NOTE on
enabled: a precise disabled-state read is non-trivial across ImGui versions. v1 recordsenabled = truefor recorded items; refine later if needed. Replace the awkward line above with simplyrec.enabled = true;.
- Step 3: Add hooks to the wrapped methods
button — after const bool ret = ImGui::Button(label_utf8.c_str()); (~line 872):
automation_record_last_item("button", label_utf8, false, {});
bbl_button — after the ImGui::BBLButton(...) call (~line 885), mirror the same:
automation_record_last_item("button", label_utf8, false, {});
checkbox — change the body (~lines 1000-1004) to capture the result before returning:
bool ImGuiWrapper::checkbox(const wxString &label, bool &value)
{
auto label_utf8 = into_u8(label);
const bool ret = ImGui::Checkbox(label_utf8.c_str(), &value);
automation_record_last_item("checkbox", label_utf8, true, value ? "true" : "false");
return ret;
}
bbl_checkbox — mirror the same pattern (capture ret, record "checkbox", then return).
combo (~line 1326, before return res;):
{
const std::string cur = (selection >= 0 && selection < (int)options.size())
? options[selection] : std::string();
automation_record_last_item("combo", label, true, cur);
}
return res;
slider_float (~after line 1149, after the m_last_slider_status block):
{
char buf[64];
std::snprintf(buf, sizeof(buf), format ? format : "%.3f", v ? *v : 0.f);
automation_record_last_item("slider", str_label, true, buf);
}
input_double (~line 972, after the ImGui::InputDouble(...) call): add
automation_record_last_item("input", into_u8(label), true, into_u8(value_str));
(Use whatever local string already holds the formatted value; if none, format value with format.)
radio_button (~line 963, after ImGui::RadioButton(...)):
automation_record_last_item("radio", into_u8(label), true, active ? "true" : "false");
menu_item_with_icon — this is a free function (line 47/~1749), not a member; skip the member helper here. Record only if trivially feasible; otherwise leave for future per-item work (documented limitation). For v1, skip instrumenting menu_item_with_icon (window-level coverage applies).
- Step 4: Add window enumeration + frame swap in
render()
Modify render() (src/slic3r/GUI/ImGuiWrapper.cpp:573-578):
void ImGuiWrapper::render()
{
ImGui::Render();
render_draw_data(ImGui::GetDrawData());
if (wxGetApp().is_automation_enabled()) {
using namespace Slic3r::GUI::Automation;
ImGuiContext& g = *ImGui::GetCurrentContext();
for (ImGuiWindow* w : g.Windows) {
if (w == nullptr) continue;
ImGuiWindowRecord wr;
wr.name = w->Name ? w->Name : "";
wr.x = w->Pos.x; wr.y = w->Pos.y; wr.w = w->Size.x; wr.h = w->Size.y;
wr.visible = w->Active && !w->Hidden;
ImGuiItemTable::instance().record_window(std::move(wr));
}
ImGuiItemTable::instance().swap_frame();
}
m_new_frame_open = false;
}
- Step 5: Build and verify
Run: cmake --build . --config RelWithDebInfo --target OrcaSlicer -- -m
Expected: clean build. (Behavioral verification happens in Task 22; disabled-overhead check in Task 24.)
- Step 6: Commit
git add src/slic3r/GUI/ImGuiWrapper.hpp src/slic3r/GUI/ImGuiWrapper.cpp
git commit -m "feat(automation): guarded ImGui item/window recording hooks"
Task 15: WxUiBackend — GUI-thread marshaller, dump_tree, app_state
Files:
-
Create:
src/slic3r/GUI/Automation/WxUiBackend.hpp -
Create:
src/slic3r/GUI/Automation/WxUiBackend.cpp -
Modify:
src/slic3r/CMakeLists.txt -
Step 1: Create the header
src/slic3r/GUI/Automation/WxUiBackend.hpp:
#pragma once
#include "IUiBackend.hpp"
namespace Slic3r { namespace GUI { namespace Automation {
// Real backend. Every public method marshals its work onto the GUI thread via
// wxGetApp().CallAfter + a std::future with a per-call timeout (error 1004 on
// timeout). Walks the wxWindow tree, reads the ImGui item table, drives
// wxUIActionSimulator, captures screenshots.
class WxUiBackend : public IUiBackend {
public:
explicit WxUiBackend(int gui_timeout_ms = 5000) : m_gui_timeout_ms(gui_timeout_ms) {}
void refresh_ui() override;
UiNode dump_tree(const DumpOptions& opts) override;
AppState app_state() override;
bool click(const UiNode& node, MouseButton button, bool dbl,
const std::vector<KeyModifier>& modifiers) override;
bool type_text(const std::string& text) override;
bool send_keys(const std::vector<KeyChord>& chords) override;
PngImage screenshot_window(const UiNode* target) override;
PngImage screenshot_viewport3d(std::optional<int> plate, std::optional<int> width,
std::optional<int> height) override;
private:
int m_gui_timeout_ms;
};
}}} // namespace
- Step 2: Implement the marshaller + dump_tree + app_state
src/slic3r/GUI/Automation/WxUiBackend.cpp:
#include "WxUiBackend.hpp"
#include "AutomationRegistry.hpp"
#include "ImGuiItemTable.hpp"
#include "slic3r/GUI/GUI_App.hpp"
#include "slic3r/GUI/MainFrame.hpp"
#include "slic3r/GUI/Plater.hpp"
#include <wx/window.h>
#include <wx/toplevel.h>
#include <chrono>
#include <future>
#include <memory>
namespace Slic3r { namespace GUI { namespace Automation {
// Run `fn` on the GUI thread, block until it returns or the timeout elapses.
// Throws AutomationError(1004) on timeout. std::promise is move-only, so we hold
// it via shared_ptr to satisfy CallAfter's copyable-functor requirement.
template <class Fn>
static auto run_on_gui(int timeout_ms, Fn&& fn) -> decltype(fn()) {
using R = decltype(fn());
auto prom = std::make_shared<std::promise<R>>();
auto fut = prom->get_future();
wxGetApp().CallAfter([prom, fn = std::forward<Fn>(fn)]() mutable {
try {
if constexpr (std::is_void_v<R>) { fn(); prom->set_value(); }
else { prom->set_value(fn()); }
} catch (...) { prom->set_exception(std::current_exception()); }
});
if (fut.wait_for(std::chrono::milliseconds(timeout_ms)) != std::future_status::ready)
throw AutomationError(kErrGuiBusy_placeholder, "GUI thread timed out");
return fut.get();
}
// --- wx tree walking (runs on GUI thread) ---
namespace {
std::string wx_class_name(const wxWindow* w) {
const wxClassInfo* ci = w->GetClassInfo();
std::string name = ci ? std::string(ci->GetClassName().ToUTF8()) : "wxWindow";
// Strip the "wx" prefix for friendlier class names ("wxButton" -> "Button").
if (name.rfind("wx", 0) == 0 && name.size() > 2) name = name.substr(2);
return name;
}
std::string wx_value_of(wxWindow* w, bool& has_value) {
has_value = false;
if (auto* tc = dynamic_cast<wxTextEntry*>(w)) { has_value = true; return std::string(tc->GetValue().ToUTF8()); }
if (auto* ch = dynamic_cast<wxChoice*>(w)) { has_value = true; return std::string(ch->GetStringSelection().ToUTF8()); }
if (auto* cb = dynamic_cast<wxCheckBox*>(w)) { has_value = true; return cb->GetValue() ? "true" : "false"; }
return {};
}
void build_node(wxWindow* w, UiNode& node, const std::string& parent_path,
int sibling_index, const DumpOptions& opts, int depth) {
node.backend = BackendKind::Wx;
node.klass = wx_class_name(w);
node.id = automation_id_of(w);
node.path = parent_path.empty()
? node.klass
: parent_path + "/" + node.klass + "[" + std::to_string(sibling_index) + "]";
node.label = std::string(w->GetLabel().ToUTF8());
node.enabled = w->IsEnabled();
node.visible = w->IsShownOnScreen();
node.value = wx_value_of(w, node.has_value);
node.handle = reinterpret_cast<std::uint64_t>(w);
const wxRect r = w->GetScreenRect();
node.rect = { r.x, r.y, r.width, r.height };
if (opts.max_depth >= 0 && depth >= opts.max_depth) return;
int idx = 0;
for (wxWindow* child : w->GetChildren()) {
if (opts.visible_only && !child->IsShownOnScreen()) { ++idx; continue; }
UiNode cn;
build_node(child, cn, node.path, idx, opts, depth + 1);
node.children.push_back(std::move(cn));
++idx;
}
}
// Map the recorded ImGui items (display coords) to screen coords using the 3D
// canvas client origin, then append them under the tree root as flat children.
void append_imgui_nodes(UiNode& root) {
Plater* plater = wxGetApp().plater();
if (plater == nullptr) return;
wxWindow* canvas = plater->canvas3D_widget(); // see NOTE below
if (canvas == nullptr) return;
const wxPoint origin = canvas->ClientToScreen(wxPoint(0, 0));
const double scale = canvas->GetContentScaleFactor();
const auto frame = ImGuiItemTable::instance().snapshot();
for (const auto& it : frame.items) {
UiNode n;
n.backend = BackendKind::ImGui;
n.klass = it.type;
n.label = it.label;
n.path = "ImGui/" + it.window_name + "/" + it.label;
n.id = n.path; // imgui items use their path as id in v1
n.enabled = it.enabled;
n.visible = true;
n.has_value = it.has_value;
n.value = it.value;
n.rect = { origin.x + int(it.x / scale), origin.y + int(it.y / scale),
int(it.w / scale), int(it.h / scale) };
root.children.push_back(std::move(n));
}
}
} // namespace
void WxUiBackend::refresh_ui() {
run_on_gui(m_gui_timeout_ms, [] {
// Force a fresh ImGui frame so transient items are recorded, then flush
// pending events so the latest frame is the one we read.
if (Plater* p = wxGetApp().plater())
p->get_current_canvas3D()->set_as_dirty();
if (Plater* p = wxGetApp().plater())
p->get_current_canvas3D()->render();
wxGetApp().Yield();
});
}
UiNode WxUiBackend::dump_tree(const DumpOptions& opts) {
return run_on_gui(m_gui_timeout_ms, [&opts]() -> UiNode {
wxWindow* root_win = nullptr;
if (opts.root) {
root_win = window_for_automation_id(*opts.root);
}
if (root_win == nullptr)
root_win = static_cast<wxWindow*>(wxGetApp().mainframe);
UiNode root;
if (root_win) build_node(root_win, root, {}, 0, opts, 0);
if (opts.include_imgui) append_imgui_nodes(root);
return root;
});
}
AppState WxUiBackend::app_state() {
return run_on_gui(m_gui_timeout_ms, []() -> AppState {
AppState s;
MainFrame* mf = wxGetApp().mainframe;
Plater* p = wxGetApp().plater();
if (mf) {
// active_tab: map the current top tab to a stable name (see MainFrame
// tab enum tp3DEditor/tpPreview/tpMonitor). Use a best-effort string.
s.active_tab = std::string(mf->get_title().ToUTF8()); // refine during integration
s.foreground = mf->IsActive();
}
if (p) {
s.project_loaded = !p->model().objects.empty();
s.slicing = p->is_background_process_running(); // refine accessor name
}
// modal_dialog: a top-level modal window other than the main frame.
if (wxWindow* top = wxGetActiveWindow())
if (auto* tlw = dynamic_cast<wxTopLevelWindow*>(top))
if (tlw != static_cast<wxWindow*>(mf) && tlw->IsModal())
s.modal_dialog = std::string(tlw->GetTitle().ToUTF8());
return s;
});
}
// click/type/keys/screenshots implemented in Task 16.
}}} // namespace
NOTE — integration TODOs to confirm while building (these are GUI glue, not logic):
kErrGuiBusy_placeholder→ use the real constantkErrGuiBusy(1004) fromJsonRpcDispatcher.hpp; include that header and replace the placeholder. (Kept distinct here only to flag the include.)plater->canvas3D_widget()/get_current_canvas3D()— confirm the exact accessor names onPlater(they exist; pick the correct one during build). The 3D canvas is aGLCanvas3Dwrapper; you need itswxWindow*forClientToScreen.p->is_background_process_running()andmodel()— confirm the exactPlatermethod names; substitute the correct slicing-status accessor.mf->get_title()foractive_tabis a placeholder — prefer mapping the selected main-tab index to"prepare"/"preview"/"device"if a getter exists. These do not affect the pure CI tests; they are exercised by the Task 22 e2e.
- Step 3: Add to the GUI build
In src/slic3r/CMakeLists.txt, add:
GUI/Automation/WxUiBackend.cpp
GUI/Automation/WxUiBackend.hpp
- Step 4: Build to verify (resolve the TODO accessor names now)
Run: cmake --build . --config RelWithDebInfo --target OrcaSlicer -- -m
Expected: clean build after replacing the placeholder constant and confirming the Plater accessor names.
- Step 5: Commit
git add src/slic3r/GUI/Automation/WxUiBackend.hpp \
src/slic3r/GUI/Automation/WxUiBackend.cpp src/slic3r/CMakeLists.txt
git commit -m "feat(automation): WxUiBackend marshaller + dump_tree + app_state"
Task 16: WxUiBackend — actions + screenshots
Files:
-
Modify:
src/slic3r/GUI/Automation/WxUiBackend.cpp -
Step 1: Add includes for input + image conversion
At the top of WxUiBackend.cpp, add:
#include <wx/uiaction.h>
#include <wx/dcclient.h>
#include <wx/dcmemory.h>
#include <wx/mstream.h>
#include "slic3r/GUI/GLCanvas3D.hpp"
#include "libslic3r/GCode/ThumbnailData.hpp"
- Step 2: Implement click / type_text / send_keys
Append to WxUiBackend.cpp (inside the namespace, before the closing braces). Add a key-mapping helper in the anonymous namespace:
namespace {
long wx_keycode(const std::string& key) {
if (key.size() == 1) return (long)std::toupper((unsigned char)key[0]);
if (key == "enter" || key == "return") return WXK_RETURN;
if (key == "tab") return WXK_TAB;
if (key == "esc" || key == "escape") return WXK_ESCAPE;
if (key == "space") return WXK_SPACE;
if (key == "delete") return WXK_DELETE;
if (key == "backspace") return WXK_BACK;
if (key.size() >= 2 && (key[0]=='f' || key[0]=='F')) {
int n = std::atoi(key.c_str() + 1);
if (n >= 1 && n <= 12) return WXK_F1 + (n - 1);
}
return 0;
}
void apply_modifiers_down(wxUIActionSimulator& sim,
const std::vector<KeyModifier>& mods, bool down) {
for (KeyModifier m : mods) {
long code = (m == KeyModifier::Ctrl) ? WXK_CONTROL :
(m == KeyModifier::Shift) ? WXK_SHIFT :
(m == KeyModifier::Alt) ? WXK_ALT : WXK_CONTROL; // Cmd~Ctrl
if (down) sim.KeyDown(code); else sim.KeyUp(code);
}
}
} // namespace
bool WxUiBackend::click(const UiNode& node, MouseButton button, bool dbl,
const std::vector<KeyModifier>& modifiers) {
return run_on_gui(m_gui_timeout_ms, [&]() -> bool {
// Raise/focus the owning top-level window so OS input lands on it.
if (auto* w = reinterpret_cast<wxWindow*>(node.handle)) {
if (wxWindow* tlw = wxGetTopLevelParent(w)) tlw->Raise();
w->SetFocus();
}
const int cx = node.rect.x + node.rect.w / 2;
const int cy = node.rect.y + node.rect.h / 2;
wxUIActionSimulator sim;
sim.MouseMove(cx, cy);
apply_modifiers_down(sim, modifiers, true);
const wxMouseButton b = (button == MouseButton::Right) ? wxMOUSE_BTN_RIGHT :
(button == MouseButton::Middle) ? wxMOUSE_BTN_MIDDLE :
wxMOUSE_BTN_LEFT;
if (dbl) sim.MouseDblClick(b); else sim.MouseClick(b);
apply_modifiers_down(sim, modifiers, false);
return true;
});
}
bool WxUiBackend::type_text(const std::string& text) {
return run_on_gui(m_gui_timeout_ms, [&]() -> bool {
wxUIActionSimulator sim;
sim.Text(wxString::FromUTF8(text.c_str()));
return true;
});
}
bool WxUiBackend::send_keys(const std::vector<KeyChord>& chords) {
return run_on_gui(m_gui_timeout_ms, [&]() -> bool {
wxUIActionSimulator sim;
for (const KeyChord& c : chords) {
const long code = wx_keycode(c.key);
if (code == 0) continue;
apply_modifiers_down(sim, c.modifiers, true);
sim.Char(code);
apply_modifiers_down(sim, c.modifiers, false);
}
return true;
});
}
- Step 3: Implement screenshots
Add a shared wxImage → PNG bytes helper in the anonymous namespace:
namespace {
PngImage wximage_to_png(const wxImage& image) {
wxMemoryOutputStream mem;
if (!image.SaveFile(mem, wxBITMAP_TYPE_PNG))
throw AutomationError(kErrScreenshotFail, "PNG encode failed");
PngImage out;
out.width = image.GetWidth();
out.height = image.GetHeight();
const size_t n = mem.GetSize();
out.png.resize(n);
mem.CopyTo(out.png.data(), n);
return out;
}
// RGBA ThumbnailData -> wxImage (mirrors GLCanvas3D::debug_output_thumbnail,
// GLCanvas3D.cpp:6099 — note the vertical flip).
wxImage thumbnail_to_wximage(const ThumbnailData& td) {
wxImage image((int)td.width, (int)td.height);
image.InitAlpha();
for (unsigned int r = 0; r < td.height; ++r) {
unsigned int rr = (td.height - 1 - r) * td.width;
for (unsigned int c = 0; c < td.width; ++c) {
const unsigned char* px = td.pixels.data() + 4 * (rr + c);
image.SetRGB((int)c, (int)r, px[0], px[1], px[2]);
image.SetAlpha((int)c, (int)r, px[3]);
}
}
return image;
}
} // namespace
PngImage WxUiBackend::screenshot_window(const UiNode* target) {
return run_on_gui(m_gui_timeout_ms, [&]() -> PngImage {
wxWindow* win = target ? reinterpret_cast<wxWindow*>(target->handle)
: static_cast<wxWindow*>(wxGetApp().mainframe);
if (win == nullptr)
throw AutomationError(kErrScreenshotFail, "no window to capture");
const wxSize sz = win->GetClientSize();
if (sz.x <= 0 || sz.y <= 0)
throw AutomationError(kErrScreenshotFail, "window has no client area");
wxBitmap bmp(sz.x, sz.y);
wxClientDC dc(win);
wxMemoryDC mdc(bmp);
mdc.Blit(0, 0, sz.x, sz.y, &dc, 0, 0);
mdc.SelectObject(wxNullBitmap);
return wximage_to_png(bmp.ConvertToImage());
});
}
PngImage WxUiBackend::screenshot_viewport3d(std::optional<int> plate,
std::optional<int> width,
std::optional<int> height) {
return run_on_gui(m_gui_timeout_ms, [&]() -> PngImage {
Plater* p = wxGetApp().plater();
if (p == nullptr)
throw AutomationError(kErrScreenshotFail, "no plater");
const unsigned int w = width ? (unsigned)*width : 800;
const unsigned int h = height ? (unsigned)*height : 600;
ThumbnailData data;
// Use Plater's thumbnail wrapper which calls GLCanvas3D::render_thumbnail
// with the GL context current (Plater.cpp:10605). Confirm exact signature
// during build; pass the requested size and default camera/params.
p->generate_thumbnail(data, w, h, /*params*/ {}, Camera::EType::Perspective);
(void)plate; // v1: active plate only; `plate` reserved for future use
if (!data.is_valid())
throw AutomationError(kErrScreenshotFail, "thumbnail render failed");
return wximage_to_png(thumbnail_to_wximage(data));
});
}
NOTE — confirm during build:
Plater::generate_thumbnail(...)public signature/availability (the wrapper exists perPlater.cpp:10605; the public entry may bePlater::generate_thumbnail— wire to whatever is public, or callget_current_canvas3D()->render_thumbnail(...)directly with a constructedThumbnailsParams).Camera::ETypeenum value name.wxUIActionSimulator::Textexists in the wx build; if not, fall back to per-charactersim.Char(...).
- Step 4: Build to verify
Run: cmake --build . --config RelWithDebInfo --target OrcaSlicer -- -m
Expected: clean build after confirming the noted accessor/enum names.
- Step 5: Commit
git add src/slic3r/GUI/Automation/WxUiBackend.cpp
git commit -m "feat(automation): WxUiBackend input + window/viewport screenshots"
Task 17: GUI_App lifecycle + is_automation_enabled()
Files:
-
Modify:
src/slic3r/GUI/GUI_App.hpp -
Modify:
src/slic3r/GUI/GUI_App.cpp -
Step 1: Add members + accessor to GUI_App.hpp
In src/slic3r/GUI/GUI_App.hpp, near the existing HttpServer m_http_server; member (line 334) add forward-declared members:
// --- UI automation (opt-in; off unless --automation-server) ---
std::unique_ptr<Slic3r::GUI::Automation::AutomationServer> m_automation_server;
std::unique_ptr<Slic3r::GUI::Automation::WxUiBackend> m_automation_backend;
std::unique_ptr<Slic3r::GUI::Automation::JsonRpcDispatcher> m_automation_dispatcher;
int m_automation_port{0};
Add forward declarations near the top of the file (with the other forward decls):
namespace Slic3r { namespace GUI { namespace Automation {
class AutomationServer; class WxUiBackend; class JsonRpcDispatcher;
}}}
Add the public accessor + lifecycle methods (near is_editor() etc.):
bool is_automation_enabled() const { return m_automation_port > 0; }
void start_automation_server();
void stop_automation_server();
- Step 2: Implement lifecycle in GUI_App.cpp
Add includes at the top of src/slic3r/GUI/GUI_App.cpp:
#include "slic3r/GUI/Automation/AutomationServer.hpp"
#include "slic3r/GUI/Automation/WxUiBackend.hpp"
#include "slic3r/GUI/Automation/JsonRpcDispatcher.hpp"
Implement the methods (anywhere in the file, e.g. next to start_http_server, ~line 7048):
void GUI_App::start_automation_server() {
if (m_automation_port <= 0) return; // disabled
if (m_automation_server) return; // already running
using namespace Slic3r::GUI::Automation;
m_automation_backend.reset(new WxUiBackend());
m_automation_dispatcher.reset(new JsonRpcDispatcher(*m_automation_backend));
m_automation_server.reset(new AutomationServer((unsigned short)m_automation_port));
JsonRpcDispatcher* disp = m_automation_dispatcher.get();
m_automation_server->set_handler(
[disp](const std::string& body) { return disp->handle_request(body); });
m_automation_server->set_health_text(
std::string("OrcaSlicer automation server v") + kAutomationVersion);
m_automation_server->start();
BOOST_LOG_TRIVIAL(warning)
<< "UI automation server ENABLED on 127.0.0.1:" << m_automation_port
<< " (input injection is active)";
}
void GUI_App::stop_automation_server() {
if (m_automation_server) m_automation_server->stop();
m_automation_server.reset();
m_automation_dispatcher.reset();
m_automation_backend.reset();
}
- Step 3: Set the port from init params + start in
post_init()
In GUI_App::post_init() (starts ~line 727), near the top after the assert(initialized()) block, add:
if (init_params != nullptr && init_params->automation_port > 0) {
m_automation_port = init_params->automation_port;
start_automation_server();
}
- Step 4: Stop in
OnExit()
In GUI_App::OnExit() (line 2464), right after stop_http_server(); (line 2466), add:
stop_automation_server();
- Step 5: Build to verify
Run: cmake --build . --config RelWithDebInfo --target OrcaSlicer -- -m
Expected: clean build.
- Step 6: Commit
git add src/slic3r/GUI/GUI_App.hpp src/slic3r/GUI/GUI_App.cpp
git commit -m "feat(automation): GUI_App owns automation server lifecycle (opt-in)"
Task 18: CLI flag plumbing
Files:
-
Modify:
src/libslic3r/PrintConfig.cpp(register options inCLIMiscConfigDef) -
Modify:
src/slic3r/GUI/GUI_Init.hpp(addautomation_portfield) -
Modify:
src/OrcaSlicer.cpp(read options, populate params) -
Step 1: Register the CLI options
In src/libslic3r/PrintConfig.cpp, inside CLIMiscConfigDef::CLIMiscConfigDef() (the constructor near line 10675, following the existing def = this->add(...) pattern), add:
def = this->add("automation_server", coBool);
def->label = L("Enable UI automation server");
def->tooltip = L("Start a localhost JSON-RPC server that lets external scripts "
"drive and observe the GUI. For testing/automation only.");
def->set_default_value(new ConfigOptionBool(false));
def = this->add("automation_server_port", coInt);
def->label = L("UI automation server port");
def->tooltip = L("TCP port for the UI automation server (bound to 127.0.0.1).");
def->min = 1;
def->cli_params = "port";
def->set_default_value(new ConfigOptionInt(13619));
- Step 2: Add the field to GUI_InitParams
In src/slic3r/GUI/GUI_Init.hpp (struct at lines 16-35), add after bool input_gcode { false };:
// UI automation: 0 = disabled, else the TCP port for the localhost JSON-RPC server.
int automation_port { 0 };
- Step 3: Populate it in OrcaSlicer.cpp
In src/OrcaSlicer.cpp, in the block that fills GUI_InitParams params; (lines ~1315-1343), before return Slic3r::GUI::GUI_Run(params);, add:
// UI automation server (opt-in). --automation-server enables it;
// --automation-server-port overrides the default 13619.
if (m_config.has("automation_server") && m_config.opt_bool("automation_server")) {
int port = m_config.has("automation_server_port")
? m_config.opt_int("automation_server_port") : 13619;
params.automation_port = port > 0 ? port : 13619;
BOOST_LOG_TRIVIAL(warning)
<< "UI automation server requested on port " << params.automation_port;
}
- Step 4: Build to verify the flag plumbs through
Run: cmake --build . --config RelWithDebInfo --target OrcaSlicer -- -m
Expected: clean build. The CLI now accepts --automation-server and --automation-server-port=PORT.
- Step 5: Manual smoke test (server reachable)
Launch:
OrcaSlicer --automation-server --automation-server-port=13619
From another shell:
curl -s http://127.0.0.1:13619/
curl -s -X POST http://127.0.0.1:13619/jsonrpc \
-H "Content-Type: application/json" \
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"automation.version\"}"
Expected: health text from GET /; and a JSON-RPC result with version/protocol/capabilities from the POST. Confirm that launching WITHOUT the flag leaves nothing listening on 13619.
- Step 6: Commit
git add src/libslic3r/PrintConfig.cpp src/slic3r/GUI/GUI_Init.hpp src/OrcaSlicer.cpp
git commit -m "feat(automation): --automation-server CLI flag plumbed into GUI"
Task 19: Widget instrumentation (~15-20 stable IDs)
Files:
- Modify: widget-construction sites listed below (each adds one
set_automation_id(...)call + the registry include).
Goal: give an external script stable, named targets for the most-used controls (spec §7). Each site adds
#include "slic3r/GUI/Automation/AutomationRegistry.hpp"(once per file) and aSlic3r::GUI::Automation::set_automation_id(widget, "id");after the widget is created. This is a safe no-op when automation is off (the registry just stores a pointer). Use the agreed IDs below.
- Step 1: Locate the construction sites
For each control, find where it is constructed (use Grep for the button/combo labels or member names). Suggested ID set (document these in doc/automation.md, Task 23):
| Widget | Automation ID | Likely file |
|---|---|---|
| Slice-plate button | btn_slice |
src/slic3r/GUI/MainFrame.cpp / Plater.cpp |
| Export-G-code button | btn_export |
Plater.cpp |
| Printer preset combo | combo_printer |
Plater.cpp (sidebar) |
| Filament preset combo | combo_filament |
Plater.cpp (sidebar) |
| Process/print preset combo | combo_process |
Plater.cpp (sidebar) |
| Prepare/3D-editor tab | tab_prepare |
MainFrame.cpp |
| Preview tab | tab_preview |
MainFrame.cpp |
| Device/Monitor tab | tab_device |
MainFrame.cpp |
| Add/Import object button | btn_add |
Plater.cpp |
| 3D canvas | canvas_3d |
Plater.cpp (GLCanvas3D widget) |
- Step 2: Add the calls
Example (Slice button — adapt to the actual variable name found):
#include "slic3r/GUI/Automation/AutomationRegistry.hpp"
// ... after the button is created, e.g.:
// m_slice_btn = new Button(parent, _L("Slice plate"));
Slic3r::GUI::Automation::set_automation_id(m_slice_btn, "btn_slice");
Repeat for each row above. For the 3D canvas, register the wxGLCanvas-derived widget returned by the plater's canvas accessor:
Slic3r::GUI::Automation::set_automation_id(view3D_canvas_widget, "canvas_3d");
- Step 3: Add common dialog OK/Cancel IDs (if a shared dialog base exists)
If OrcaSlicer has a common dialog base/factory for OK/Cancel/Yes/No, register them there once (dlg_ok, dlg_cancel, dlg_yes, dlg_no). Otherwise, instrument the two or three most-used dialogs. Document whichever you choose.
- Step 4: Build to verify
Run: cmake --build . --config RelWithDebInfo --target OrcaSlicer -- -m
Expected: clean build.
- Step 5: Verify IDs resolve at runtime
Launch with --automation-server, then:
curl -s -X POST http://127.0.0.1:13619/jsonrpc -H "Content-Type: application/json" \
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"widget.get\",\"params\":{\"target\":{\"id\":\"btn_slice\"}}}"
Expected: a node with "id":"btn_slice" and a sensible screen rect.
- Step 6: Commit
git add -A
git commit -m "feat(automation): instrument core widgets with stable automation ids"
PHASE 4 — Client, docs, regression
Task 20: Python reference client
Files:
-
Create:
tools/automation/orca_automation.py -
Step 1: Write the client
tools/automation/orca_automation.py:
"""Reference client for the OrcaSlicer UI automation JSON-RPC server.
Usage:
from orca_automation import OrcaClient
orca = OrcaClient(port=13619)
print(orca.version())
orca.click({"id": "btn_slice"})
orca.wait_for({"id": "btn_export"}, state="enabled", timeout_ms=120000)
png = orca.screenshot_3d(width=1024, height=768)
open("preview.png", "wb").write(png)
"""
from __future__ import annotations
import base64
import json
import urllib.request
from typing import Any, Optional
class OrcaError(RuntimeError):
def __init__(self, code: int, message: str):
super().__init__(f"[{code}] {message}")
self.code = code
self.message = message
class OrcaClient:
def __init__(self, host: str = "127.0.0.1", port: int = 13619, timeout: float = 30.0):
self._url = f"http://{host}:{port}/jsonrpc"
self._timeout = timeout
self._id = 0
def _call(self, method: str, params: Optional[dict] = None) -> Any:
self._id += 1
payload = {"jsonrpc": "2.0", "id": self._id, "method": method}
if params is not None:
payload["params"] = params
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
self._url, data=data, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
body = json.loads(resp.read().decode("utf-8"))
if "error" in body:
err = body["error"]
raise OrcaError(err.get("code", -1), err.get("message", "unknown error"))
return body.get("result")
# --- protocol methods ---
def version(self) -> dict:
return self._call("automation.version")
def dump_tree(self, root: Optional[str] = None, max_depth: Optional[int] = None,
visible_only: bool = False, include_imgui: bool = True) -> dict:
params: dict = {"visible_only": visible_only, "include_imgui": include_imgui}
if root is not None:
params["root"] = root
if max_depth is not None:
params["max_depth"] = max_depth
return self._call("tree.dump", params)
def find(self, **predicate) -> list:
# predicate keys: name, class, label, value, backend
return self._call("tree.find", predicate)
def get(self, target: dict) -> dict:
return self._call("widget.get", {"target": target})
def click(self, target: dict, button: str = "left",
double: bool = False, modifiers: Optional[list] = None) -> dict:
params = {"target": target, "button": button, "double": double}
if modifiers:
params["modifiers"] = modifiers
return self._call("input.click", params)
def type(self, text: str, target: Optional[dict] = None) -> dict:
params: dict = {"text": text}
if target is not None:
params["target"] = target
return self._call("input.type", params)
def key(self, keys) -> dict:
# keys: "ctrl+s" or ["ctrl", "s"]
return self._call("input.key", {"keys": keys})
def wait_for(self, target: dict, state: str = "visible",
value: Optional[str] = None, timeout_ms: int = 5000,
poll_ms: int = 100) -> dict:
params = {"target": target, "state": state,
"timeout_ms": timeout_ms, "poll_ms": poll_ms}
if value is not None:
params["value"] = value
return self._call("sync.wait_for", params)
def app_state(self) -> dict:
return self._call("app.state")
def screenshot(self, target: Optional[dict] = None) -> bytes:
params = {"target": target} if target is not None else None
result = self._call("screenshot.window", params)
return base64.b64decode(result["png_base64"])
def screenshot_3d(self, plate: Optional[int] = None,
width: Optional[int] = None, height: Optional[int] = None) -> bytes:
params: dict = {}
if plate is not None:
params["plate"] = plate
if width is not None:
params["width"] = width
if height is not None:
params["height"] = height
result = self._call("screenshot.viewport3d", params or None)
return base64.b64decode(result["png_base64"])
- Step 2: Commit
git add tools/automation/orca_automation.py
git commit -m "feat(automation): Python reference client"
Task 21: End-to-end example / smoke test
Files:
-
Create:
tools/automation/example_slice.py -
Step 1: Write the example
tools/automation/example_slice.py:
"""End-to-end smoke test: launch OrcaSlicer with the automation server, load a
model, slice it, wait for completion, and save both a window PNG and a 3D PNG.
Run:
python example_slice.py --orca /path/to/OrcaSlicer --model /path/to/cube.stl
On Linux CI, wrap with a virtual display, e.g.:
xvfb-run -a python example_slice.py --orca ./OrcaSlicer --model cube.stl
"""
from __future__ import annotations
import argparse
import subprocess
import sys
import time
from orca_automation import OrcaClient, OrcaError
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--orca", required=True, help="path to the OrcaSlicer executable")
ap.add_argument("--model", required=True, help="path to an STL/3MF to load")
ap.add_argument("--port", type=int, default=13619)
args = ap.parse_args()
proc = subprocess.Popen([
args.orca,
"--automation-server",
f"--automation-server-port={args.port}",
args.model,
])
try:
orca = OrcaClient(port=args.port)
# Wait for the server to come up.
for _ in range(60):
try:
print("connected:", orca.version())
break
except OSError:
time.sleep(0.5)
else:
print("ERROR: automation server did not start", file=sys.stderr)
return 1
# Wait until the project (model) is loaded.
deadline = time.time() + 30
while time.time() < deadline:
if orca.app_state().get("project_loaded"):
break
time.sleep(0.5)
# Click Slice and wait for the Export button to become enabled
# (slicing complete) — wait_for replaces fragile fixed sleeps.
orca.click({"id": "btn_slice"})
orca.wait_for({"id": "btn_export"}, state="enabled", timeout_ms=180000,
poll_ms=500)
with open("window.png", "wb") as f:
f.write(orca.screenshot())
with open("preview_3d.png", "wb") as f:
f.write(orca.screenshot_3d(width=1024, height=768))
print("wrote window.png and preview_3d.png")
return 0
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
if __name__ == "__main__":
raise SystemExit(main())
- Step 2: Run the e2e (manual; needs a display)
Run:
cd tools/automation
python example_slice.py --orca <built OrcaSlicer path> --model <some .stl>
Expected: connects, reports version, loads the model, slices, wait_for returns when Export enables, and window.png + preview_3d.png are written and visually correct.
- Step 3: Commit
git add tools/automation/example_slice.py
git commit -m "feat(automation): runnable e2e slice example / smoke test"
Task 22: Protocol documentation
Files:
-
Create:
doc/automation.md -
Step 1: Write the docs
doc/automation.md must contain (write complete prose + tables — no stubs):
-
Overview & activation:
--automation-server [--automation-server-port=PORT], default port 13619, localhost-only, off by default, security note (no token in v1; the localhost bind is the boundary). -
Transport: HTTP/1.1,
POST /jsonrpc(JSON-RPC 2.0 body),GET /health page. -
Methods table: reproduce the spec §5 method table (params + results) for all ten methods +
automation.version. -
Node shape: the unified JSON node (the spec §5 block:
backend,id,path,class,label,rect,enabled,visible,value,children). -
Error codes: standard (
-32700/-32600/-32601/-32602) + application (1001–1006) with meanings. -
Automation-id naming conventions: the table from Task 19 (
btn_slice,combo_printer,tab_preview,canvas_3d, …) + guidance (btn_/combo_/tab_/dlg_/canvas_prefixes). -
ImGui notes: items addressable only while drawn;
refresh_uiis forced before reads/actions; usesync.wait_forto wait for a gizmo panel item to appear; raw-ImGui::gizmos (Emboss/SVG/Text) are window-level only in v1. -
Platform / display caveats: OS input injection needs a focused, visible window; Linux CI needs a display (Xvfb); input is async — rely on
sync.wait_for, not fixed sleeps; single-client/serialized in v1. -
Quick start: a 10-line
orca_automation.pysnippet (connect → version → click → wait_for → screenshot_3d). -
Future work: auth token + Preferences toggle, WebSocket push events, per-item ImGui gizmo instrumentation, MCP wrapper.
-
Step 2: Commit
git add doc/automation.md
git commit -m "docs(automation): protocol reference, ids, and platform caveats"
Task 23: Regression verification (automation OFF)
Files: none (verification task).
- Step 1: Run the full unit-test suite
Run:
cd build && ctest --output-on-failure
Expected: all suites pass, including automation_tests.
- Step 2: Build a normal (no-flag) run and confirm zero footprint
Launch OrcaSlicer without --automation-server. Confirm:
-
No listener on 13619:
curl -s http://127.0.0.1:13619/fails to connect. -
No
orca_automationthread is created (verify in a debugger / process explorer, or add a one-off log line during testing then remove it). -
wxGetApp().is_automation_enabled()returns false (the ImGui hooks short-circuit). -
Step 3: Confirm ImGui hot path is unchanged when disabled
Inspect the ImGui hook sites: each begins with if (!wxGetApp().is_automation_enabled()) return; (or is wrapped in that guard). Confirm there is no allocation or ImGuiItemTable access on the disabled path. Build in RelWithDebInfo and do a quick interactive sanity pass (open a gizmo, move sliders) to confirm no visual/behavior change.
- Step 4: Cross-platform build check
Ensure the new code compiles on all three platforms (per the project's cross-platform constraint). At minimum, confirm the Windows build is clean; note in the PR that macOS/Linux builds must be validated by CI/maintainers. Watch for: wxUIActionSimulator availability, wxImage PNG handler registration (wxInitAllImageHandlers/PNG handler must be present — it is, since OrcaSlicer already loads PNGs), and beast/asio includes.
- Step 5: Final commit (if any doc/notes added)
git add -A
git commit -m "test(automation): regression checks for disabled-path no-op"
Self-Review (performed against the spec)
Spec coverage check (spec §§1-16):
- §4 components → Tasks 1,3,6,11,12,13,15,16 (all new files) + §4 touch points → Tasks 14,17,18,19,20(CMake within tasks). ✔
- §5 transport/protocol/methods/node-shape/errors → Tasks 6-11 (dispatcher) + Task 11 (server) + Task 22 (docs). All ten methods + version covered. ✔
- §6 threading (CallAfter + future + 5s timeout → 1004) → Task 15
run_on_gui. ✔ - §7 locator & IDs → Tasks 3,4 (resolution) + Task 12 (registry) + Task 19 (instrumentation). ✔
- §8 ImGui coverage (recording, window enum, double-buffer swap, freshness, actions) → Tasks 13,14,15 (
append_imgui_nodes,refresh_ui). ✔ - §9 screenshots (window DC + render_thumbnail) → Task 16. ✔
- §10 activation/security (off by default, localhost, zero overhead disabled) → Tasks 17,18,23. ✔
- §11 testability (pure dispatcher/serializer/locator, MockUiBackend, CI no-display) → Tasks 1-10. ✔
- §12 deliverables (C++ components, python client, example, doc, tests) → Tasks 1-22. ✔
- §13 file inventory → matches File Structure section (plus added
Locator.{hpp,cpp}— justified in the Architecture note). ✔ - §16 verification plan (CI units, e2e, regression) → Tasks 1-10 (CI), 21 (e2e), 23 (regression). ✔
Deliberate deviations from the spec (documented inline):
IUiBackendshape refined for testability (orchestration in the pure dispatcher; backend exposes snapshot + rect-based primitives). External JSON-RPC protocol is unchanged. (Architecture note.)- Added
Locator.{hpp,cpp}(not in spec inventory) to make resolution pure & unit-testable — satisfies spec §11. (Architecture note.) - CLI flag is two options (
--automation-server+--automation-server-port) instead of--automation-server[=PORT], to fit OrcaSlicer'sDynamicConfigCLI. Same capability. (File Structure → CLI flag mapping.)
Placeholder scan: The only TODO-style notes are explicitly-flagged GUI-glue accessor names in Tasks 15/16 (Plater slicing-status/canvas accessors, Camera::EType value, Plater::generate_thumbnail signature). These are integration lookups to confirm at build time on real wx headers, not logic gaps; the pure CI-tested core has complete code. Each is called out with the verified anchor (Plater.cpp:10605, GLCanvas3D.cpp:6099) so the engineer can resolve them immediately. Action for the executor: resolve each flagged accessor name during the relevant task's build step before committing.
Type consistency: UiNode, Target, WaitState, DumpOptions, KeyChord, AppState, PngImage, AutomationError, error-code constants, and method handler names are defined once (Task 1 / Task 3 / Task 6) and used consistently across Tasks 7-17. node_to_json/app_state_to_json/find_matches/resolve_unique/evaluate_state signatures match between header, tests, and call sites.