pinakes-plugin-api: add integration and sample plugin tests
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I0de4c3e1e5b49579ae42983f93a2332e6a6a6964
This commit is contained in:
parent
5a0901ba95
commit
7a6d602eed
2 changed files with 422 additions and 0 deletions
61
crates/pinakes-plugin-api/tests/example_plugin.rs
Normal file
61
crates/pinakes-plugin-api/tests/example_plugin.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
361
crates/pinakes-plugin-api/tests/integration.rs
Normal file
361
crates/pinakes-plugin-api/tests/integration.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue