1use std::collections::BTreeSet;
9use std::fmt;
10
11use serde::{Deserialize, Deserializer, Serialize, Serializer};
12
13use crate::{AppManifest, AppType, AppUiKind, ASSISTANT_SLOT};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
17pub enum HostFeature {
18 UiSurfaces,
19 UiWidget,
20 UiComposition,
21 UiWidgetData,
22 UiCommands,
23 UiAssistantSlot,
24 UiWidgetUiOnly,
25}
26
27impl HostFeature {
28 pub const ALL: [HostFeature; 7] = [
29 HostFeature::UiSurfaces,
30 HostFeature::UiWidget,
31 HostFeature::UiComposition,
32 HostFeature::UiWidgetData,
33 HostFeature::UiCommands,
34 HostFeature::UiAssistantSlot,
35 HostFeature::UiWidgetUiOnly,
36 ];
37
38 pub fn id(self) -> &'static str {
40 match self {
41 HostFeature::UiSurfaces => "ui.surfaces",
42 HostFeature::UiWidget => "ui.widget",
43 HostFeature::UiComposition => "ui.composition",
44 HostFeature::UiWidgetData => "ui.widget-data",
45 HostFeature::UiCommands => "ui.commands",
46 HostFeature::UiAssistantSlot => "ui.assistant-slot",
47 HostFeature::UiWidgetUiOnly => "ui.widget-ui-only",
48 }
49 }
50
51 pub fn from_id(id: &str) -> Option<HostFeature> {
52 HostFeature::ALL
53 .into_iter()
54 .find(|feature| feature.id() == id)
55 }
56
57 pub fn unlocks(self) -> &'static str {
58 match self {
59 HostFeature::UiSurfaces => "`ui.surfaces` shell-chrome contributions",
60 HostFeature::UiWidget => "`ui.kind: \"widget\"` UIs composed by a parent stage",
61 HostFeature::UiComposition => "`ui.composes` and the composition input/event port",
62 HostFeature::UiWidgetData => "`ui.data` app-data declarations on a widget",
63 HostFeature::UiCommands => {
64 "`requires.commands` kernel commands sent through `ctx.command`"
65 }
66 HostFeature::UiAssistantSlot => "the single-occupant `assistant` shell surface slot",
67 HostFeature::UiWidgetUiOnly => "a composed widget with no backend process",
68 }
69 }
70
71 pub fn derived_when(self) -> &'static str {
72 match self {
73 HostFeature::UiSurfaces => "`ui.surfaces` is non-empty",
74 HostFeature::UiWidget => "`ui.kind` is `widget`",
75 HostFeature::UiComposition => "`ui.composes` is non-empty",
76 HostFeature::UiWidgetData => "`ui.kind` is `widget` and `ui.data` is present",
77 HostFeature::UiCommands => "any `requires.commands` (stage or surface) is non-empty",
78 HostFeature::UiAssistantSlot => "a `ui.surfaces` entry uses slot `assistant`",
79 HostFeature::UiWidgetUiOnly => {
80 "`ui.kind` is `widget` and `app_type` is `ui-only` (or neither `app_type` nor `entrypoint` is set)"
81 }
82 }
83 }
84
85 pub fn since(self) -> HostVersion {
87 match self {
88 HostFeature::UiSurfaces => HostVersion::new(7, 0, 11),
89 HostFeature::UiWidget => HostVersion::new(7, 1, 11),
90 HostFeature::UiComposition => HostVersion::new(7, 1, 11),
91 HostFeature::UiWidgetData => HostVersion::new(7, 2, 0),
93 HostFeature::UiCommands => HostVersion::new(7, 3, 0),
95 HostFeature::UiAssistantSlot => HostVersion::new(7, 3, 0),
97 HostFeature::UiWidgetUiOnly => HostVersion::new(7, 3, 0),
101 }
102 }
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
107pub struct HostVersion {
108 pub major: u32,
109 pub minor: u32,
110 pub patch: u32,
111}
112
113impl HostVersion {
114 pub const fn new(major: u32, minor: u32, patch: u32) -> Self {
115 HostVersion {
116 major,
117 minor,
118 patch,
119 }
120 }
121
122 pub fn parse(value: &str) -> Option<HostVersion> {
124 let value = value.strip_prefix('v').unwrap_or(value);
125 let core = value.split(['+', '-', '~']).next()?;
126 let mut parts = core.split('.');
127 let major = parts.next()?.parse().ok()?;
128 let minor = parts.next()?.parse().ok()?;
129 let patch = parts.next()?.parse().ok()?;
130 if parts.next().is_some() {
131 return None;
132 }
133 Some(HostVersion::new(major, minor, patch))
134 }
135}
136
137impl fmt::Display for HostVersion {
138 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
140 }
141}
142
143impl Serialize for HostVersion {
144 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
145 serializer.collect_str(self)
146 }
147}
148
149impl<'de> Deserialize<'de> for HostVersion {
150 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
151 let raw = String::deserialize(deserializer)?;
152 HostVersion::parse(&raw)
153 .ok_or_else(|| serde::de::Error::custom(format!("invalid host version '{raw}'")))
154 }
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct HostFeatureSet(BTreeSet<HostFeature>);
160
161impl HostFeatureSet {
162 pub fn current() -> Self {
164 HostFeatureSet(HostFeature::ALL.into_iter().collect())
165 }
166
167 pub fn at(version: HostVersion) -> Self {
169 HostFeatureSet(
170 HostFeature::ALL
171 .into_iter()
172 .filter(|feature| feature.since() <= version)
173 .collect(),
174 )
175 }
176
177 pub fn contains(&self, feature: HostFeature) -> bool {
178 self.0.contains(&feature)
179 }
180
181 pub fn ids(&self) -> Vec<&'static str> {
182 self.0.iter().map(|feature| feature.id()).collect()
183 }
184}
185
186pub fn required_features(manifest: &AppManifest) -> BTreeSet<HostFeature> {
188 let mut features = BTreeSet::new();
189 let Some(ui) = &manifest.ui else {
190 return features;
191 };
192 if ui.kind == AppUiKind::Widget {
193 features.insert(HostFeature::UiWidget);
194 if ui.data.is_some() {
195 features.insert(HostFeature::UiWidgetData);
196 }
197 if manifest.app_type == AppType::UiOnly {
200 features.insert(HostFeature::UiWidgetUiOnly);
201 }
202 }
203 if !ui.composes.is_empty() {
204 features.insert(HostFeature::UiComposition);
205 }
206 if !ui.surfaces.is_empty() {
207 features.insert(HostFeature::UiSurfaces);
208 }
209 let any_commands = !ui.requires.commands.is_empty()
210 || ui
211 .surfaces
212 .iter()
213 .any(|surface| !surface.requires.commands.is_empty());
214 if any_commands {
215 features.insert(HostFeature::UiCommands);
216 }
217 if ui
218 .surfaces
219 .iter()
220 .any(|surface| surface.slot == ASSISTANT_SLOT)
221 {
222 features.insert(HostFeature::UiAssistantSlot);
223 }
224 features
225}
226
227pub fn min_host_for(features: &BTreeSet<HostFeature>) -> Option<HostVersion> {
229 features.iter().map(|feature| feature.since()).max()
230}
231
232pub fn render_host_feature_table() -> String {
234 let mut table =
235 String::from("| Feature | Unlocks | Derived when | First host |\n|---|---|---|---|\n");
236 for feature in HostFeature::ALL {
237 table.push_str(&format!(
238 "| `{}` | {} | {} | {} |\n",
239 feature.id(),
240 feature.unlocks(),
241 feature.derived_when(),
242 feature.since()
243 ));
244 }
245 table
246}
247
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255pub struct HostContractStamp {
256 pub features: Vec<String>,
258 #[serde(
259 default,
260 deserialize_with = "lenient_min_host",
261 skip_serializing_if = "Option::is_none"
262 )]
263 pub min_host: Option<HostVersion>,
264}
265
266impl HostContractStamp {
267 pub fn same_contract(&self, other: &HostContractStamp) -> bool {
269 let mine: BTreeSet<&str> = self.features.iter().map(String::as_str).collect();
270 let theirs: BTreeSet<&str> = other.features.iter().map(String::as_str).collect();
271 mine == theirs && self.min_host == other.min_host
272 }
273}
274
275fn lenient_min_host<'de, D: Deserializer<'de>>(
276 deserializer: D,
277) -> Result<Option<HostVersion>, D::Error> {
278 let raw = serde_json::Value::deserialize(deserializer)?;
279 Ok(raw.as_str().and_then(HostVersion::parse))
280}
281
282pub fn stamp_for(manifest: &AppManifest) -> HostContractStamp {
284 let features = required_features(manifest);
285 let mut ids: Vec<String> = features
286 .iter()
287 .map(|feature| feature.id().to_string())
288 .collect();
289 ids.sort();
290 HostContractStamp {
291 features: ids,
292 min_host: min_host_for(&features),
293 }
294}
295
296#[derive(Debug, Clone, PartialEq, Eq)]
298pub enum ManifestRejection {
299 HostFeatureMissing {
301 features: Vec<String>,
302 min_host: Option<HostVersion>,
303 },
304 Invalid { message: String },
306}
307
308impl fmt::Display for ManifestRejection {
309 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
310 match self {
311 ManifestRejection::HostFeatureMissing { features, min_host } => {
312 write!(
313 f,
314 "requires host features this Node does not support: {}",
315 features.join(", ")
316 )?;
317 if let Some(version) = min_host {
318 write!(f, " (first available in Node {version})")?;
319 }
320 Ok(())
321 }
322 ManifestRejection::Invalid { message } => f.write_str(message),
323 }
324 }
325}
326
327pub fn precheck_stamp(json: &str, supported: &HostFeatureSet) -> Result<(), ManifestRejection> {
335 let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
336 return Ok(());
337 };
338 let Some(features) = value
339 .get("host_contract")
340 .and_then(|stamp| stamp.get("features"))
341 .and_then(serde_json::Value::as_array)
342 else {
343 return Ok(());
344 };
345 let missing: Vec<String> = features
346 .iter()
347 .filter_map(serde_json::Value::as_str)
348 .filter(|id| !HostFeature::from_id(id).is_some_and(|feature| supported.contains(feature)))
349 .map(str::to_string)
350 .collect();
351 if missing.is_empty() {
352 return Ok(());
353 }
354 let min_host = value["host_contract"]
355 .get("min_host")
356 .and_then(serde_json::Value::as_str)
357 .and_then(HostVersion::parse);
358 Err(ManifestRejection::HostFeatureMissing {
359 features: missing,
360 min_host,
361 })
362}
363
364pub fn check_host_contract(
366 manifest: &AppManifest,
367 supported: &HostFeatureSet,
368) -> Result<(), ManifestRejection> {
369 let derived = required_features(manifest);
370 let missing: BTreeSet<HostFeature> = derived
371 .iter()
372 .copied()
373 .filter(|feature| !supported.contains(*feature))
374 .collect();
375 if !missing.is_empty() {
376 let mut features: Vec<String> = missing
377 .iter()
378 .map(|feature| feature.id().to_string())
379 .collect();
380 features.sort();
381 return Err(ManifestRejection::HostFeatureMissing {
382 features,
383 min_host: min_host_for(&missing),
384 });
385 }
386 if let Some(stamp) = &manifest.host_contract {
387 let unstamped: Vec<&str> = derived
388 .iter()
389 .map(|feature| feature.id())
390 .filter(|id| !stamp.features.iter().any(|stamped| stamped == id))
391 .collect();
392 if !unstamped.is_empty() {
393 return Err(ManifestRejection::Invalid {
394 message: format!(
395 "host_contract is stale: missing {}; run `node-app contract stamp`",
396 unstamped.join(", ")
397 ),
398 });
399 }
400 if let (Some(required), stamped) = (min_host_for(&derived), stamp.min_host) {
401 if stamped.is_none_or(|version| version < required) {
402 return Err(ManifestRejection::Invalid {
403 message: format!(
404 "host_contract is stale: min_host must be at least {required}; run `node-app contract stamp`"
405 ),
406 });
407 }
408 }
409 }
410 Ok(())
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416 use crate::AppManifest;
417
418 const HASH: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
419
420 fn manifest(ui: &str) -> AppManifest {
421 AppManifest::from_json(&format!(
422 r#"{{"name":"demo","version":"1.0.0","app_type":"bun","ui":{ui}}}"#
423 ))
424 .expect("valid manifest")
425 }
426
427 fn ui(kind: &str, extra: &str) -> String {
428 format!(
429 r#"{{"kind":"{kind}","entry":"ui/main.js","title":"Demo","ui_api":1,"integrity":{{"ui/main.js":"{HASH}"}}{extra}}}"#
430 )
431 }
432
433 #[test]
434 fn plain_stage_requires_nothing() {
435 assert!(required_features(&manifest(&ui("stage", ""))).is_empty());
436 }
437
438 #[test]
439 fn widget_requires_ui_widget() {
440 let features = required_features(&manifest(&ui("widget", "")));
441 assert_eq!(
442 features.into_iter().collect::<Vec<_>>(),
443 vec![HostFeature::UiWidget]
444 );
445 }
446
447 #[test]
448 fn widget_with_data_requires_widget_data() {
449 let mut m = manifest(&ui("widget", ""));
450 m.ui.as_mut().unwrap().data = Some(
451 serde_json::from_str(
452 r#"{"namespace":"demo","offline":"online-only","sync":"snapshot","queries":[],"streams":[]}"#,
453 )
454 .unwrap(),
455 );
456 let features: Vec<_> = required_features(&m).into_iter().collect();
457 assert_eq!(
458 features,
459 vec![HostFeature::UiWidget, HostFeature::UiWidgetData]
460 );
461 }
462
463 fn ui_only(ui: &str) -> AppManifest {
465 AppManifest::from_json(&format!(r#"{{"name":"demo","version":"1.0.0","ui":{ui}}}"#))
466 .expect("valid UI-only manifest")
467 }
468
469 #[test]
470 fn ui_only_widget_requires_widget_ui_only() {
471 let m = ui_only(&ui("widget", ""));
472 assert_eq!(m.app_type, AppType::UiOnly);
473 let features: Vec<_> = required_features(&m).into_iter().collect();
474 assert_eq!(
475 features,
476 vec![HostFeature::UiWidget, HostFeature::UiWidgetUiOnly]
477 );
478 let stamp = stamp_for(&m);
479 assert_eq!(stamp.features, vec!["ui.widget", "ui.widget-ui-only"]);
480 assert_eq!(stamp.min_host, Some(HostVersion::new(7, 3, 0)));
481 }
482
483 #[test]
486 fn ui_only_stage_and_bun_widget_do_not_require_widget_ui_only() {
487 assert!(required_features(&ui_only(&ui("stage", ""))).is_empty());
488 assert!(
489 !required_features(&manifest(&ui("widget", ""))).contains(&HostFeature::UiWidgetUiOnly)
490 );
491 }
492
493 #[test]
495 fn a_host_before_widget_ui_only_reports_it_missing() {
496 let m = ui_only(&ui("widget", ""));
497 assert_eq!(
498 check_host_contract(&m, &HostFeatureSet::at(HostVersion::new(7, 2, 5))),
499 Err(ManifestRejection::HostFeatureMissing {
500 features: vec!["ui.widget-ui-only".into()],
501 min_host: Some(HostVersion::new(7, 3, 0)),
502 })
503 );
504 assert_eq!(check_host_contract(&m, &HostFeatureSet::current()), Ok(()));
505 let mut stale = m.clone();
507 stale.host_contract = Some(HostContractStamp {
508 features: vec!["ui.widget".into()],
509 min_host: Some(HostVersion::new(7, 1, 11)),
510 });
511 assert!(matches!(
512 check_host_contract(&stale, &HostFeatureSet::current()),
513 Err(ManifestRejection::Invalid { .. })
514 ));
515 }
516
517 #[test]
520 fn precheck_on_an_older_host_reports_widget_ui_only_as_too_new() {
521 let json = format!(
522 r#"{{"name":"demo","version":"1.0.0","host_contract":{{"features":["ui.widget","ui.widget-ui-only"],"min_host":"7.3.0"}},"ui":{}}}"#,
523 ui("widget", "")
524 );
525 let before = HostFeatureSet(
526 HostFeature::ALL
527 .into_iter()
528 .filter(|feature| *feature != HostFeature::UiWidgetUiOnly)
529 .collect(),
530 );
531 assert_eq!(
532 precheck_stamp(&json, &before),
533 Err(ManifestRejection::HostFeatureMissing {
534 features: vec!["ui.widget-ui-only".into()],
535 min_host: Some(HostVersion::new(7, 3, 0)),
536 })
537 );
538 assert_eq!(precheck_stamp(&json, &HostFeatureSet::current()), Ok(()));
539 }
540
541 #[test]
542 fn composing_stage_requires_composition() {
543 let features: Vec<_> =
544 required_features(&manifest(&ui("stage", r#","composes":["child"]"#)))
545 .into_iter()
546 .collect();
547 assert_eq!(features, vec![HostFeature::UiComposition]);
548 }
549
550 #[test]
551 fn ids_round_trip_and_are_stable() {
552 let ids: Vec<_> = HostFeature::ALL.iter().map(|f| f.id()).collect();
553 assert_eq!(
554 ids,
555 vec![
556 "ui.surfaces",
557 "ui.widget",
558 "ui.composition",
559 "ui.widget-data",
560 "ui.commands",
561 "ui.assistant-slot",
562 "ui.widget-ui-only"
563 ]
564 );
565 for feature in HostFeature::ALL {
566 assert_eq!(HostFeature::from_id(feature.id()), Some(feature));
567 }
568 assert_eq!(HostFeature::from_id("ui.future"), None);
569 }
570
571 #[test]
572 fn feature_sets_follow_release_history() {
573 let old = HostFeatureSet::at(HostVersion::new(7, 1, 17));
574 assert!(old.contains(HostFeature::UiWidget));
575 assert!(old.contains(HostFeature::UiComposition));
576 assert!(!old.contains(HostFeature::UiWidgetData));
577 assert!(HostFeatureSet::at(HostVersion::new(7, 2, 0)).contains(HostFeature::UiWidgetData));
578 assert!(
579 !HostFeatureSet::at(HostVersion::new(7, 2, 4)).contains(HostFeature::UiWidgetUiOnly)
580 );
581 assert!(HostFeatureSet::at(HostVersion::new(7, 3, 0)).contains(HostFeature::UiWidgetUiOnly));
582 assert!(!HostFeatureSet::at(HostVersion::new(7, 0, 10)).contains(HostFeature::UiSurfaces));
583 assert_eq!(
584 HostFeatureSet::current().ids().len(),
585 HostFeature::ALL.len()
586 );
587 }
588
589 #[test]
590 fn host_version_parses_tags_and_package_suffixes() {
591 assert_eq!(HostVersion::parse("7.2.0"), Some(HostVersion::new(7, 2, 0)));
592 assert_eq!(
593 HostVersion::parse("v7.1.17"),
594 Some(HostVersion::new(7, 1, 17))
595 );
596 assert_eq!(
597 HostVersion::parse("7.1.17+composed.abc"),
598 Some(HostVersion::new(7, 1, 17))
599 );
600 assert_eq!(HostVersion::parse("7.1"), None);
601 assert_eq!(HostVersion::parse("seven"), None);
602 assert_eq!(HostVersion::new(7, 2, 0).to_string(), "7.2.0");
603 }
604
605 #[test]
606 fn min_host_is_the_latest_first_release() {
607 let set: BTreeSet<_> = [HostFeature::UiWidget, HostFeature::UiWidgetData]
608 .into_iter()
609 .collect();
610 assert_eq!(min_host_for(&set), Some(HostVersion::new(7, 2, 0)));
611 assert_eq!(min_host_for(&BTreeSet::new()), None);
612 }
613
614 #[test]
615 fn feature_table_lists_every_feature() {
616 let table = render_host_feature_table();
617 for feature in HostFeature::ALL {
618 assert!(table.contains(&format!("`{}`", feature.id())), "{table}");
619 }
620 }
621
622 const DATA: &str = r#","data":{"namespace":"demo","offline":"online-only","sync":"snapshot","queries":[],"streams":[]}"#;
623
624 #[test]
625 fn widget_may_declare_app_data() {
626 let m = manifest(&ui("widget", DATA));
627 assert!(m.ui.unwrap().data.is_some());
628 }
629
630 #[test]
631 fn widget_app_data_gets_stage_rules() {
632 let error = AppManifest::from_json(&format!(
633 r#"{{"name":"demo","version":"1.0.0","app_type":"bun","ui":{}}}"#,
634 ui("widget", r#","data":{"namespace":"other","offline":"online-only","sync":"snapshot","queries":[],"streams":[]}"#)
635 ))
636 .expect_err("namespace must equal the app name");
637 assert!(error.contains("must equal the app name"), "{error}");
638 }
639
640 #[test]
641 fn widget_nav_is_still_rejected() {
642 let error = AppManifest::from_json(&format!(
643 r#"{{"name":"demo","version":"1.0.0","app_type":"bun","ui":{}}}"#,
644 ui("widget", r#","nav":{"section":"default","order":1}"#)
645 ))
646 .expect_err("widgets have no nav");
647 assert!(error.contains("nav"), "{error}");
648 }
649
650 #[test]
651 fn stamp_is_sorted_derived_features_with_min_host() {
652 let stamp = stamp_for(&manifest(&ui("widget", DATA)));
653 assert_eq!(stamp.features, vec!["ui.widget", "ui.widget-data"]);
654 assert_eq!(stamp.min_host, Some(HostVersion::new(7, 2, 0)));
655 assert_eq!(
656 serde_json::to_string(&stamp).unwrap(),
657 r#"{"features":["ui.widget","ui.widget-data"],"min_host":"7.2.0"}"#
658 );
659 let empty = stamp_for(&manifest(&ui("stage", "")));
660 assert_eq!(serde_json::to_string(&empty).unwrap(), r#"{"features":[]}"#);
661 }
662
663 #[test]
664 fn check_accepts_absent_and_fresh_stamps() {
665 let m = manifest(&ui("widget", DATA));
666 assert_eq!(check_host_contract(&m, &HostFeatureSet::current()), Ok(()));
667 let mut stamped = m.clone();
668 stamped.host_contract = Some(stamp_for(&m));
669 assert_eq!(
670 check_host_contract(&stamped, &HostFeatureSet::current()),
671 Ok(())
672 );
673 }
674
675 #[test]
676 fn check_rejects_a_stale_stamp() {
677 let mut m = manifest(&ui("widget", DATA));
678 m.host_contract = Some(HostContractStamp {
679 features: vec!["ui.widget".into()],
680 min_host: Some(HostVersion::new(7, 1, 11)),
681 });
682 match check_host_contract(&m, &HostFeatureSet::current()) {
683 Err(ManifestRejection::Invalid { message }) => {
684 assert!(message.contains("stale"), "{message}");
685 assert!(message.contains("ui.widget-data"), "{message}");
686 }
687 other => panic!("expected stale stamp rejection, got {other:?}"),
688 }
689 }
690
691 #[test]
692 fn check_reports_missing_features_for_an_older_target() {
693 let m = manifest(&ui("widget", DATA));
694 assert_eq!(
695 check_host_contract(&m, &HostFeatureSet::at(HostVersion::new(7, 1, 17))),
696 Err(ManifestRejection::HostFeatureMissing {
697 features: vec!["ui.widget-data".into()],
698 min_host: Some(HostVersion::new(7, 2, 0)),
699 })
700 );
701 }
702
703 #[test]
704 fn precheck_reports_unknown_stamped_feature_with_stamped_min_host() {
705 let json = format!(
706 r#"{{"name":"demo","version":"1.0.0","app_type":"bun","host_contract":{{"features":["ui.future","ui.widget"],"min_host":"9.0.0"}},"ui":{}}}"#,
707 ui("widget", "")
708 );
709 assert_eq!(
710 precheck_stamp(&json, &HostFeatureSet::current()),
711 Err(ManifestRejection::HostFeatureMissing {
712 features: vec!["ui.future".into()],
713 min_host: Some(HostVersion::new(9, 0, 0)),
714 })
715 );
716 }
717
718 #[test]
719 fn precheck_tolerates_a_newer_stamp_shape_and_the_manifest_still_parses() {
720 let json = format!(
721 r#"{{"name":"demo","version":"1.0.0","app_type":"bun","host_contract":{{"features":["ui.future"],"min_host":"9.0.0","extra":1}},"ui":{}}}"#,
722 ui("widget", "")
723 );
724 assert_eq!(
725 precheck_stamp(&json, &HostFeatureSet::current()),
726 Err(ManifestRejection::HostFeatureMissing {
727 features: vec!["ui.future".into()],
728 min_host: Some(HostVersion::new(9, 0, 0)),
729 })
730 );
731 let manifest = AppManifest::from_json(&json).expect("unknown stamp members are tolerated");
732 assert_eq!(
733 manifest.host_contract.unwrap().min_host,
734 Some(HostVersion::new(9, 0, 0))
735 );
736 }
737
738 #[test]
739 fn unparseable_stamped_min_host_reads_as_absent() {
740 let json = format!(
741 r#"{{"name":"demo","version":"1.0.0","app_type":"bun","host_contract":{{"features":["ui.future","ui.widget"],"min_host":"nine"}},"ui":{}}}"#,
742 ui("widget", "")
743 );
744 assert_eq!(
745 precheck_stamp(&json, &HostFeatureSet::current()),
746 Err(ManifestRejection::HostFeatureMissing {
747 features: vec!["ui.future".into()],
748 min_host: None
749 })
750 );
751 let manifest = AppManifest::from_json(&json).expect("an unparseable min_host is tolerated");
752 let stamp = manifest.host_contract.clone().unwrap();
753 assert_eq!(stamp.min_host, None);
754 assert_eq!(
755 serde_json::to_string(&stamp).unwrap(),
756 r#"{"features":["ui.future","ui.widget"]}"#
757 );
758 let mut widget = manifest.clone();
760 widget.host_contract = Some(HostContractStamp {
761 features: vec!["ui.widget".into()],
762 min_host: None,
763 });
764 assert!(matches!(
765 check_host_contract(&widget, &HostFeatureSet::current()),
766 Err(ManifestRejection::Invalid { .. })
767 ));
768 }
769
770 #[test]
771 fn stamp_features_are_sorted_by_id_and_compared_as_sets() {
772 let m = manifest(&ui("widget", &format!(r#"{DATA},"composes":["child"]"#)));
773 let stamp = stamp_for(&m);
774 assert_eq!(
775 stamp.features,
776 vec!["ui.composition", "ui.widget", "ui.widget-data"]
777 );
778 let reordered = HostContractStamp {
779 features: vec![
780 "ui.widget-data".into(),
781 "ui.composition".into(),
782 "ui.widget".into(),
783 ],
784 min_host: stamp.min_host,
785 };
786 assert!(stamp.same_contract(&reordered));
787 let mut stamped = m.clone();
788 stamped.host_contract = Some(reordered);
789 assert_eq!(
790 check_host_contract(&stamped, &HostFeatureSet::current()),
791 Ok(())
792 );
793 let fewer = HostContractStamp {
794 features: vec!["ui.widget".into()],
795 min_host: stamp.min_host,
796 };
797 assert!(!stamp.same_contract(&fewer));
798 }
799
800 #[test]
801 fn precheck_ignores_absent_or_malformed_stamps() {
802 assert_eq!(
803 precheck_stamp(r#"{"name":"demo"}"#, &HostFeatureSet::current()),
804 Ok(())
805 );
806 assert_eq!(
807 precheck_stamp(r#"{"host_contract":7}"#, &HostFeatureSet::current()),
808 Ok(())
809 );
810 assert_eq!(
811 precheck_stamp("not json", &HostFeatureSet::current()),
812 Ok(())
813 );
814 }
815
816 #[test]
817 fn rejection_messages_are_actionable() {
818 let missing = ManifestRejection::HostFeatureMissing {
819 features: vec!["ui.widget-data".into()],
820 min_host: Some(HostVersion::new(7, 2, 0)),
821 };
822 assert_eq!(
823 missing.to_string(),
824 "requires host features this Node does not support: ui.widget-data (first available in Node 7.2.0)"
825 );
826 }
827}