GUI plugins #9

Merged
NotAShelf merged 46 commits from notashelf/push-mytsqvppsvxu into main 2026-03-12 16:53:43 +00:00
2 changed files with 422 additions and 0 deletions
Showing only changes of commit 7a6d602eed - Show all commits

pinakes-plugin-api: add integration and sample plugin tests

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I0de4c3e1e5b49579ae42983f93a2332e6a6a6964
raf 2026-03-11 16:49:41 +03:00
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF

View file

@ -0,0 +1,61 @@
//! Integration tests that parse and validate the media-stats-ui example.
use pinakes_plugin_api::{PluginManifest, UiPage};
/// Resolve a path relative to the workspace root.
fn workspace_path(rel: &str) -> std::path::PathBuf {
// tests run from the crate root (crates/pinakes-plugin-api)
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join(rel)
}
#[test]
fn example_plugin_manifest_parses() {
let path = workspace_path("examples/plugins/media-stats-ui/plugin.toml");
// load_ui_pages needs the manifest validated, but the file-based pages
// just need the paths to exist; we test them separately below.
let content = std::fs::read_to_string(&path).expect("read plugin.toml");
// parse_str validates the manifest; it returns Err for any violation
let manifest = PluginManifest::parse_str(&content)
.expect("plugin.toml should parse and validate");
assert_eq!(manifest.plugin.name, "media-stats-ui");
assert_eq!(
manifest.ui.pages.len(),
2,
"expected 2 page file references"
);
assert_eq!(manifest.ui.widgets.len(), 1, "expected 1 widget");
}
#[test]
fn example_stats_page_parses_and_validates() {
let path = workspace_path("examples/plugins/media-stats-ui/pages/stats.json");
let content = std::fs::read_to_string(&path).expect("read stats.json");
let page: UiPage =
serde_json::from_str(&content).expect("stats.json should deserialise");
assert_eq!(page.id, "stats");
page.validate().expect("stats page should pass validation");
}
#[test]
fn example_tag_manager_page_parses_and_validates() {
let path =
workspace_path("examples/plugins/media-stats-ui/pages/tag-manager.json");
let content = std::fs::read_to_string(&path).expect("read tag-manager.json");
let page: UiPage = serde_json::from_str(&content)
.expect("tag-manager.json should deserialise");
assert_eq!(page.id, "tag-manager");
page
.validate()
.expect("tag-manager page should pass validation");
// Verify the named action and data source are both present
assert!(
page.actions.contains_key("create-tag"),
"create-tag action should be defined"
);
assert!(
page.data_sources.contains_key("tags"),
"tags data source should be defined"
);
}

View file

@ -0,0 +1,361 @@
//! Integration tests for the plugin validation pipeline.
//!
//! Renderer-level behaviour (e.g., Dioxus components) is out of scope here;
//! that requires a Dioxus runtime and belongs in pinakes-ui tests.
use std::collections::HashMap;
use pinakes_plugin_api::{
DataSource,
HttpMethod,
UiElement,
UiPage,
UiWidget,
validation::SchemaValidator,
};
// Build a minimal valid UiPage.
fn make_page(id: &str, route: &str) -> UiPage {
UiPage {
id: id.to_string(),
title: "Integration Test Page".to_string(),
route: route.to_string(),
icon: None,
root_element: UiElement::Container {
children: vec![],
gap: 0,
padding: None,
},
data_sources: HashMap::new(),
actions: HashMap::new(),
}
}
// Build a minimal valid UiWidget.
fn make_widget(id: &str, target: &str) -> UiWidget {
UiWidget {
id: id.to_string(),
target: target.to_string(),
content: UiElement::Container {
children: vec![],
gap: 0,
padding: None,
},
}
}
// Build a complete valid PluginManifest as TOML string.
const fn valid_manifest_toml() -> &'static str {
r#"
[plugin]
name = "integration-test-plugin"
version = "1.0.0"
api_version = "1.0"
kind = ["ui_page"]
[plugin.binary]
wasm = "plugin.wasm"
[[ui.pages]]
id = "stats"
title = "Statistics"
route = "/plugins/integration-test/stats"
[ui.pages.layout]
type = "container"
children = []
gap = 0
[[ui.widgets]]
id = "status-badge"
target = "library_header"
[ui.widgets.content]
type = "badge"
text = "online"
"#
}
// A complete valid manifest (with a page and a widget) passes all
// checks.
#[test]
fn test_full_valid_plugin_manifest_passes_all_checks() {
use pinakes_plugin_api::PluginManifest;
let manifest = PluginManifest::parse_str(valid_manifest_toml())
.expect("manifest must parse");
assert_eq!(manifest.plugin.name, "integration-test-plugin");
assert_eq!(manifest.ui.pages.len(), 1);
assert_eq!(manifest.ui.widgets.len(), 1);
// validate page via UiPage::validate
let page = make_page("my-plugin-page", "/plugins/integration-test/overview");
page
.validate()
.expect("valid page must pass UiPage::validate");
// validate the same page via SchemaValidator
SchemaValidator::validate_page(&page)
.expect("valid page must pass SchemaValidator::validate_page");
// validate widget
let widget = make_widget("my-widget", "library_header");
SchemaValidator::validate_widget(&widget)
.expect("valid widget must pass SchemaValidator::validate_widget");
}
// A page with a reserved route is rejected by both UiPage::validate
// and SchemaValidator::validate_page.
//
// The reserved routes exercised here are "/search" and "/admin".
#[test]
fn test_page_with_reserved_route_rejected() {
let reserved = ["/search", "/admin", "/settings", "/library", "/books"];
for route in reserved {
// UiPage::validate path
let page = make_page("my-page", route);
let result = page.validate();
assert!(
result.is_err(),
"UiPage::validate must reject reserved route: {route}"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("conflicts with a built-in app route"),
"error for {route} must mention 'conflicts with a built-in app route', \
got: {msg}"
);
// SchemaValidator::validate_page path
let page2 = make_page("my-page", route);
let result2 = SchemaValidator::validate_page(&page2);
assert!(
result2.is_err(),
"SchemaValidator::validate_page must reject reserved route: {route}"
);
}
}
// Sub-paths of reserved routes are also rejected.
#[test]
fn test_page_with_reserved_route_subpath_rejected() {
let subpaths = [
"/search/advanced",
"/admin/users",
"/settings/theme",
"/library/foo",
];
for route in subpaths {
let page = make_page("my-page", route);
assert!(
page.validate().is_err(),
"reserved route sub-path must be rejected: {route}"
);
}
}
// A UiWidget whose content contains a DataTable fails validation.
//
// Widgets have no data-fetching mechanism; any data source reference in a
// widget content tree must be caught at load time.
#[test]
fn test_widget_with_datatable_fails_validation() {
use pinakes_plugin_api::ColumnDef;
let col: ColumnDef =
serde_json::from_value(serde_json::json!({"key": "id", "header": "ID"}))
.unwrap();
let widget = UiWidget {
id: "bad-widget".to_string(),
target: "library_header".to_string(),
content: UiElement::DataTable {
data: "items".to_string(),
columns: vec![col],
sortable: false,
filterable: false,
page_size: 0,
row_actions: vec![],
},
};
let result = SchemaValidator::validate_widget(&widget);
assert!(
result.is_err(),
"widget with DataTable content must fail validation"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("cannot reference data sources"),
"error must mention data sources, got: {msg}"
);
}
// A UiWidget whose content is a Container wrapping a Loop fails
// validation. Basically a recursive data source check.
#[test]
fn test_widget_container_wrapping_loop_fails_validation() {
use pinakes_plugin_api::Expression;
let widget = UiWidget {
id: "loop-widget".to_string(),
target: "library_header".to_string(),
content: UiElement::Container {
children: vec![UiElement::Loop {
data: "items".to_string(),
template: Box::new(UiElement::Text {
content: Default::default(),
variant: Default::default(),
allow_html: false,
}),
empty: None,
}],
gap: 0,
padding: None,
},
};
let result = SchemaValidator::validate_widget(&widget);
assert!(
result.is_err(),
"widget containing a Loop must fail validation"
);
let _ = Expression::default(); // Expression should be accessible from the import path
}
// Endpoint data source with a path that does not start with /api/ fails
// UiPage::validate (DataSource::validate is called there).
#[test]
fn test_endpoint_data_source_non_api_path_rejected() {
let mut page = make_page("my-page", "/plugins/test/page");
page
.data_sources
.insert("items".to_string(), DataSource::Endpoint {
path: "/v1/media".to_string(),
method: HttpMethod::Get,
params: Default::default(),
poll_interval: 0,
transform: None,
});
// DataSource::validate requires /api/ prefix
assert!(
page.validate().is_err(),
"data source path not starting with /api/ must be rejected"
);
// Also verify SchemaValidator::validate_page rejects it
let result2 = SchemaValidator::validate_page(&page);
assert!(
result2.is_err(),
"SchemaValidator should reject non-/api/ endpoint path"
);
}
// A static data source with any value passes validation on its own.
#[test]
fn test_static_data_source_passes_validation() {
use pinakes_plugin_api::ColumnDef;
let col: ColumnDef =
serde_json::from_value(serde_json::json!({"key": "n", "header": "N"}))
.unwrap();
let mut page = make_page("my-page", "/plugins/test/page");
page
.data_sources
.insert("nums".to_string(), DataSource::Static {
value: serde_json::json!([1, 2, 3]),
});
// Root element references the static data source so DataTable passes
page.root_element = UiElement::DataTable {
data: "nums".to_string(),
columns: vec![col],
sortable: false,
filterable: false,
page_size: 0,
row_actions: vec![],
};
page
.validate()
.expect("page with static data source must pass validation");
}
// Parse TOML string, validate, and load inline pages round-trips without
// errors.
#[test]
fn test_manifest_inline_page_roundtrip() {
use pinakes_plugin_api::{PluginManifest, manifest::UiPageEntry};
let toml = r#"
[plugin]
name = "roundtrip-plugin"
version = "2.3.0"
api_version = "1.0"
kind = ["ui_page"]
[plugin.binary]
wasm = "plugin.wasm"
[[ui.pages]]
id = "overview"
title = "Overview"
route = "/plugins/roundtrip/overview"
[ui.pages.layout]
type = "container"
children = []
gap = 8
"#;
let manifest = PluginManifest::parse_str(toml).expect("manifest must parse");
assert_eq!(manifest.plugin.name, "roundtrip-plugin");
assert_eq!(manifest.ui.pages.len(), 1);
match &manifest.ui.pages[0] {
UiPageEntry::Inline(page) => {
assert_eq!(page.id, "overview");
assert_eq!(page.route, "/plugins/roundtrip/overview");
// UiPage::validate must also succeed for inline pages
page
.validate()
.expect("inline page must pass UiPage::validate");
},
UiPageEntry::File { .. } => {
panic!("expected inline page entry, got file reference");
},
}
}
// warnings by PluginManifest::validate but do NOT cause an error return.
// (The UiSection::validate failure is non-fatal; see manifest.rs.)
#[test]
fn test_manifest_bad_required_endpoint_is_non_fatal() {
use pinakes_plugin_api::PluginManifest;
let toml = r#"
[plugin]
name = "ep-test"
version = "1.0.0"
api_version = "1.0"
kind = ["ui_page"]
[plugin.binary]
wasm = "plugin.wasm"
[ui]
required_endpoints = ["/not-an-api-path"]
"#;
// PluginManifest::validate emits a tracing::warn for invalid
// required_endpoints but does not return Err - verify the manifest still
// parses successfully.
let result = PluginManifest::parse_str(toml);
assert!(
result.is_ok(),
"bad required_endpoint should be non-fatal for PluginManifest::validate"
);
}