Skip to main content

node_app_manifest/
host_contract.rs

1//! Host contract features: which manifest constructs a host understands.
2//!
3//! Features are derived from manifest content, never declared by authors.
4//! `node-app contract stamp` writes the derived set into `manifest.json` as
5//! `host_contract` so hosts released before a feature existed can still
6//! recognise that they do not support it (see `check_host_contract`).
7
8use std::collections::BTreeSet;
9use std::fmt;
10
11use serde::{Deserialize, Deserializer, Serialize, Serializer};
12
13use crate::{AppManifest, AppUiKind};
14
15/// A manifest construct that only some host releases understand.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
17pub enum HostFeature {
18    UiSurfaces,
19    UiWidget,
20    UiComposition,
21    UiWidgetData,
22}
23
24impl HostFeature {
25    pub const ALL: [HostFeature; 4] = [
26        HostFeature::UiSurfaces,
27        HostFeature::UiWidget,
28        HostFeature::UiComposition,
29        HostFeature::UiWidgetData,
30    ];
31
32    /// Stable wire id. Never rename a published id.
33    pub fn id(self) -> &'static str {
34        match self {
35            HostFeature::UiSurfaces => "ui.surfaces",
36            HostFeature::UiWidget => "ui.widget",
37            HostFeature::UiComposition => "ui.composition",
38            HostFeature::UiWidgetData => "ui.widget-data",
39        }
40    }
41
42    pub fn from_id(id: &str) -> Option<HostFeature> {
43        HostFeature::ALL.into_iter().find(|feature| feature.id() == id)
44    }
45
46    pub fn unlocks(self) -> &'static str {
47        match self {
48            HostFeature::UiSurfaces => "`ui.surfaces` shell-chrome contributions",
49            HostFeature::UiWidget => "`ui.kind: \"widget\"` UIs composed by a parent stage",
50            HostFeature::UiComposition => "`ui.composes` and the composition input/event port",
51            HostFeature::UiWidgetData => "`ui.data` app-data declarations on a widget",
52        }
53    }
54
55    pub fn derived_when(self) -> &'static str {
56        match self {
57            HostFeature::UiSurfaces => "`ui.surfaces` is non-empty",
58            HostFeature::UiWidget => "`ui.kind` is `widget`",
59            HostFeature::UiComposition => "`ui.composes` is non-empty",
60            HostFeature::UiWidgetData => "`ui.kind` is `widget` and `ui.data` is present",
61        }
62    }
63
64    /// First host release that supports this feature.
65    pub fn since(self) -> HostVersion {
66        match self {
67            HostFeature::UiSurfaces => HostVersion::new(7, 0, 11),
68            HostFeature::UiWidget => HostVersion::new(7, 1, 11),
69            HostFeature::UiComposition => HostVersion::new(7, 1, 11),
70            // Confirm with the release owner before tagging (spec §13 A9).
71            HostFeature::UiWidgetData => HostVersion::new(7, 2, 0),
72        }
73    }
74}
75
76/// A host release version, `major.minor.patch`.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
78pub struct HostVersion {
79    pub major: u32,
80    pub minor: u32,
81    pub patch: u32,
82}
83
84impl HostVersion {
85    pub const fn new(major: u32, minor: u32, patch: u32) -> Self {
86        HostVersion { major, minor, patch }
87    }
88
89    /// Accepts `7.2.0`, `v7.2.0`, and package versions with a `+…`/`-…`/`~…` suffix.
90    pub fn parse(value: &str) -> Option<HostVersion> {
91        let value = value.strip_prefix('v').unwrap_or(value);
92        let core = value.split(['+', '-', '~']).next()?;
93        let mut parts = core.split('.');
94        let major = parts.next()?.parse().ok()?;
95        let minor = parts.next()?.parse().ok()?;
96        let patch = parts.next()?.parse().ok()?;
97        if parts.next().is_some() {
98            return None;
99        }
100        Some(HostVersion::new(major, minor, patch))
101    }
102}
103
104impl fmt::Display for HostVersion {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
107    }
108}
109
110impl Serialize for HostVersion {
111    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
112        serializer.collect_str(self)
113    }
114}
115
116impl<'de> Deserialize<'de> for HostVersion {
117    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
118        let raw = String::deserialize(deserializer)?;
119        HostVersion::parse(&raw)
120            .ok_or_else(|| serde::de::Error::custom(format!("invalid host version '{raw}'")))
121    }
122}
123
124/// The features one host release understands.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct HostFeatureSet(BTreeSet<HostFeature>);
127
128impl HostFeatureSet {
129    /// Everything this build understands.
130    pub fn current() -> Self {
131        HostFeatureSet(HostFeature::ALL.into_iter().collect())
132    }
133
134    /// What the host release `version` understood.
135    pub fn at(version: HostVersion) -> Self {
136        HostFeatureSet(
137            HostFeature::ALL
138                .into_iter()
139                .filter(|feature| feature.since() <= version)
140                .collect(),
141        )
142    }
143
144    pub fn contains(&self, feature: HostFeature) -> bool {
145        self.0.contains(&feature)
146    }
147
148    pub fn ids(&self) -> Vec<&'static str> {
149        self.0.iter().map(|feature| feature.id()).collect()
150    }
151}
152
153/// Features this manifest needs, computed from its content.
154pub fn required_features(manifest: &AppManifest) -> BTreeSet<HostFeature> {
155    let mut features = BTreeSet::new();
156    let Some(ui) = &manifest.ui else {
157        return features;
158    };
159    if ui.kind == AppUiKind::Widget {
160        features.insert(HostFeature::UiWidget);
161        if ui.data.is_some() {
162            features.insert(HostFeature::UiWidgetData);
163        }
164    }
165    if !ui.composes.is_empty() {
166        features.insert(HostFeature::UiComposition);
167    }
168    if !ui.surfaces.is_empty() {
169        features.insert(HostFeature::UiSurfaces);
170    }
171    features
172}
173
174/// The first host release that supports every feature in `features`.
175pub fn min_host_for(features: &BTreeSet<HostFeature>) -> Option<HostVersion> {
176    features.iter().map(|feature| feature.since()).max()
177}
178
179/// Markdown table for `docs/development/stages/09-compatibility.md`.
180pub fn render_host_feature_table() -> String {
181    let mut table = String::from(
182        "| Feature | Unlocks | Derived when | First host |\n|---|---|---|---|\n",
183    );
184    for feature in HostFeature::ALL {
185        table.push_str(&format!(
186            "| `{}` | {} | {} | {} |\n",
187            feature.id(),
188            feature.unlocks(),
189            feature.derived_when(),
190            feature.since()
191        ));
192    }
193    table
194}
195
196/// The `host_contract` block written into `manifest.json` at build time.
197///
198/// Deliberately tolerant: a newer toolchain may add stamp members or write a
199/// `min_host` this host cannot parse, and neither may turn a "this Node is too
200/// old" answer into "the package is broken". Unknown members are ignored and an
201/// unparseable `min_host` reads as absent.
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203pub struct HostContractStamp {
204    /// Feature ids, sorted by id. Kept as strings so ids from newer toolchains survive parsing.
205    pub features: Vec<String>,
206    #[serde(default, deserialize_with = "lenient_min_host", skip_serializing_if = "Option::is_none")]
207    pub min_host: Option<HostVersion>,
208}
209
210impl HostContractStamp {
211    /// Same feature set and `min_host`, regardless of feature order.
212    pub fn same_contract(&self, other: &HostContractStamp) -> bool {
213        let mine: BTreeSet<&str> = self.features.iter().map(String::as_str).collect();
214        let theirs: BTreeSet<&str> = other.features.iter().map(String::as_str).collect();
215        mine == theirs && self.min_host == other.min_host
216    }
217}
218
219fn lenient_min_host<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<HostVersion>, D::Error> {
220    let raw = serde_json::Value::deserialize(deserializer)?;
221    Ok(raw.as_str().and_then(HostVersion::parse))
222}
223
224/// The stamp `node-app contract stamp` writes for this manifest.
225pub fn stamp_for(manifest: &AppManifest) -> HostContractStamp {
226    let features = required_features(manifest);
227    let mut ids: Vec<String> = features.iter().map(|feature| feature.id().to_string()).collect();
228    ids.sort();
229    HostContractStamp {
230        features: ids,
231        min_host: min_host_for(&features),
232    }
233}
234
235/// Why a host (or a target host) cannot load a manifest.
236#[derive(Debug, Clone, PartialEq, Eq)]
237pub enum ManifestRejection {
238    /// The manifest is fine; this host is too old for it.
239    HostFeatureMissing {
240        features: Vec<String>,
241        min_host: Option<HostVersion>,
242    },
243    /// The package itself is broken.
244    Invalid { message: String },
245}
246
247impl fmt::Display for ManifestRejection {
248    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
249        match self {
250            ManifestRejection::HostFeatureMissing { features, min_host } => {
251                write!(
252                    f,
253                    "requires host features this Node does not support: {}",
254                    features.join(", ")
255                )?;
256                if let Some(version) = min_host {
257                    write!(f, " (first available in Node {version})")?;
258                }
259                Ok(())
260            }
261            ManifestRejection::Invalid { message } => f.write_str(message),
262        }
263    }
264}
265
266/// Runs on raw JSON before full parsing, so a manifest using constructs this
267/// host cannot even deserialize is still reported as "too new", not "broken".
268/// A missing or malformed stamp is left for full parsing to report.
269///
270/// Reads only `host_contract.features` and `host_contract.min_host`, leniently,
271/// so a stamp shape from a newer toolchain (extra members, a `min_host` this
272/// host cannot parse) still yields "too new" rather than "broken".
273pub fn precheck_stamp(json: &str, supported: &HostFeatureSet) -> Result<(), ManifestRejection> {
274    let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
275        return Ok(());
276    };
277    let Some(features) = value
278        .get("host_contract")
279        .and_then(|stamp| stamp.get("features"))
280        .and_then(serde_json::Value::as_array)
281    else {
282        return Ok(());
283    };
284    let missing: Vec<String> = features
285        .iter()
286        .filter_map(serde_json::Value::as_str)
287        .filter(|id| !HostFeature::from_id(id).is_some_and(|feature| supported.contains(feature)))
288        .map(str::to_string)
289        .collect();
290    if missing.is_empty() {
291        return Ok(());
292    }
293    let min_host = value["host_contract"]
294        .get("min_host")
295        .and_then(serde_json::Value::as_str)
296        .and_then(HostVersion::parse);
297    Err(ManifestRejection::HostFeatureMissing { features: missing, min_host })
298}
299
300/// Checks a parsed manifest against `supported`, and that any stamp is not stale.
301pub fn check_host_contract(
302    manifest: &AppManifest,
303    supported: &HostFeatureSet,
304) -> Result<(), ManifestRejection> {
305    let derived = required_features(manifest);
306    let missing: BTreeSet<HostFeature> = derived
307        .iter()
308        .copied()
309        .filter(|feature| !supported.contains(*feature))
310        .collect();
311    if !missing.is_empty() {
312        let mut features: Vec<String> = missing.iter().map(|feature| feature.id().to_string()).collect();
313        features.sort();
314        return Err(ManifestRejection::HostFeatureMissing {
315            features,
316            min_host: min_host_for(&missing),
317        });
318    }
319    if let Some(stamp) = &manifest.host_contract {
320        let unstamped: Vec<&str> = derived
321            .iter()
322            .map(|feature| feature.id())
323            .filter(|id| !stamp.features.iter().any(|stamped| stamped == id))
324            .collect();
325        if !unstamped.is_empty() {
326            return Err(ManifestRejection::Invalid {
327                message: format!(
328                    "host_contract is stale: missing {}; run `node-app contract stamp`",
329                    unstamped.join(", ")
330                ),
331            });
332        }
333        if let (Some(required), stamped) = (min_host_for(&derived), stamp.min_host) {
334            if stamped.is_none_or(|version| version < required) {
335                return Err(ManifestRejection::Invalid {
336                    message: format!(
337                        "host_contract is stale: min_host must be at least {required}; run `node-app contract stamp`"
338                    ),
339                });
340            }
341        }
342    }
343    Ok(())
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use crate::AppManifest;
350
351    const HASH: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
352
353    fn manifest(ui: &str) -> AppManifest {
354        AppManifest::from_json(&format!(
355            r#"{{"name":"demo","version":"1.0.0","app_type":"bun","ui":{ui}}}"#
356        ))
357        .expect("valid manifest")
358    }
359
360    fn ui(kind: &str, extra: &str) -> String {
361        format!(
362            r#"{{"kind":"{kind}","entry":"ui/main.js","title":"Demo","ui_api":1,"integrity":{{"ui/main.js":"{HASH}"}}{extra}}}"#
363        )
364    }
365
366    #[test]
367    fn plain_stage_requires_nothing() {
368        assert!(required_features(&manifest(&ui("stage", ""))).is_empty());
369    }
370
371    #[test]
372    fn widget_requires_ui_widget() {
373        let features = required_features(&manifest(&ui("widget", "")));
374        assert_eq!(features.into_iter().collect::<Vec<_>>(), vec![HostFeature::UiWidget]);
375    }
376
377    #[test]
378    fn widget_with_data_requires_widget_data() {
379        let mut m = manifest(&ui("widget", ""));
380        m.ui.as_mut().unwrap().data = Some(
381            serde_json::from_str(
382                r#"{"namespace":"demo","offline":"online-only","sync":"snapshot","queries":[],"streams":[]}"#,
383            )
384            .unwrap(),
385        );
386        let features: Vec<_> = required_features(&m).into_iter().collect();
387        assert_eq!(features, vec![HostFeature::UiWidget, HostFeature::UiWidgetData]);
388    }
389
390    #[test]
391    fn composing_stage_requires_composition() {
392        let features: Vec<_> = required_features(&manifest(&ui("stage", r#","composes":["child"]"#)))
393            .into_iter()
394            .collect();
395        assert_eq!(features, vec![HostFeature::UiComposition]);
396    }
397
398    #[test]
399    fn ids_round_trip_and_are_stable() {
400        let ids: Vec<_> = HostFeature::ALL.iter().map(|f| f.id()).collect();
401        assert_eq!(ids, vec!["ui.surfaces", "ui.widget", "ui.composition", "ui.widget-data"]);
402        for feature in HostFeature::ALL {
403            assert_eq!(HostFeature::from_id(feature.id()), Some(feature));
404        }
405        assert_eq!(HostFeature::from_id("ui.future"), None);
406    }
407
408    #[test]
409    fn feature_sets_follow_release_history() {
410        let old = HostFeatureSet::at(HostVersion::new(7, 1, 17));
411        assert!(old.contains(HostFeature::UiWidget));
412        assert!(old.contains(HostFeature::UiComposition));
413        assert!(!old.contains(HostFeature::UiWidgetData));
414        assert!(HostFeatureSet::at(HostVersion::new(7, 2, 0)).contains(HostFeature::UiWidgetData));
415        assert!(!HostFeatureSet::at(HostVersion::new(7, 0, 10)).contains(HostFeature::UiSurfaces));
416        assert_eq!(HostFeatureSet::current().ids().len(), HostFeature::ALL.len());
417    }
418
419    #[test]
420    fn host_version_parses_tags_and_package_suffixes() {
421        assert_eq!(HostVersion::parse("7.2.0"), Some(HostVersion::new(7, 2, 0)));
422        assert_eq!(HostVersion::parse("v7.1.17"), Some(HostVersion::new(7, 1, 17)));
423        assert_eq!(HostVersion::parse("7.1.17+composed.abc"), Some(HostVersion::new(7, 1, 17)));
424        assert_eq!(HostVersion::parse("7.1"), None);
425        assert_eq!(HostVersion::parse("seven"), None);
426        assert_eq!(HostVersion::new(7, 2, 0).to_string(), "7.2.0");
427    }
428
429    #[test]
430    fn min_host_is_the_latest_first_release() {
431        let set: BTreeSet<_> = [HostFeature::UiWidget, HostFeature::UiWidgetData].into_iter().collect();
432        assert_eq!(min_host_for(&set), Some(HostVersion::new(7, 2, 0)));
433        assert_eq!(min_host_for(&BTreeSet::new()), None);
434    }
435
436    #[test]
437    fn feature_table_lists_every_feature() {
438        let table = render_host_feature_table();
439        for feature in HostFeature::ALL {
440            assert!(table.contains(&format!("`{}`", feature.id())), "{table}");
441        }
442    }
443
444    const DATA: &str = r#","data":{"namespace":"demo","offline":"online-only","sync":"snapshot","queries":[],"streams":[]}"#;
445
446    #[test]
447    fn widget_may_declare_app_data() {
448        let m = manifest(&ui("widget", DATA));
449        assert!(m.ui.unwrap().data.is_some());
450    }
451
452    #[test]
453    fn widget_app_data_gets_stage_rules() {
454        let error = AppManifest::from_json(&format!(
455            r#"{{"name":"demo","version":"1.0.0","app_type":"bun","ui":{}}}"#,
456            ui("widget", r#","data":{"namespace":"other","offline":"online-only","sync":"snapshot","queries":[],"streams":[]}"#)
457        ))
458        .expect_err("namespace must equal the app name");
459        assert!(error.contains("must equal the app name"), "{error}");
460    }
461
462    #[test]
463    fn widget_nav_is_still_rejected() {
464        let error = AppManifest::from_json(&format!(
465            r#"{{"name":"demo","version":"1.0.0","app_type":"bun","ui":{}}}"#,
466            ui("widget", r#","nav":{"section":"default","order":1}"#)
467        ))
468        .expect_err("widgets have no nav");
469        assert!(error.contains("nav"), "{error}");
470    }
471
472    #[test]
473    fn stamp_is_sorted_derived_features_with_min_host() {
474        let stamp = stamp_for(&manifest(&ui("widget", DATA)));
475        assert_eq!(stamp.features, vec!["ui.widget", "ui.widget-data"]);
476        assert_eq!(stamp.min_host, Some(HostVersion::new(7, 2, 0)));
477        assert_eq!(
478            serde_json::to_string(&stamp).unwrap(),
479            r#"{"features":["ui.widget","ui.widget-data"],"min_host":"7.2.0"}"#
480        );
481        let empty = stamp_for(&manifest(&ui("stage", "")));
482        assert_eq!(serde_json::to_string(&empty).unwrap(), r#"{"features":[]}"#);
483    }
484
485    #[test]
486    fn check_accepts_absent_and_fresh_stamps() {
487        let m = manifest(&ui("widget", DATA));
488        assert_eq!(check_host_contract(&m, &HostFeatureSet::current()), Ok(()));
489        let mut stamped = m.clone();
490        stamped.host_contract = Some(stamp_for(&m));
491        assert_eq!(check_host_contract(&stamped, &HostFeatureSet::current()), Ok(()));
492    }
493
494    #[test]
495    fn check_rejects_a_stale_stamp() {
496        let mut m = manifest(&ui("widget", DATA));
497        m.host_contract = Some(HostContractStamp {
498            features: vec!["ui.widget".into()],
499            min_host: Some(HostVersion::new(7, 1, 11)),
500        });
501        match check_host_contract(&m, &HostFeatureSet::current()) {
502            Err(ManifestRejection::Invalid { message }) => {
503                assert!(message.contains("stale"), "{message}");
504                assert!(message.contains("ui.widget-data"), "{message}");
505            }
506            other => panic!("expected stale stamp rejection, got {other:?}"),
507        }
508    }
509
510    #[test]
511    fn check_reports_missing_features_for_an_older_target() {
512        let m = manifest(&ui("widget", DATA));
513        assert_eq!(
514            check_host_contract(&m, &HostFeatureSet::at(HostVersion::new(7, 1, 17))),
515            Err(ManifestRejection::HostFeatureMissing {
516                features: vec!["ui.widget-data".into()],
517                min_host: Some(HostVersion::new(7, 2, 0)),
518            })
519        );
520    }
521
522    #[test]
523    fn precheck_reports_unknown_stamped_feature_with_stamped_min_host() {
524        let json = format!(
525            r#"{{"name":"demo","version":"1.0.0","app_type":"bun","host_contract":{{"features":["ui.future","ui.widget"],"min_host":"9.0.0"}},"ui":{}}}"#,
526            ui("widget", "")
527        );
528        assert_eq!(
529            precheck_stamp(&json, &HostFeatureSet::current()),
530            Err(ManifestRejection::HostFeatureMissing {
531                features: vec!["ui.future".into()],
532                min_host: Some(HostVersion::new(9, 0, 0)),
533            })
534        );
535    }
536
537    #[test]
538    fn precheck_tolerates_a_newer_stamp_shape_and_the_manifest_still_parses() {
539        let json = format!(
540            r#"{{"name":"demo","version":"1.0.0","app_type":"bun","host_contract":{{"features":["ui.future"],"min_host":"9.0.0","extra":1}},"ui":{}}}"#,
541            ui("widget", "")
542        );
543        assert_eq!(
544            precheck_stamp(&json, &HostFeatureSet::current()),
545            Err(ManifestRejection::HostFeatureMissing {
546                features: vec!["ui.future".into()],
547                min_host: Some(HostVersion::new(9, 0, 0)),
548            })
549        );
550        let manifest = AppManifest::from_json(&json).expect("unknown stamp members are tolerated");
551        assert_eq!(manifest.host_contract.unwrap().min_host, Some(HostVersion::new(9, 0, 0)));
552    }
553
554    #[test]
555    fn unparseable_stamped_min_host_reads_as_absent() {
556        let json = format!(
557            r#"{{"name":"demo","version":"1.0.0","app_type":"bun","host_contract":{{"features":["ui.future","ui.widget"],"min_host":"nine"}},"ui":{}}}"#,
558            ui("widget", "")
559        );
560        assert_eq!(
561            precheck_stamp(&json, &HostFeatureSet::current()),
562            Err(ManifestRejection::HostFeatureMissing { features: vec!["ui.future".into()], min_host: None })
563        );
564        let manifest = AppManifest::from_json(&json).expect("an unparseable min_host is tolerated");
565        let stamp = manifest.host_contract.clone().unwrap();
566        assert_eq!(stamp.min_host, None);
567        assert_eq!(serde_json::to_string(&stamp).unwrap(), r#"{"features":["ui.future","ui.widget"]}"#);
568        // Known features with an unusable min_host are still a stale stamp.
569        let mut widget = manifest.clone();
570        widget.host_contract = Some(HostContractStamp { features: vec!["ui.widget".into()], min_host: None });
571        assert!(matches!(
572            check_host_contract(&widget, &HostFeatureSet::current()),
573            Err(ManifestRejection::Invalid { .. })
574        ));
575    }
576
577    #[test]
578    fn stamp_features_are_sorted_by_id_and_compared_as_sets() {
579        let m = manifest(&ui("widget", &format!(r#"{DATA},"composes":["child"]"#)));
580        let stamp = stamp_for(&m);
581        assert_eq!(stamp.features, vec!["ui.composition", "ui.widget", "ui.widget-data"]);
582        let reordered = HostContractStamp {
583            features: vec!["ui.widget-data".into(), "ui.composition".into(), "ui.widget".into()],
584            min_host: stamp.min_host,
585        };
586        assert!(stamp.same_contract(&reordered));
587        let mut stamped = m.clone();
588        stamped.host_contract = Some(reordered);
589        assert_eq!(check_host_contract(&stamped, &HostFeatureSet::current()), Ok(()));
590        let fewer = HostContractStamp { features: vec!["ui.widget".into()], min_host: stamp.min_host };
591        assert!(!stamp.same_contract(&fewer));
592    }
593
594    #[test]
595    fn precheck_ignores_absent_or_malformed_stamps() {
596        assert_eq!(precheck_stamp(r#"{"name":"demo"}"#, &HostFeatureSet::current()), Ok(()));
597        assert_eq!(precheck_stamp(r#"{"host_contract":7}"#, &HostFeatureSet::current()), Ok(()));
598        assert_eq!(precheck_stamp("not json", &HostFeatureSet::current()), Ok(()));
599    }
600
601    #[test]
602    fn rejection_messages_are_actionable() {
603        let missing = ManifestRejection::HostFeatureMissing {
604            features: vec!["ui.widget-data".into()],
605            min_host: Some(HostVersion::new(7, 2, 0)),
606        };
607        assert_eq!(
608            missing.to_string(),
609            "requires host features this Node does not support: ui.widget-data (first available in Node 7.2.0)"
610        );
611    }
612}