use std::collections::BTreeSet;
use std::fmt;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::{AppManifest, AppType, AppUiKind, ASSISTANT_SLOT};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum HostFeature {
UiSurfaces,
UiWidget,
UiComposition,
UiWidgetData,
UiCommands,
UiAssistantSlot,
UiWidgetUiOnly,
}
impl HostFeature {
pub const ALL: [HostFeature; 7] = [
HostFeature::UiSurfaces,
HostFeature::UiWidget,
HostFeature::UiComposition,
HostFeature::UiWidgetData,
HostFeature::UiCommands,
HostFeature::UiAssistantSlot,
HostFeature::UiWidgetUiOnly,
];
pub fn id(self) -> &'static str {
match self {
HostFeature::UiSurfaces => "ui.surfaces",
HostFeature::UiWidget => "ui.widget",
HostFeature::UiComposition => "ui.composition",
HostFeature::UiWidgetData => "ui.widget-data",
HostFeature::UiCommands => "ui.commands",
HostFeature::UiAssistantSlot => "ui.assistant-slot",
HostFeature::UiWidgetUiOnly => "ui.widget-ui-only",
}
}
pub fn from_id(id: &str) -> Option<HostFeature> {
HostFeature::ALL
.into_iter()
.find(|feature| feature.id() == id)
}
pub fn unlocks(self) -> &'static str {
match self {
HostFeature::UiSurfaces => "`ui.surfaces` shell-chrome contributions",
HostFeature::UiWidget => "`ui.kind: \"widget\"` UIs composed by a parent stage",
HostFeature::UiComposition => "`ui.composes` and the composition input/event port",
HostFeature::UiWidgetData => "`ui.data` app-data declarations on a widget",
HostFeature::UiCommands => {
"`requires.commands` kernel commands sent through `ctx.command`"
}
HostFeature::UiAssistantSlot => "the single-occupant `assistant` shell surface slot",
HostFeature::UiWidgetUiOnly => "a composed widget with no backend process",
}
}
pub fn derived_when(self) -> &'static str {
match self {
HostFeature::UiSurfaces => "`ui.surfaces` is non-empty",
HostFeature::UiWidget => "`ui.kind` is `widget`",
HostFeature::UiComposition => "`ui.composes` is non-empty",
HostFeature::UiWidgetData => "`ui.kind` is `widget` and `ui.data` is present",
HostFeature::UiCommands => "any `requires.commands` (stage or surface) is non-empty",
HostFeature::UiAssistantSlot => "a `ui.surfaces` entry uses slot `assistant`",
HostFeature::UiWidgetUiOnly => {
"`ui.kind` is `widget` and `app_type` is `ui-only` (or neither `app_type` nor `entrypoint` is set)"
}
}
}
pub fn since(self) -> HostVersion {
match self {
HostFeature::UiSurfaces => HostVersion::new(7, 0, 11),
HostFeature::UiWidget => HostVersion::new(7, 1, 11),
HostFeature::UiComposition => HostVersion::new(7, 1, 11),
HostFeature::UiWidgetData => HostVersion::new(7, 2, 0),
HostFeature::UiCommands => HostVersion::new(7, 3, 0),
HostFeature::UiAssistantSlot => HostVersion::new(7, 3, 0),
HostFeature::UiWidgetUiOnly => HostVersion::new(7, 3, 0),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct HostVersion {
pub major: u32,
pub minor: u32,
pub patch: u32,
}
impl HostVersion {
pub const fn new(major: u32, minor: u32, patch: u32) -> Self {
HostVersion {
major,
minor,
patch,
}
}
pub fn parse(value: &str) -> Option<HostVersion> {
let value = value.strip_prefix('v').unwrap_or(value);
let core = value.split(['+', '-', '~']).next()?;
let mut parts = core.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None;
}
Some(HostVersion::new(major, minor, patch))
}
}
impl fmt::Display for HostVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
impl Serialize for HostVersion {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for HostVersion {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let raw = String::deserialize(deserializer)?;
HostVersion::parse(&raw)
.ok_or_else(|| serde::de::Error::custom(format!("invalid host version '{raw}'")))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostFeatureSet(BTreeSet<HostFeature>);
impl HostFeatureSet {
pub fn current() -> Self {
HostFeatureSet(HostFeature::ALL.into_iter().collect())
}
pub fn at(version: HostVersion) -> Self {
HostFeatureSet(
HostFeature::ALL
.into_iter()
.filter(|feature| feature.since() <= version)
.collect(),
)
}
pub fn contains(&self, feature: HostFeature) -> bool {
self.0.contains(&feature)
}
pub fn ids(&self) -> Vec<&'static str> {
self.0.iter().map(|feature| feature.id()).collect()
}
}
pub fn required_features(manifest: &AppManifest) -> BTreeSet<HostFeature> {
let mut features = BTreeSet::new();
let Some(ui) = &manifest.ui else {
return features;
};
if ui.kind == AppUiKind::Widget {
features.insert(HostFeature::UiWidget);
if ui.data.is_some() {
features.insert(HostFeature::UiWidgetData);
}
if manifest.app_type == AppType::UiOnly {
features.insert(HostFeature::UiWidgetUiOnly);
}
}
if !ui.composes.is_empty() {
features.insert(HostFeature::UiComposition);
}
if !ui.surfaces.is_empty() {
features.insert(HostFeature::UiSurfaces);
}
let any_commands = !ui.requires.commands.is_empty()
|| ui
.surfaces
.iter()
.any(|surface| !surface.requires.commands.is_empty());
if any_commands {
features.insert(HostFeature::UiCommands);
}
if ui
.surfaces
.iter()
.any(|surface| surface.slot == ASSISTANT_SLOT)
{
features.insert(HostFeature::UiAssistantSlot);
}
features
}
pub fn min_host_for(features: &BTreeSet<HostFeature>) -> Option<HostVersion> {
features.iter().map(|feature| feature.since()).max()
}
pub fn render_host_feature_table() -> String {
let mut table =
String::from("| Feature | Unlocks | Derived when | First host |\n|---|---|---|---|\n");
for feature in HostFeature::ALL {
table.push_str(&format!(
"| `{}` | {} | {} | {} |\n",
feature.id(),
feature.unlocks(),
feature.derived_when(),
feature.since()
));
}
table
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostContractStamp {
pub features: Vec<String>,
#[serde(
default,
deserialize_with = "lenient_min_host",
skip_serializing_if = "Option::is_none"
)]
pub min_host: Option<HostVersion>,
}
impl HostContractStamp {
pub fn same_contract(&self, other: &HostContractStamp) -> bool {
let mine: BTreeSet<&str> = self.features.iter().map(String::as_str).collect();
let theirs: BTreeSet<&str> = other.features.iter().map(String::as_str).collect();
mine == theirs && self.min_host == other.min_host
}
}
fn lenient_min_host<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Option<HostVersion>, D::Error> {
let raw = serde_json::Value::deserialize(deserializer)?;
Ok(raw.as_str().and_then(HostVersion::parse))
}
pub fn stamp_for(manifest: &AppManifest) -> HostContractStamp {
let features = required_features(manifest);
let mut ids: Vec<String> = features
.iter()
.map(|feature| feature.id().to_string())
.collect();
ids.sort();
HostContractStamp {
features: ids,
min_host: min_host_for(&features),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ManifestRejection {
HostFeatureMissing {
features: Vec<String>,
min_host: Option<HostVersion>,
},
Invalid { message: String },
}
impl fmt::Display for ManifestRejection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ManifestRejection::HostFeatureMissing { features, min_host } => {
write!(
f,
"requires host features this Node does not support: {}",
features.join(", ")
)?;
if let Some(version) = min_host {
write!(f, " (first available in Node {version})")?;
}
Ok(())
}
ManifestRejection::Invalid { message } => f.write_str(message),
}
}
}
pub fn precheck_stamp(json: &str, supported: &HostFeatureSet) -> Result<(), ManifestRejection> {
let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
return Ok(());
};
let Some(features) = value
.get("host_contract")
.and_then(|stamp| stamp.get("features"))
.and_then(serde_json::Value::as_array)
else {
return Ok(());
};
let missing: Vec<String> = features
.iter()
.filter_map(serde_json::Value::as_str)
.filter(|id| !HostFeature::from_id(id).is_some_and(|feature| supported.contains(feature)))
.map(str::to_string)
.collect();
if missing.is_empty() {
return Ok(());
}
let min_host = value["host_contract"]
.get("min_host")
.and_then(serde_json::Value::as_str)
.and_then(HostVersion::parse);
Err(ManifestRejection::HostFeatureMissing {
features: missing,
min_host,
})
}
pub fn check_host_contract(
manifest: &AppManifest,
supported: &HostFeatureSet,
) -> Result<(), ManifestRejection> {
let derived = required_features(manifest);
let missing: BTreeSet<HostFeature> = derived
.iter()
.copied()
.filter(|feature| !supported.contains(*feature))
.collect();
if !missing.is_empty() {
let mut features: Vec<String> = missing
.iter()
.map(|feature| feature.id().to_string())
.collect();
features.sort();
return Err(ManifestRejection::HostFeatureMissing {
features,
min_host: min_host_for(&missing),
});
}
if let Some(stamp) = &manifest.host_contract {
let unstamped: Vec<&str> = derived
.iter()
.map(|feature| feature.id())
.filter(|id| !stamp.features.iter().any(|stamped| stamped == id))
.collect();
if !unstamped.is_empty() {
return Err(ManifestRejection::Invalid {
message: format!(
"host_contract is stale: missing {}; run `node-app contract stamp`",
unstamped.join(", ")
),
});
}
if let (Some(required), stamped) = (min_host_for(&derived), stamp.min_host) {
if stamped.is_none_or(|version| version < required) {
return Err(ManifestRejection::Invalid {
message: format!(
"host_contract is stale: min_host must be at least {required}; run `node-app contract stamp`"
),
});
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::AppManifest;
const HASH: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
fn manifest(ui: &str) -> AppManifest {
AppManifest::from_json(&format!(
r#"{{"name":"demo","version":"1.0.0","app_type":"bun","ui":{ui}}}"#
))
.expect("valid manifest")
}
fn ui(kind: &str, extra: &str) -> String {
format!(
r#"{{"kind":"{kind}","entry":"ui/main.js","title":"Demo","ui_api":1,"integrity":{{"ui/main.js":"{HASH}"}}{extra}}}"#
)
}
#[test]
fn plain_stage_requires_nothing() {
assert!(required_features(&manifest(&ui("stage", ""))).is_empty());
}
#[test]
fn widget_requires_ui_widget() {
let features = required_features(&manifest(&ui("widget", "")));
assert_eq!(
features.into_iter().collect::<Vec<_>>(),
vec![HostFeature::UiWidget]
);
}
#[test]
fn widget_with_data_requires_widget_data() {
let mut m = manifest(&ui("widget", ""));
m.ui.as_mut().unwrap().data = Some(
serde_json::from_str(
r#"{"namespace":"demo","offline":"online-only","sync":"snapshot","queries":[],"streams":[]}"#,
)
.unwrap(),
);
let features: Vec<_> = required_features(&m).into_iter().collect();
assert_eq!(
features,
vec![HostFeature::UiWidget, HostFeature::UiWidgetData]
);
}
fn ui_only(ui: &str) -> AppManifest {
AppManifest::from_json(&format!(r#"{{"name":"demo","version":"1.0.0","ui":{ui}}}"#))
.expect("valid UI-only manifest")
}
#[test]
fn ui_only_widget_requires_widget_ui_only() {
let m = ui_only(&ui("widget", ""));
assert_eq!(m.app_type, AppType::UiOnly);
let features: Vec<_> = required_features(&m).into_iter().collect();
assert_eq!(
features,
vec![HostFeature::UiWidget, HostFeature::UiWidgetUiOnly]
);
let stamp = stamp_for(&m);
assert_eq!(stamp.features, vec!["ui.widget", "ui.widget-ui-only"]);
assert_eq!(stamp.min_host, Some(HostVersion::new(7, 3, 0)));
}
#[test]
fn ui_only_stage_and_bun_widget_do_not_require_widget_ui_only() {
assert!(required_features(&ui_only(&ui("stage", ""))).is_empty());
assert!(
!required_features(&manifest(&ui("widget", ""))).contains(&HostFeature::UiWidgetUiOnly)
);
}
#[test]
fn a_host_before_widget_ui_only_reports_it_missing() {
let m = ui_only(&ui("widget", ""));
assert_eq!(
check_host_contract(&m, &HostFeatureSet::at(HostVersion::new(7, 2, 5))),
Err(ManifestRejection::HostFeatureMissing {
features: vec!["ui.widget-ui-only".into()],
min_host: Some(HostVersion::new(7, 3, 0)),
})
);
assert_eq!(check_host_contract(&m, &HostFeatureSet::current()), Ok(()));
let mut stale = m.clone();
stale.host_contract = Some(HostContractStamp {
features: vec!["ui.widget".into()],
min_host: Some(HostVersion::new(7, 1, 11)),
});
assert!(matches!(
check_host_contract(&stale, &HostFeatureSet::current()),
Err(ManifestRejection::Invalid { .. })
));
}
#[test]
fn precheck_on_an_older_host_reports_widget_ui_only_as_too_new() {
let json = format!(
r#"{{"name":"demo","version":"1.0.0","host_contract":{{"features":["ui.widget","ui.widget-ui-only"],"min_host":"7.3.0"}},"ui":{}}}"#,
ui("widget", "")
);
let before = HostFeatureSet(
HostFeature::ALL
.into_iter()
.filter(|feature| *feature != HostFeature::UiWidgetUiOnly)
.collect(),
);
assert_eq!(
precheck_stamp(&json, &before),
Err(ManifestRejection::HostFeatureMissing {
features: vec!["ui.widget-ui-only".into()],
min_host: Some(HostVersion::new(7, 3, 0)),
})
);
assert_eq!(precheck_stamp(&json, &HostFeatureSet::current()), Ok(()));
}
#[test]
fn composing_stage_requires_composition() {
let features: Vec<_> =
required_features(&manifest(&ui("stage", r#","composes":["child"]"#)))
.into_iter()
.collect();
assert_eq!(features, vec![HostFeature::UiComposition]);
}
#[test]
fn ids_round_trip_and_are_stable() {
let ids: Vec<_> = HostFeature::ALL.iter().map(|f| f.id()).collect();
assert_eq!(
ids,
vec![
"ui.surfaces",
"ui.widget",
"ui.composition",
"ui.widget-data",
"ui.commands",
"ui.assistant-slot",
"ui.widget-ui-only"
]
);
for feature in HostFeature::ALL {
assert_eq!(HostFeature::from_id(feature.id()), Some(feature));
}
assert_eq!(HostFeature::from_id("ui.future"), None);
}
#[test]
fn feature_sets_follow_release_history() {
let old = HostFeatureSet::at(HostVersion::new(7, 1, 17));
assert!(old.contains(HostFeature::UiWidget));
assert!(old.contains(HostFeature::UiComposition));
assert!(!old.contains(HostFeature::UiWidgetData));
assert!(HostFeatureSet::at(HostVersion::new(7, 2, 0)).contains(HostFeature::UiWidgetData));
assert!(
!HostFeatureSet::at(HostVersion::new(7, 2, 4)).contains(HostFeature::UiWidgetUiOnly)
);
assert!(HostFeatureSet::at(HostVersion::new(7, 3, 0)).contains(HostFeature::UiWidgetUiOnly));
assert!(!HostFeatureSet::at(HostVersion::new(7, 0, 10)).contains(HostFeature::UiSurfaces));
assert_eq!(
HostFeatureSet::current().ids().len(),
HostFeature::ALL.len()
);
}
#[test]
fn host_version_parses_tags_and_package_suffixes() {
assert_eq!(HostVersion::parse("7.2.0"), Some(HostVersion::new(7, 2, 0)));
assert_eq!(
HostVersion::parse("v7.1.17"),
Some(HostVersion::new(7, 1, 17))
);
assert_eq!(
HostVersion::parse("7.1.17+composed.abc"),
Some(HostVersion::new(7, 1, 17))
);
assert_eq!(HostVersion::parse("7.1"), None);
assert_eq!(HostVersion::parse("seven"), None);
assert_eq!(HostVersion::new(7, 2, 0).to_string(), "7.2.0");
}
#[test]
fn min_host_is_the_latest_first_release() {
let set: BTreeSet<_> = [HostFeature::UiWidget, HostFeature::UiWidgetData]
.into_iter()
.collect();
assert_eq!(min_host_for(&set), Some(HostVersion::new(7, 2, 0)));
assert_eq!(min_host_for(&BTreeSet::new()), None);
}
#[test]
fn feature_table_lists_every_feature() {
let table = render_host_feature_table();
for feature in HostFeature::ALL {
assert!(table.contains(&format!("`{}`", feature.id())), "{table}");
}
}
const DATA: &str = r#","data":{"namespace":"demo","offline":"online-only","sync":"snapshot","queries":[],"streams":[]}"#;
#[test]
fn widget_may_declare_app_data() {
let m = manifest(&ui("widget", DATA));
assert!(m.ui.unwrap().data.is_some());
}
#[test]
fn widget_app_data_gets_stage_rules() {
let error = AppManifest::from_json(&format!(
r#"{{"name":"demo","version":"1.0.0","app_type":"bun","ui":{}}}"#,
ui("widget", r#","data":{"namespace":"other","offline":"online-only","sync":"snapshot","queries":[],"streams":[]}"#)
))
.expect_err("namespace must equal the app name");
assert!(error.contains("must equal the app name"), "{error}");
}
#[test]
fn widget_nav_is_still_rejected() {
let error = AppManifest::from_json(&format!(
r#"{{"name":"demo","version":"1.0.0","app_type":"bun","ui":{}}}"#,
ui("widget", r#","nav":{"section":"default","order":1}"#)
))
.expect_err("widgets have no nav");
assert!(error.contains("nav"), "{error}");
}
#[test]
fn stamp_is_sorted_derived_features_with_min_host() {
let stamp = stamp_for(&manifest(&ui("widget", DATA)));
assert_eq!(stamp.features, vec!["ui.widget", "ui.widget-data"]);
assert_eq!(stamp.min_host, Some(HostVersion::new(7, 2, 0)));
assert_eq!(
serde_json::to_string(&stamp).unwrap(),
r#"{"features":["ui.widget","ui.widget-data"],"min_host":"7.2.0"}"#
);
let empty = stamp_for(&manifest(&ui("stage", "")));
assert_eq!(serde_json::to_string(&empty).unwrap(), r#"{"features":[]}"#);
}
#[test]
fn check_accepts_absent_and_fresh_stamps() {
let m = manifest(&ui("widget", DATA));
assert_eq!(check_host_contract(&m, &HostFeatureSet::current()), Ok(()));
let mut stamped = m.clone();
stamped.host_contract = Some(stamp_for(&m));
assert_eq!(
check_host_contract(&stamped, &HostFeatureSet::current()),
Ok(())
);
}
#[test]
fn check_rejects_a_stale_stamp() {
let mut m = manifest(&ui("widget", DATA));
m.host_contract = Some(HostContractStamp {
features: vec!["ui.widget".into()],
min_host: Some(HostVersion::new(7, 1, 11)),
});
match check_host_contract(&m, &HostFeatureSet::current()) {
Err(ManifestRejection::Invalid { message }) => {
assert!(message.contains("stale"), "{message}");
assert!(message.contains("ui.widget-data"), "{message}");
}
other => panic!("expected stale stamp rejection, got {other:?}"),
}
}
#[test]
fn check_reports_missing_features_for_an_older_target() {
let m = manifest(&ui("widget", DATA));
assert_eq!(
check_host_contract(&m, &HostFeatureSet::at(HostVersion::new(7, 1, 17))),
Err(ManifestRejection::HostFeatureMissing {
features: vec!["ui.widget-data".into()],
min_host: Some(HostVersion::new(7, 2, 0)),
})
);
}
#[test]
fn precheck_reports_unknown_stamped_feature_with_stamped_min_host() {
let json = format!(
r#"{{"name":"demo","version":"1.0.0","app_type":"bun","host_contract":{{"features":["ui.future","ui.widget"],"min_host":"9.0.0"}},"ui":{}}}"#,
ui("widget", "")
);
assert_eq!(
precheck_stamp(&json, &HostFeatureSet::current()),
Err(ManifestRejection::HostFeatureMissing {
features: vec!["ui.future".into()],
min_host: Some(HostVersion::new(9, 0, 0)),
})
);
}
#[test]
fn precheck_tolerates_a_newer_stamp_shape_and_the_manifest_still_parses() {
let json = format!(
r#"{{"name":"demo","version":"1.0.0","app_type":"bun","host_contract":{{"features":["ui.future"],"min_host":"9.0.0","extra":1}},"ui":{}}}"#,
ui("widget", "")
);
assert_eq!(
precheck_stamp(&json, &HostFeatureSet::current()),
Err(ManifestRejection::HostFeatureMissing {
features: vec!["ui.future".into()],
min_host: Some(HostVersion::new(9, 0, 0)),
})
);
let manifest = AppManifest::from_json(&json).expect("unknown stamp members are tolerated");
assert_eq!(
manifest.host_contract.unwrap().min_host,
Some(HostVersion::new(9, 0, 0))
);
}
#[test]
fn unparseable_stamped_min_host_reads_as_absent() {
let json = format!(
r#"{{"name":"demo","version":"1.0.0","app_type":"bun","host_contract":{{"features":["ui.future","ui.widget"],"min_host":"nine"}},"ui":{}}}"#,
ui("widget", "")
);
assert_eq!(
precheck_stamp(&json, &HostFeatureSet::current()),
Err(ManifestRejection::HostFeatureMissing {
features: vec!["ui.future".into()],
min_host: None
})
);
let manifest = AppManifest::from_json(&json).expect("an unparseable min_host is tolerated");
let stamp = manifest.host_contract.clone().unwrap();
assert_eq!(stamp.min_host, None);
assert_eq!(
serde_json::to_string(&stamp).unwrap(),
r#"{"features":["ui.future","ui.widget"]}"#
);
let mut widget = manifest.clone();
widget.host_contract = Some(HostContractStamp {
features: vec!["ui.widget".into()],
min_host: None,
});
assert!(matches!(
check_host_contract(&widget, &HostFeatureSet::current()),
Err(ManifestRejection::Invalid { .. })
));
}
#[test]
fn stamp_features_are_sorted_by_id_and_compared_as_sets() {
let m = manifest(&ui("widget", &format!(r#"{DATA},"composes":["child"]"#)));
let stamp = stamp_for(&m);
assert_eq!(
stamp.features,
vec!["ui.composition", "ui.widget", "ui.widget-data"]
);
let reordered = HostContractStamp {
features: vec![
"ui.widget-data".into(),
"ui.composition".into(),
"ui.widget".into(),
],
min_host: stamp.min_host,
};
assert!(stamp.same_contract(&reordered));
let mut stamped = m.clone();
stamped.host_contract = Some(reordered);
assert_eq!(
check_host_contract(&stamped, &HostFeatureSet::current()),
Ok(())
);
let fewer = HostContractStamp {
features: vec!["ui.widget".into()],
min_host: stamp.min_host,
};
assert!(!stamp.same_contract(&fewer));
}
#[test]
fn precheck_ignores_absent_or_malformed_stamps() {
assert_eq!(
precheck_stamp(r#"{"name":"demo"}"#, &HostFeatureSet::current()),
Ok(())
);
assert_eq!(
precheck_stamp(r#"{"host_contract":7}"#, &HostFeatureSet::current()),
Ok(())
);
assert_eq!(
precheck_stamp("not json", &HostFeatureSet::current()),
Ok(())
);
}
#[test]
fn rejection_messages_are_actionable() {
let missing = ManifestRejection::HostFeatureMissing {
features: vec!["ui.widget-data".into()],
min_host: Some(HostVersion::new(7, 2, 0)),
};
assert_eq!(
missing.to_string(),
"requires host features this Node does not support: ui.widget-data (first available in Node 7.2.0)"
);
}
}