Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions docs/rpc-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,49 @@ b64 = s.takeScreenshotAsBase64("mainWindow")
image_data = base64.b64decode(b64)
```

### Introspection

| Method | Signature | Description |
|--------|-----------|-------------|
| `dumpTree` | `dumpTree(path) -> string` | Return the item tree under `path` as a compact JSON string |

The JSON tree recursively describes all items. Each node contains:

| Field | Type | Description |
|-------|------|-------------|
| `type` | string | Simplified type name (e.g. `"Rectangle"`) |
| `objectName` | string | Object name (`objectName` property) |
| `className` | string | Full C++ class name |
| `bounds` | object | `x`, `y`, `width`, `height` in screen coordinates (QQuickItem only) |
| `visible` | bool | Whether the item is visible (QQuickItem only) |
| `opacity` | number | Opacity 0.0–1.0 (QQuickItem only) |
| `clip` | bool | Whether clipping is enabled (QQuickItem only) |
| `enabled` | bool | Whether the item is enabled (QQuickItem only) |
| `properties` | object | All readable scalar QMetaObject properties as strings |
| `children` | array | Recursively nested child nodes |

Pass a window-level path (length 1) to dump the entire window content tree, or a deeper path to start from a specific item.

```python
import json

# Dump the full window tree
tree = json.loads(s.dumpTree("mainWindow"))

# Dump a subtree
subtree = json.loads(s.dumpTree("mainWindow/panel"))

# Walk the tree
def print_tree(node, indent=0):
print(" " * indent + node["name"] + " (" + node["type"] + ")")
for child in node.get("children", []):
print_tree(child, indent + 2)

print_tree(tree)
```

Returns `"{}"` if the path cannot be resolved.

### Error Handling

| Method | Signature | Description |
Expand Down
2 changes: 2 additions & 0 deletions libs/Core/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ set(SOURCES
src/Commands/Command.cpp
src/Commands/CustomCmd.cpp
src/Commands/CustomCmd.h
src/Commands/DumpTree.cpp
src/Commands/DumpTree.h
src/Commands/DragBegin.cpp
src/Commands/DragBegin.h
src/Commands/DragEnd.cpp
Expand Down
3 changes: 3 additions & 0 deletions libs/Core/include/Spix/Scene/Scene.h
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ class Scene {
// Tasks
virtual void takeScreenshot(const ItemPath& targetItem, const std::string& filePath) = 0;
virtual std::string takeScreenshotAsBase64(const ItemPath& targetItem) = 0;

// Introspection
virtual std::string dumpTree(const ItemPath& rootPath) = 0;
};

} // namespace spix
1 change: 1 addition & 0 deletions libs/Core/include/Spix/TestServer.h
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ class SPIXCORE_EXPORT TestServer {

void takeScreenshot(ItemPath targetItem, std::string filePath);
std::string takeScreenshotAsBase64(ItemPath targetItem);
std::string dumpTree(ItemPath rootPath);
void quit();

protected:
Expand Down
4 changes: 4 additions & 0 deletions libs/Core/src/AnyRpcServer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ AnyRpcServer::AnyRpcServer(int anyrpcPort)
"Take a screenshot of the object and send as base64 string | takeScreenshotAsBase64(string pathToTargetedItem)",
[this](std::string targetItem) { return takeScreenshotAsBase64(std::move(targetItem)); });

utils::AddFunctionToAnyRpc<std::string(std::string)>(methodManager, "dumpTree",
"Return a JSON tree of all items under the given path | dumpTree(string rootPath) : string json_tree",
[this](std::string rootPath) { return dumpTree(std::move(rootPath)); });

utils::AddFunctionToAnyRpc<void()>(methodManager, "quit", "Close the app | quit()", [this] { quit(); });

utils::AddFunctionToAnyRpc<void(std::string, std::string)>(methodManager, "command",
Expand Down
21 changes: 21 additions & 0 deletions libs/Core/src/Commands/DumpTree.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#include "DumpTree.h"

#include <Spix/Scene/Scene.h>

namespace spix {
namespace cmd {

DumpTree::DumpTree(ItemPath rootPath, std::promise<std::string> promise)
: m_rootPath(std::move(rootPath))
, m_promise(std::move(promise))
{
}

void DumpTree::execute(CommandEnvironment& env)
{
auto result = env.scene().dumpTree(m_rootPath);
m_promise.set_value(std::move(result));
}

} // namespace cmd
} // namespace spix
25 changes: 25 additions & 0 deletions libs/Core/src/Commands/DumpTree.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#pragma once

#include <Spix/spix_core_export.h>

#include <Spix/Commands/Command.h>
#include <Spix/Data/ItemPath.h>

#include <future>

namespace spix {
namespace cmd {

class SPIXCORE_EXPORT DumpTree : public Command {
public:
DumpTree(ItemPath rootPath, std::promise<std::string> promise);

void execute(CommandEnvironment& env) override;

private:
ItemPath m_rootPath;
std::promise<std::string> m_promise;
};

} // namespace cmd
} // namespace spix
5 changes: 5 additions & 0 deletions libs/Core/src/Scene/Mock/MockScene.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ std::string MockScene::takeScreenshotAsBase64(const ItemPath&)
return "Base64 String";
}

std::string MockScene::dumpTree(const ItemPath&)
{
return "{}";
}

void MockScene::addItemAtPath(MockItem item, const ItemPath& path)
{
m_items.emplace(std::make_pair(path.string(), std::move(item)));
Expand Down
4 changes: 4 additions & 0 deletions libs/Core/src/Scene/Mock/MockScene.h
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ class SPIXCORE_EXPORT MockScene : public Scene {
// Tasks
void takeScreenshot(const ItemPath& targetItem, const std::string& filePath) override;
std::string takeScreenshotAsBase64(const ItemPath& targetItem) override;

// Introspection
std::string dumpTree(const ItemPath& rootPath) override;

// Mock stuff
void addItemAtPath(MockItem item, const ItemPath& path);
MockEvents& mockEvents();
Expand Down
11 changes: 11 additions & 0 deletions libs/Core/src/TestServer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
#include <Commands/DragBegin.h>
#include <Commands/DragEnd.h>
#include <Commands/DropFromExt.h>
#include <Commands/DumpTree.h>
#include <Commands/EnterKey.h>
#include <Commands/ExistsAndVisible.h>
#include <Commands/GetBoundingBox.h>
Expand Down Expand Up @@ -199,6 +200,16 @@ std::string TestServer::takeScreenshotAsBase64(ItemPath targetItem)
return result.get();
}

std::string TestServer::dumpTree(ItemPath rootPath)
{
std::promise<std::string> promise;
auto result = promise.get_future();
auto cmd = std::make_unique<cmd::DumpTree>(rootPath, std::move(promise));
m_cmdExec->enqueueCommand(std::move(cmd));

return result.get();
}

void TestServer::quit()
{
m_cmdExec->enqueueCommand<cmd::Quit>();
Expand Down
1 change: 1 addition & 0 deletions libs/Core/tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ set(CORE_TEST_SOURCES
CommandExecuter/ExecuterState_test.cpp
Commands/ClickOnItem_test.cpp
Commands/DropFromExt_test.cpp
Commands/DumpTree_test.cpp
Commands/GetProperty_test.cpp
Data/ItemPathComponent_test.cpp
Data/ItemPath_test.cpp
Expand Down
35 changes: 35 additions & 0 deletions libs/Core/tests/Commands/DumpTree_test.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#include <gtest/gtest.h>

#include <Commands/DumpTree.h>
#include <Scene/Mock/MockScene.h>
#include <Spix/CommandExecuter/CommandExecuter.h>

TEST(DumpTreeCommandTest, ReturnsSceneResult)
{
std::promise<std::string> promise;
auto result = promise.get_future();
auto command = std::make_unique<spix::cmd::DumpTree>("window", std::move(promise));

spix::MockScene scene;
spix::CommandExecuter exec;
exec.enqueueCommand(std::move(command));
exec.processCommands(scene);

// MockScene::dumpTree returns "{}"
EXPECT_EQ(result.get(), "{}");
}

TEST(DumpTreeCommandTest, NoErrorsOnExecution)
{
std::promise<std::string> promise;
auto result = promise.get_future();
auto command = std::make_unique<spix::cmd::DumpTree>("window/item", std::move(promise));

spix::MockScene scene;
spix::CommandExecuter exec;
exec.enqueueCommand(std::move(command));
exec.processCommands(scene);

result.get(); // consume the future
EXPECT_FALSE(exec.state().hasErrors());
}
93 changes: 93 additions & 0 deletions libs/Scenes/QtQuick/src/QtScene.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,77 @@
#include <QBuffer>
#include <QByteArray>
#include <QGuiApplication>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QMetaProperty>
#include <QObject>
#include <QQuickItem>
#include <QQuickWindow>

namespace {

QJsonObject serializeItem(QObject* object)
{
QJsonObject node;

node["type"] = spix::qt::TypeStringForObject(object);
node["objectName"] = spix::qt::GetObjectName(object);
node["className"] = QString(object->metaObject()->className());

auto quickItem = qobject_cast<QQuickItem*>(object);
if (quickItem) {
auto globalPos = quickItem->mapToGlobal(QPointF(0, 0));
QJsonObject bounds;
bounds["x"] = globalPos.x();
bounds["y"] = globalPos.y();
bounds["width"] = quickItem->width();
bounds["height"] = quickItem->height();
node["bounds"] = bounds;

node["visible"] = quickItem->isVisible();
node["opacity"] = quickItem->opacity();
node["clip"] = quickItem->clip();
node["enabled"] = quickItem->isEnabled();
}

// Serialize QMetaObject properties
QJsonObject properties;
const QMetaObject* meta = object->metaObject();
for (int i = 0; i < meta->propertyCount(); ++i) {
QMetaProperty prop = meta->property(i);

// Skip non-readable or object-pointer properties
if (!prop.isReadable()) {
continue;
}

int propType = prop.userType();
// Skip QObject* and derived pointer types
if (QMetaType(propType).flags() & QMetaType::PointerToQObject) {
continue;
}

QVariant value = prop.read(object);
if (value.canConvert<QString>()) {
properties[prop.name()] = value.toString();
}
}
node["properties"] = properties;

// Recurse into children
QJsonArray children;
spix::qt::ForEachChild(object, [&](QObject* child) -> bool {
children.append(serializeItem(child));
return true;
});
node["children"] = children;

return node;
}

} // anonymous namespace

namespace spix {

std::unique_ptr<Item> QtScene::itemAtPath(const ItemPath& path)
Expand Down Expand Up @@ -95,4 +162,30 @@ std::string QtScene::takeScreenshotAsBase64(const ItemPath& targetItem)
return byteArray.toBase64().toStdString();
}

std::string QtScene::dumpTree(const ItemPath& rootPath)
{
QQuickItem* rootItem = nullptr;

if (rootPath.length() == 0) {
return "{}";
}

if (rootPath.length() == 1) {
auto window = qt::GetQQuickWindowAtPath(rootPath);
if (window) {
rootItem = window->contentItem();
}
} else {
rootItem = qt::GetQQuickItemAtPath(rootPath);
}

if (!rootItem) {
return "{}";
}

QJsonObject tree = serializeItem(rootItem);
QJsonDocument doc(tree);
return doc.toJson(QJsonDocument::Compact).toStdString();
}

} // namespace spix
3 changes: 3 additions & 0 deletions libs/Scenes/QtQuick/src/QtScene.h
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ class QtScene : public Scene {
void takeScreenshot(const ItemPath& targetItem, const std::string& filePath) override;
std::string takeScreenshotAsBase64(const ItemPath& targetItem) override;

// Introspection
std::string dumpTree(const ItemPath& rootPath) override;

private:
QtEvents m_events;
};
Expand Down
Loading
Loading