This guide documents the current Makepad testing approach using the Studio Remote Protocol, based on the latest dev branch and AGENTS.md. Key discoveries: - Makepad does NOT have a makepad-test crate - Testing is done via Studio Remote bridge (JSON protocol) - Uses script_mod! syntax (not live_design!) - Supports screenshots, widget queries, clicks, typing, code search Includes: - Complete protocol reference - Python and Bash test scripts - Current Makepad syntax (2026) - Testing best practices - Example test cases for map widget This supersedes the previous MAKEPAD_TESTING_BEST_PRACTICES.md which was based on older Makepad patterns.
19 KiB
19 KiB
Makepad Testing Guide - Studio Remote Protocol (2026)
Executive Summary
Makepad uses the Studio Remote Protocol for automated UI testing. This is a JSON-based protocol that allows external tools to:
- Launch and control Makepad applications
- Take screenshots
- Query widget trees
- Simulate user interactions (clicks, typing, scrolling)
- Inspect application state
Key Discovery: Makepad does NOT have a makepad-test crate. Instead, testing is done through the Studio Remote bridge.
Studio Remote Protocol Overview
Architecture
┌─────────────────┐
│ Test Script │ (Python, Bash, Rust, etc.)
│ (JSON client) │
└────────┬────────┘
│ stdin/stdout (JSON lines)
▼
┌─────────────────┐
│ Studio Remote │ target/release/cargo-makepad studio --studio=127.0.0.1:8001
│ Bridge │
└────────┬────────┘
│ WebSocket
▼
┌─────────────────┐
│ Makepad Studio │ (Running on desktop)
│ (Desktop) │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Makepad App │ (Your application)
│ (Under Test) │
└─────────────────┘
Starting the Studio Remote Bridge
# Start the bridge (one-time setup)
target/release/cargo-makepad studio --studio=127.0.0.1:8001
# Or use port 8002 if 8001 is occupied
target/release/cargo-makepad studio --studio=127.0.0.1:8002
The bridge communicates via newline-delimited JSON on stdin/stdout.
Testing Workflow
1. List Available Builds
{"ListBuilds":[]}
Response:
{"Builds":[{"build_id":[6],"name":"makepad-example-map","status":"running"}]}
2. Clear Old Build (if exists)
{"ClearBuild":{"build_id":[6]}}
This stops the running build and clears Studio UI tabs.
3. Launch Application
{"RunItem":{"mount":"makepad","name":"makepad-example-map"}}
Wait for responses:
{"BuildStarted":{"build_id":[7]}}
{"AppStarted":{"build_id":[7]}}
4. Take Screenshot
{"Screenshot":{"build_id":[7],"kind_id":0}}
Response:
{"Screenshot":{"build_id":[7],"path":"/tmp/screenshot_7.png","width":1280,"height":840}}
5. Query Widget Tree
{"WidgetTreeDump":{"build_id":[7]}}
Response includes widget hierarchy with coordinates for clicking.
6. Query Specific Widget
{"WidgetQuery":{"build_id":[7],"query":"id:map_view"}}
7. Simulate Click
{"Click":{"build_id":[7],"x":640,"y":420}}
8. Type Text
{"TypeText":{"build_id":[7],"text":"Hello World"}}
9. Press Enter
{"Return":{"build_id":[7],"auto_dump":false}}
10. Search Code
{"FindInFiles":{"mount":"makepad","pattern":"MapView","is_regex":false,"glob":"**/*.rs","max_results":200}}
Response:
{"SearchFileResults":{"results":[{"path":"widgets/src/map_view.rs","line":42,"column":5,"line_text":"pub struct MapView {...}"}],"done":true}}
11. Read Code Range
{"ReadTextRange":{"path":"makepad/widgets/src/map_view.rs","start_line":40,"end_line":100}}
Response:
{"TextFileRange":{"path":"makepad/widgets/src/map_view.rs","start_line":40,"end_line":100,"total_lines":250,"content":"..."}}
Complete Testing Example
Python Test Script
#!/usr/bin/env python3
"""
Makepad Map Widget Test Suite
Uses Studio Remote Protocol for automated UI testing
"""
import json
import subprocess
import sys
import time
from pathlib import Path
class MakepadTestRunner:
def __init__(self, studio_port=8001):
self.studio_port = studio_port
self.process = None
self.build_id = None
def start_bridge(self):
"""Start the Studio Remote bridge"""
print(f"Starting Studio Remote bridge on port {self.studio_port}...")
self.process = subprocess.Popen(
["target/release/cargo-makepad", "studio", f"--studio=127.0.0.1:{self.studio_port}"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
time.sleep(2) # Wait for bridge to start
def send_request(self, request):
"""Send JSON request and read response"""
json_str = json.dumps(request) + "\n"
self.process.stdin.write(json_str)
self.process.stdin.flush()
# Read response (may be multiple lines)
response = self.process.stdout.readline()
return json.loads(response)
def list_builds(self):
"""List all running builds"""
return self.send_request({"ListBuilds": []})
def clear_build(self, build_id):
"""Clear a build"""
return self.send_request({"ClearBuild": {"build_id": [build_id]}})
def run_item(self, mount, name):
"""Run a Makepad application"""
return self.send_request({"RunItem": {"mount": mount, "name": name}})
def screenshot(self, build_id, kind_id=0):
"""Take a screenshot"""
return self.send_request({"Screenshot": {"build_id": [build_id], "kind_id": kind_id}})
def widget_tree_dump(self, build_id):
"""Dump widget tree"""
return self.send_request({"WidgetTreeDump": {"build_id": [build_id]}})
def widget_query(self, build_id, query):
"""Query a specific widget"""
return self.send_request({"WidgetQuery": {"build_id": [build_id], "query": query}})
def click(self, build_id, x, y):
"""Simulate a click"""
return self.send_request({"Click": {"build_id": [build_id], "x": x, "y": y}})
def type_text(self, build_id, text):
"""Type text"""
return self.send_request({"TypeText": {"build_id": [build_id], "text": text}})
def press_return(self, build_id, auto_dump=False):
"""Press Enter key"""
return self.send_request({"Return": {"build_id": [build_id], "auto_dump": auto_dump}})
def find_in_files(self, mount, pattern, is_regex=False, glob=None, max_results=200):
"""Search for pattern in files"""
request = {
"FindInFiles": {
"mount": mount,
"pattern": pattern,
"is_regex": is_regex,
"glob": glob,
"max_results": max_results
}
}
return self.send_request(request)
def read_text_range(self, path, start_line, end_line):
"""Read a range of lines from a file"""
return self.send_request({
"ReadTextRange": {
"path": path,
"start_line": start_line,
"end_line": end_line
}
})
def wait_for_build_started(self, timeout=30):
"""Wait for BuildStarted response"""
start_time = time.time()
while time.time() - start_time < timeout:
response = self.process.stdout.readline()
if not response:
continue
data = json.loads(response)
if "BuildStarted" in data:
self.build_id = data["BuildStarted"]["build_id"][0]
return self.build_id
raise TimeoutError("Build did not start within timeout")
def wait_for_app_started(self, timeout=30):
"""Wait for AppStarted response"""
start_time = time.time()
while time.time() - start_time < timeout:
response = self.process.stdout.readline()
if not response:
continue
data = json.loads(response)
if "AppStarted" in data:
return data["AppStarted"]["build_id"][0]
raise TimeoutError("App did not start within timeout")
def cleanup(self):
"""Clean up the bridge process"""
if self.process:
self.process.terminate()
self.process.wait()
def test_map_widget():
"""Test the MapView widget"""
runner = MakepadTestRunner()
try:
# Start bridge
runner.start_bridge()
# Clear any existing builds
print("Clearing old builds...")
builds = runner.list_builds()
if "Builds" in builds:
for build in builds["Builds"]:
runner.clear_build(build["build_id"][0])
# Launch map example
print("Launching map example...")
runner.run_item("makepad", "makepad-example-map")
# Wait for app to start
build_id = runner.wait_for_build_started()
runner.wait_for_app_started()
print(f"App started with build_id: {build_id}")
# Take initial screenshot
print("Taking initial screenshot...")
screenshot = runner.screenshot(build_id)
print(f"Screenshot saved to: {screenshot['Screenshot']['path']}")
# Query widget tree
print("Querying widget tree...")
tree = runner.widget_tree_dump(build_id)
print(f"Widget tree dumped")
# Query map widget
print("Querying map widget...")
map_query = runner.widget_query(build_id, "id:map")
print(f"Map widget found")
# Simulate user interaction: click on map
print("Simulating click on map...")
runner.click(build_id, 640, 420)
# Take screenshot after click
time.sleep(0.5) # Wait for UI to update
screenshot2 = runner.screenshot(build_id)
print(f"Screenshot after click: {screenshot2['Screenshot']['path']}")
# Search for MapView in codebase
print("Searching for MapView in codebase...")
search = runner.find_in_files("makepad", "MapView", glob="**/*.rs")
if "SearchFileResults" in search:
print(f"Found {len(search['SearchFileResults']['results'])} occurrences of MapView")
print("✓ All tests passed!")
return True
except Exception as e:
print(f"✗ Test failed: {e}")
import traceback
traceback.print_exc()
return False
finally:
runner.cleanup()
if __name__ == "__main__":
success = test_map_widget()
sys.exit(0 if success else 1)
Bash Test Script
#!/bin/bash
# Makepad Map Widget Test (Bash version)
STUDIO_PORT=8001
BRIDGE_PID=""
cleanup() {
if [ -n "$BRIDGE_PID" ]; then
kill $BRIDGE_PID 2>/dev/null
fi
}
trap cleanup EXIT
# Start bridge
echo "Starting Studio Remote bridge..."
target/release/cargo-makepad studio --studio=127.0.0.1:$STUDIO_PORT &
BRIDGE_PID=$!
sleep 2
# Send request function
send_request() {
echo "$1" | target/release/cargo-makepad studio --studio=127.0.0.1:$STUDIO_PORT
}
# List builds
echo "Listing builds..."
send_request '{"ListBuilds":[]}'
# Launch map example
echo "Launching map example..."
send_request '{"RunItem":{"mount":"makepad","name":"makepad-example-map"}}'
# Wait for app to start
sleep 5
# Take screenshot
echo "Taking screenshot..."
send_request '{"Screenshot":{"build_id":[1],"kind_id":0}}'
# Query widget tree
echo "Querying widget tree..."
send_request '{"WidgetTreeDump":{"build_id":[1]}}'
# Click on map
echo "Clicking on map..."
send_request '{"Click":{"build_id":[1],"x":640,"y":420}}'
sleep 1
# Take another screenshot
echo "Taking screenshot after click..."
send_request '{"Screenshot":{"build_id":[1],"kind_id":0}}'
echo "✓ Tests complete!"
Current Makepad Syntax (2026)
Application Structure
// src/main.rs
pub use makepad_widgets;
use makepad_widgets::*;
app_main!(App);
script_mod! {
use mod.prelude.widgets.*
startup() do #(App::script_component(vm)) {
ui: Root {
main_window := Window {
window.inner_size: vec2(1280, 840)
pass.clear_color: vec4(0.08, 0.10, 0.12, 1.0)
body +: {
map := MapView {
width: Fill
height: Fill
}
}
}
}
}
}
#[derive(Script, ScriptHook)]
pub struct App {
#[live] ui: WidgetRef,
}
impl MatchEvent for App {
fn handle_actions(&mut self, _cx: &mut Cx, _actions: &Actions) {
// Handle widget actions
}
}
impl AppMain for App {
fn script_mod(vm: &mut ScriptVm) -> ScriptValue {
crate::makepad_widgets::script_mod(vm);
self::script_mod(vm)
}
fn handle_event(&mut self, cx: &mut Cx, event: &Event) {
self.match_event(cx, event);
self.ui.handle_event(cx, event, &mut Scope::empty());
}
}
Key Syntax Changes (Old vs New)
| Old (live_design!) | New (script_mod!) |
|---|---|
<BaseWidget> |
mod.widgets.BaseWidget{} |
{{StructName}} |
#(Struct::register_widget(vm)) |
Name = value |
Name: value |
name: Type{...} |
name := Type{...} |
(THEME_COLOR_X) |
theme.color_x |
<THEME_FONT> |
theme.font_regular |
draw_bg: {} (replace) |
draw_bg +: {} (merge) |
instance hover: 0.0 |
hover: instance(0.0) |
uniform color: #fff |
color: uniform(#fff) |
Widget Definition Pattern
#[derive(Script, ScriptHook, Widget)]
pub struct MyWidget {
#[source] source: ScriptObjectRef,
#[walk] walk: Walk,
#[layout] layout: Layout,
#[redraw] #[live] draw_bg: DrawQuad,
#[live] draw_text: DrawText,
#[rust] my_state: i32,
}
Runtime Property Updates
// Old system (live! macro)
item.apply_over(cx, live!{
height: (height)
});
// New system (script_apply_eval! macro)
script_apply_eval!(cx, item, {
height: #(height)
});
Testing Best Practices
1. Separate Business Logic
// Testable business logic (no UI dependencies)
pub struct MapLogic {
center_lon: f64,
center_lat: f64,
zoom: f32,
}
impl MapLogic {
pub fn new(lon: f64, lat: f64, zoom: f32) -> Self {
Self {
center_lon: lon,
center_lat: lat,
zoom,
}
}
pub fn zoom_in(&mut self) -> f32 {
self.zoom = (self.zoom + 1.0).min(18.0);
self.zoom
}
pub fn zoom_out(&mut self) -> f32 {
self.zoom = (self.zoom - 1.0).max(1.0);
self.zoom
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_zoom_in() {
let mut logic = MapLogic::new(0.0, 0.0, 10.0);
assert_eq!(logic.zoom_in(), 11.0);
assert_eq!(logic.zoom_in(), 12.0);
}
#[test]
fn test_zoom_out() {
let mut logic = MapLogic::new(0.0, 0.0, 10.0);
assert_eq!(logic.zoom_out(), 9.0);
assert_eq!(logic.zoom_out(), 8.0);
}
}
2. Use Studio Remote for UI Testing
# Test UI interactions
def test_map_zoom():
runner = MakepadTestRunner()
runner.start_bridge()
runner.run_item("makepad", "makepad-example-map")
build_id = runner.wait_for_build_started()
# Take screenshot at zoom 10
screenshot1 = runner.screenshot(build_id)
# Simulate zoom in (scroll up)
runner.click(build_id, 640, 420)
runner.type_text(build_id, "+") # Or use scroll
# Take screenshot at zoom 11
screenshot2 = runner.screenshot(build_id)
# Compare screenshots
assert screenshots_differ(screenshot1, screenshot2)
3. Document Test Cases
# Map Widget Test Cases
## TC-001: Map Renders
**Steps:**
1. Launch map example via Studio Remote
2. Wait for AppStarted
3. Take screenshot
**Expected:** Screenshot shows map with tiles loaded
**Actual:** Map renders correctly
**Status:** PASS ✓
## TC-002: Map Click Interaction
**Steps:**
1. Launch map example
2. Query widget tree for map widget
3. Click on map at coordinates (640, 420)
4. Take screenshot
**Expected:** Map responds to click (pan or zoom)
**Actual:** Map pans to new location
**Status:** PASS ✓
4. Use Release Builds for Testing
# Always use release builds for UI testing
cargo build --release
# Run bridge with release build
target/release/cargo-makepad studio --studio=127.0.0.1:8001
5. Clear Old Builds Before Testing
# Always clear old builds before launching new tests
builds = runner.list_builds()
if "Builds" in builds:
for build in builds["Builds"]:
runner.clear_build(build["build_id"][0])
# Now launch fresh
runner.run_item("makepad", "makepad-example-map")
Available Studio Remote Commands
Build Management
ListBuilds- List all running buildsClearBuild- Stop and clear a buildStopBuild- Stop a build without clearingRunItem- Launch a runnable item
UI Inspection
Screenshot- Take a screenshotWidgetTreeDump- Dump widget hierarchyWidgetQuery- Query specific widget
User Interaction
Click- Simulate mouse clickTypeText- Type textReturn- Press Enter key
Code Search
FindInFiles- Search for patternReadTextRange- Read file range
Advanced
ForwardToApp- Send raw binary message to app
Limitations
What Studio Remote Doesn't Have (Yet)
- No visual regression comparison - You need to implement screenshot comparison
- No accessibility testing - No WCAG compliance tools
- No performance profiling - Use external profilers
- No cross-platform testing - Tests run on desktop only
Workarounds
- Visual regression: Use Python PIL or OpenCV for image comparison
- Accessibility: Manual testing with screen readers
- Performance: Use
perf,Instruments, or browser DevTools - Cross-platform: Run tests on each platform separately
Resources
- Makepad AGENTS.md - Official testing guide
- Makepad Examples - Example applications
- Makepad Studio - Studio source code
- Makepad Widgets - Widget library
Conclusion
Makepad uses the Studio Remote Protocol for automated UI testing. This is a powerful JSON-based protocol that allows:
- Launching applications via
RunItem - Taking screenshots via
Screenshot - Querying widgets via
WidgetTreeDumpandWidgetQuery - Simulating interactions via
Click,TypeText,Return - Searching code via
FindInFilesandReadTextRange
Key Points:
- No
makepad-testcrate exists - Testing is done through the Studio Remote bridge
- Use
script_mod!syntax (notlive_design!) - Always use release builds for testing
- Clear old builds before launching new tests
- Separate business logic from UI code for unit testing
This is the current best practice for testing Makepad applications in 2026.