Skip to main content

webui_parser/
component_registry.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Component registry for WebUI framework.
5//!
6//! This module manages the registry of web components used in the application.
7
8use crate::{CssFallbackChain, CssParser, LegalComments, ParserError, Result};
9use std::collections::HashMap;
10#[cfg(feature = "fs")]
11use std::fs;
12#[cfg(feature = "fs")]
13use std::path::Path;
14#[cfg(feature = "fs")]
15use walkdir::WalkDir;
16
17type ProcessedCss = (String, Vec<String>, Vec<CssFallbackChain>);
18
19/// Represents a web component in the registry.
20#[derive(Debug, Clone)]
21pub struct Component {
22    /// The custom element tag name (e.g., "hello-world")
23    pub tag_name: String,
24
25    /// The HTML content of the component
26    pub html_content: String,
27
28    /// The CSS content of the component, if any
29    pub css_content: Option<String>,
30
31    /// CSS custom property definitions from this component's CSS.
32    pub css_definitions: Vec<String>,
33
34    /// CSS `var()` fallback chains from this component's CSS.
35    pub css_fallback_chains: Vec<CssFallbackChain>,
36
37    /// Whether authored browser code owns this custom element tag.
38    pub is_client_owned: bool,
39}
40
41/// Inputs for registering a component from content strings.
42///
43/// Grouping the fields keeps [`ComponentRegistry::register_component`] a
44/// single-argument call without growing its arity.
45#[derive(Debug, Clone)]
46pub struct ComponentRegistration<'a> {
47    /// The custom element tag name (must contain a hyphen).
48    pub tag_name: &'a str,
49    /// The component's HTML template content.
50    pub html_content: &'a str,
51    /// The component's CSS content, if any.
52    pub css_content: Option<&'a str>,
53    /// Whether authored browser code owns this custom element tag.
54    pub is_client_owned: bool,
55}
56
57impl<'a> ComponentRegistration<'a> {
58    /// Create a component registration.
59    #[must_use]
60    pub fn new(
61        tag_name: &'a str,
62        html_content: &'a str,
63        css_content: Option<&'a str>,
64        is_client_owned: bool,
65    ) -> Self {
66        Self {
67            tag_name,
68            html_content,
69            css_content,
70            is_client_owned,
71        }
72    }
73}
74
75/// Registry of web components.
76#[derive(Debug)]
77pub struct ComponentRegistry {
78    /// Map of component tag names to their component data
79    components: HashMap<String, Component>,
80    /// Reusable CSS parser for token extraction during registration.
81    css_parser: CssParser,
82    /// Legal comment preservation policy for component CSS.
83    legal_comments: LegalComments,
84}
85
86#[cfg(feature = "fs")]
87/// Return whether a component has an authored sibling module.
88///
89/// Use `try_exists()` rather than `exists()`: `exists()` converts metadata
90/// errors into `false`, which could silently classify an inaccessible authored
91/// component as scriptless and bypass projection-manifest coverage.
92fn has_component_script(html_path: &Path) -> Result<bool> {
93    for ext in ["ts", "js"] {
94        let candidate = html_path.with_extension(ext);
95        if candidate.try_exists().map_err(|source| ParserError::IO {
96            context: format!(
97                "Failed to inspect component script: {}",
98                candidate.display()
99            ),
100            source,
101        })? {
102            return Ok(true);
103        }
104    }
105    Ok(false)
106}
107
108impl Default for ComponentRegistry {
109    fn default() -> Self {
110        Self::new()
111    }
112}
113
114impl ComponentRegistry {
115    /// Create a new component registry.
116    pub fn new() -> Self {
117        Self::with_legal_comments(LegalComments::default())
118    }
119
120    pub(crate) fn with_legal_comments(legal_comments: LegalComments) -> Self {
121        Self {
122            components: HashMap::new(),
123            css_parser: CssParser::new(),
124            legal_comments,
125        }
126    }
127
128    /// Register multiple components from directories recursively.
129    #[cfg(feature = "fs")]
130    pub fn register_from_paths<P: AsRef<Path>>(&mut self, directories: &[P]) -> Result<&mut Self> {
131        for dir in directories {
132            for entry in WalkDir::new(dir.as_ref())
133                .into_iter()
134                .filter_map(|e| e.ok())
135            {
136                let path = entry.path();
137                if !path.is_file() {
138                    continue;
139                }
140                // Only process HTML files
141                if path.extension().is_some_and(|ext| ext == "html") {
142                    // Check for a component name (must contain a hyphen)
143                    if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) {
144                        if filename.contains('-') {
145                            // Find associated CSS file
146                            let css_path = path.with_extension("css");
147                            // Register the component (key is the file name without extension)
148                            self.register_component_from_paths(
149                                path,
150                                if css_path.exists() {
151                                    Some(&css_path)
152                                } else {
153                                    None
154                                },
155                            )?;
156                        }
157                    }
158                }
159            }
160        }
161        Ok(self)
162    }
163
164    /// Register a web component from paths to HTML and CSS files.
165    #[cfg(feature = "fs")]
166    pub fn register_component_from_paths<P: AsRef<Path>, Q: AsRef<Path>>(
167        &mut self,
168        html_path: P,
169        css_path: Option<Q>,
170    ) -> Result<()> {
171        let html_path = html_path.as_ref();
172
173        // Extract component name from file name (without extension)
174        let tag_name = html_path
175            .file_stem()
176            .and_then(|s| s.to_str())
177            .ok_or_else(|| ParserError::Component("Invalid component file name".to_string()))?;
178
179        // Validate component name (must contain a hyphen)
180        if !tag_name.contains('-') {
181            return Err(ParserError::Component(format!(
182                "Component name '{}' must contain a hyphen",
183                tag_name
184            )));
185        }
186
187        // Check for duplicate component
188        if self.components.contains_key(tag_name) {
189            return Err(ParserError::Component(format!(
190                "Component '{}' is already registered",
191                tag_name
192            )));
193        }
194
195        // Read HTML content
196        let html_content = fs::read_to_string(html_path).map_err(|source| ParserError::IO {
197            context: format!("Failed to read HTML file: {}", html_path.display()),
198            source,
199        })?;
200
201        // Read CSS content and extract definitions/fallback requirements if available
202        let (css_content, css_definitions, css_fallback_chains) = if let Some(css_path) = css_path {
203            let css_path = css_path.as_ref();
204            if css_path.exists() {
205                let content = fs::read_to_string(css_path).map_err(|source| ParserError::IO {
206                    context: format!("Failed to read CSS file: {}", css_path.display()),
207                    source,
208                })?;
209                let (content, definitions, requirements) = self.process_css_content(&content)?;
210                (Some(content), definitions, requirements)
211            } else {
212                (None, Vec::new(), Vec::new())
213            }
214        } else {
215            (None, Vec::new(), Vec::new())
216        };
217
218        let is_client_owned = has_component_script(html_path)?;
219
220        // Create and register the component
221        let component = Component {
222            tag_name: tag_name.to_string(),
223            html_content,
224            css_content,
225            css_definitions,
226            css_fallback_chains,
227            is_client_owned,
228        };
229
230        self.components.insert(tag_name.to_string(), component);
231        Ok(())
232    }
233
234    /// Register a component directly from provided content strings.
235    ///
236    /// Exact JavaScript state ownership is supplied later by a bundler projection
237    /// manifest; the parser stores only whether this tag is client-owned.
238    pub fn register_component(&mut self, registration: ComponentRegistration<'_>) -> Result<()> {
239        let ComponentRegistration {
240            tag_name,
241            html_content,
242            css_content,
243            is_client_owned,
244        } = registration;
245
246        // Validate component name (must contain a hyphen)
247        if !tag_name.contains('-') {
248            return Err(ParserError::Component(format!(
249                "Component name '{}' must contain a hyphen",
250                tag_name
251            )));
252        }
253
254        // Check for duplicate component
255        if self.components.contains_key(tag_name) {
256            return Err(ParserError::Component(format!(
257                "Component '{}' is already registered",
258                tag_name
259            )));
260        }
261
262        // Extract CSS definitions/fallback requirements if CSS content is provided
263        let (css_content, css_definitions, css_fallback_chains) = match css_content {
264            Some(css) => {
265                let (content, definitions, requirements) = self.process_css_content(css)?;
266                (Some(content), definitions, requirements)
267            }
268            None => (None, Vec::new(), Vec::new()),
269        };
270
271        let component: Component = Component {
272            tag_name: tag_name.to_string(),
273            html_content: html_content.to_string(),
274            css_content,
275            css_definitions,
276            css_fallback_chains,
277            is_client_owned,
278        };
279
280        // Register the component
281        self.components.insert(tag_name.to_string(), component);
282        Ok(())
283    }
284
285    /// Strip comments and extract CSS definitions/fallback requirements.
286    fn process_css_content(&mut self, css_content: &str) -> Result<ProcessedCss> {
287        let (_tokens, definitions, requirements, stripped) = self
288            .css_parser
289            .extract_tokens_definitions_requirements_and_strip_comments(
290                css_content,
291                self.legal_comments,
292            )?;
293        let mut sorted_definitions: Vec<String> = definitions.into_iter().collect();
294        sorted_definitions.sort();
295        Ok((stripped.into_owned(), sorted_definitions, requirements))
296    }
297
298    /// Check if a tag name is registered as a component.
299    pub fn contains(&self, tag_name: &str) -> bool {
300        self.components.contains_key(tag_name)
301    }
302
303    /// Get a component by its tag name.
304    pub fn get(&self, tag_name: &str) -> Option<&Component> {
305        self.components.get(tag_name)
306    }
307
308    /// Get all registered components.
309    pub fn get_all(&self) -> impl Iterator<Item = &Component> {
310        self.components.values()
311    }
312
313    /// Iterate the registered component tag names (e.g. `mp-button`).
314    ///
315    /// Used to offer "did you mean …?" suggestions when an unknown component
316    /// tag is encountered during parsing.
317    pub fn names(&self) -> impl Iterator<Item = &str> {
318        self.components.keys().map(String::as_str)
319    }
320
321    /// Get the number of registered components.
322    pub fn len(&self) -> usize {
323        self.components.len()
324    }
325
326    /// Check if the registry has no registered components.
327    pub fn is_empty(&self) -> bool {
328        self.components.is_empty()
329    }
330}
331
332#[cfg(test)]
333#[allow(clippy::disallowed_methods)]
334mod tests {
335    use super::*;
336    use webui_test_utils::TestFileSystem;
337
338    #[test]
339    fn test_register_component() {
340        let html_content = "<p>Hello World</p>";
341        let css_content = "p { color: red; }";
342
343        // Create temporary files with proper names directly
344        let mut fs = TestFileSystem::new();
345        let html_path = fs.add_file("components/test-component.html", html_content);
346        let css_path = fs.add_file("components/test-component.css", css_content);
347
348        // Register the component (no rename needed)
349        let mut registry = ComponentRegistry::new();
350        let result = registry.register_component_from_paths(&html_path, Some(&css_path));
351
352        assert!(result.is_ok());
353        assert!(registry.contains("test-component"));
354
355        let component = registry
356            .get("test-component")
357            .expect("Failed to retrieve registered component");
358        assert_eq!(component.html_content, html_content);
359        assert_eq!(component.css_content.as_deref(), Some(css_content));
360        assert!(!component.is_client_owned);
361    }
362
363    #[test]
364    fn test_register_component_detects_ts_sibling_script() {
365        let mut fs = TestFileSystem::new();
366        let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
367        std::fs::write(html_path.with_extension("ts"), "export {};")
368            .expect("Failed to write sibling script");
369
370        let mut registry = ComponentRegistry::new();
371        registry
372            .register_component_from_paths(&html_path, None::<&str>)
373            .expect("register failed");
374
375        let component = registry
376            .get("scripted-card")
377            .expect("Failed to retrieve registered component");
378        assert!(component.is_client_owned);
379    }
380
381    #[test]
382    fn test_register_component_detects_js_sibling_script() {
383        let mut fs = TestFileSystem::new();
384        let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
385        std::fs::write(html_path.with_extension("js"), "export {};")
386            .expect("Failed to write sibling script");
387
388        let mut registry = ComponentRegistry::new();
389        registry
390            .register_component_from_paths(&html_path, None::<&str>)
391            .expect("register failed");
392
393        let component = registry
394            .get("scripted-card")
395            .expect("Failed to retrieve registered component");
396        assert!(component.is_client_owned);
397    }
398
399    #[test]
400    fn test_register_component_detects_non_utf8_sibling_without_reading_it() {
401        let mut fs = TestFileSystem::new();
402        let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
403        std::fs::write(html_path.with_extension("ts"), [0xFF, 0xFE, 0x00])
404            .expect("Failed to write invalid sibling script");
405
406        let mut registry = ComponentRegistry::new();
407        registry
408            .register_component_from_paths(&html_path, None::<&str>)
409            .expect("registration should inspect presence without decoding source");
410
411        let component = registry
412            .get("scripted-card")
413            .expect("Failed to retrieve registered component");
414        assert!(component.is_client_owned);
415    }
416
417    #[test]
418    fn test_register_component_ignores_tsx_sibling_script() {
419        let mut fs = TestFileSystem::new();
420        let html_path = fs.add_file("components/scripted-card.html", "<p>Scripted</p>");
421        std::fs::write(html_path.with_extension("tsx"), "export {};")
422            .expect("Failed to write sibling script");
423
424        let mut registry = ComponentRegistry::new();
425        registry
426            .register_component_from_paths(&html_path, None::<&str>)
427            .expect("register failed");
428
429        let component = registry
430            .get("scripted-card")
431            .expect("Failed to retrieve registered component");
432        assert!(!component.is_client_owned);
433    }
434
435    #[test]
436    fn test_component_name_validation() {
437        let html_content = "<p>Invalid</p>";
438
439        // Create temporary file with invalid name (no hyphen)
440        let mut fs = TestFileSystem::new();
441        let html_path = fs.add_file("invalid_name.html", html_content);
442
443        // Try to register the component
444        let mut registry = ComponentRegistry::new();
445        let result = registry.register_component_from_paths(&html_path, None::<&str>);
446
447        assert!(result.is_err());
448        assert_eq!(registry.len(), 0);
449    }
450
451    #[test]
452    fn test_missing_css_file() {
453        let html_content = "<p>CSS Optional</p>";
454
455        // Create temporary HTML file
456        let mut fs = TestFileSystem::new();
457        let html_path = fs.add_file("test-component.html", html_content);
458
459        // Register with non-existent CSS file
460        let mut registry = ComponentRegistry::new();
461        let result = registry.register_component_from_paths(&html_path, None::<&str>);
462
463        assert!(result.is_ok());
464        let component = registry
465            .get("test-component")
466            .expect("Failed to retrieve registered component");
467        assert_eq!(component.html_content, html_content);
468        assert_eq!(component.css_content, None);
469    }
470
471    #[test]
472    fn test_register_component_from_strings() {
473        let html_content = "<p>Hello from string!</p>";
474        let css_content = "p { color: green; }";
475
476        // Register component directly from strings
477        let mut registry = ComponentRegistry::new();
478        let result = registry.register_component(ComponentRegistration::new(
479            "string-component",
480            html_content,
481            Some(css_content),
482            true,
483        ));
484
485        assert!(result.is_ok());
486        assert!(registry.contains("string-component"));
487
488        let component = registry
489            .get("string-component")
490            .expect("Failed to retrieve registered component");
491        assert_eq!(component.html_content, html_content);
492        assert_eq!(component.css_content.as_deref(), Some(css_content));
493        assert!(component.is_client_owned);
494    }
495
496    #[test]
497    fn test_register_component_strips_css_comments() {
498        let mut registry = ComponentRegistry::new();
499        registry
500            .register_component(ComponentRegistration::new(
501                "style-component",
502                "<p>Styled</p>",
503                Some("/* var(--ignored) */ p { color: var(--textColor); } /* remove */"),
504                true,
505            ))
506            .expect("register failed");
507
508        let component = registry
509            .get("style-component")
510            .expect("Failed to retrieve registered component");
511        assert_eq!(
512            component.css_content.as_deref(),
513            Some(" p { color: var(--textColor); } ")
514        );
515        assert_eq!(component.css_fallback_chains.len(), 1);
516    }
517
518    #[test]
519    fn test_register_component_preserves_legal_css_comments_by_default() {
520        let mut registry = ComponentRegistry::new();
521        registry
522            .register_component(ComponentRegistration::new(
523                "legal-component",
524                "<p>Styled</p>",
525                Some("/*! @license MIT */ p { color: red; } /* remove */"),
526                true,
527            ))
528            .expect("register failed");
529
530        let component = registry
531            .get("legal-component")
532            .expect("Failed to retrieve registered component");
533        assert_eq!(
534            component.css_content.as_deref(),
535            Some("/*! @license MIT */ p { color: red; } ")
536        );
537    }
538
539    #[test]
540    fn test_register_component_strips_legal_css_comments_when_disabled() {
541        let mut registry = ComponentRegistry::with_legal_comments(LegalComments::None);
542        registry
543            .register_component(ComponentRegistration::new(
544                "legal-component",
545                "<p>Styled</p>",
546                Some("/*! @license MIT */ p { color: red; }"),
547                true,
548            ))
549            .expect("register failed");
550
551        let component = registry
552            .get("legal-component")
553            .expect("Failed to retrieve registered component");
554        assert_eq!(component.css_content.as_deref(), Some(" p { color: red; }"));
555    }
556
557    #[test]
558    fn test_invalid_component_name_from_strings() {
559        let html_content = "<p>Invalid component</p>";
560
561        // Try registering with invalid name (no hyphen)
562        let mut registry = ComponentRegistry::new();
563        let result = registry.register_component(ComponentRegistration::new(
564            "invalid",
565            html_content,
566            None,
567            true,
568        ));
569
570        // More idiomatic approach using assert!() with message
571        assert!(result.is_err(), "Expected error for invalid component name");
572
573        // Better pattern matching using matches!() macro
574        assert!(
575            matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
576            "Wrong error type or message: {:?}",
577            result
578        );
579    }
580
581    #[test]
582    fn test_duplicate_component_from_strings() {
583        let html_content = "<p>First component</p>";
584        let html_content2 = "<p>Second component with same name</p>";
585
586        // Register the first component
587        let mut registry = ComponentRegistry::new();
588        let result1 = registry.register_component(ComponentRegistration::new(
589            "dupe-component",
590            html_content,
591            None,
592            true,
593        ));
594        assert!(result1.is_ok());
595
596        // Try to register a second component with the same name
597        let result2 = registry.register_component(ComponentRegistration::new(
598            "dupe-component",
599            html_content2,
600            None,
601            true,
602        ));
603        assert!(result2.is_err());
604
605        // Verify the error message
606        assert!(
607            matches!(result2, Err(ParserError::Component(ref msg)) if msg.contains("already registered")),
608            "Expected 'already registered' error, got: {:?}",
609            result2
610        );
611
612        // Verify the original component is still there unchanged
613        let component = registry
614            .get("dupe-component")
615            .expect("Failed to retrieve registered component");
616        assert_eq!(component.html_content, html_content);
617    }
618
619    #[test]
620    fn test_duplicate_component_from_paths() {
621        let html_content1 = "<p>Component from dir A</p>";
622        let html_content2 = "<p>Component from dir B</p>";
623
624        // Create temporary directories and files.
625        let mut fs = TestFileSystem::new();
626        let file_path_a = fs.add_file("dir_a/my-comp.html", html_content1);
627        let file_path_b = fs.add_file("dir_b/my-comp.html", html_content2);
628
629        // Register the first component
630        let mut registry = ComponentRegistry::new();
631        let result1 = registry.register_component_from_paths(&file_path_a, None::<&str>);
632        assert!(result1.is_ok());
633
634        // Try to register the second component with the same name from a different path
635        let result2 = registry.register_component_from_paths(&file_path_b, None::<&str>);
636        assert!(result2.is_err());
637
638        // Verify the error message
639        assert!(
640            matches!(result2, Err(ParserError::Component(ref msg)) if msg.contains("already registered")),
641            "Expected 'already registered' error, got: {:?}",
642            result2
643        );
644
645        // Verify the original component is still there unchanged
646        let component = registry
647            .get("my-comp")
648            .expect("Failed to retrieve registered component");
649        assert_eq!(component.html_content, html_content1);
650    }
651
652    #[test]
653    fn test_exclude_dot_in_component_name() {
654        let mut registry = ComponentRegistry::new();
655        let result = registry.register_component(ComponentRegistration::new(
656            "fluent.button",
657            "<p>Dot name</p>",
658            None,
659            true,
660        ));
661
662        assert!(
663            result.is_err(),
664            "Component name with dot but no hyphen should be rejected"
665        );
666        assert!(
667            matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
668            "Wrong error type or message: {:?}",
669            result
670        );
671        assert_eq!(registry.len(), 0);
672    }
673
674    #[test]
675    fn test_exclude_no_hyphen_html() {
676        let mut registry = ComponentRegistry::new();
677        let result = registry.register_component(ComponentRegistration::new(
678            "foobar",
679            "<p>No hyphen</p>",
680            None,
681            true,
682        ));
683
684        assert!(
685            result.is_err(),
686            "Component name without hyphen should be rejected"
687        );
688        assert!(
689            matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
690            "Wrong error type or message: {:?}",
691            result
692        );
693        assert_eq!(registry.len(), 0);
694    }
695
696    #[test]
697    fn test_valid_component_with_hyphen() {
698        let mut registry = ComponentRegistry::new();
699        let result = registry.register_component(ComponentRegistration::new(
700            "fluent-button",
701            "<button>Click me</button>",
702            None,
703            true,
704        ));
705
706        assert!(
707            result.is_ok(),
708            "Component name with hyphen should be accepted"
709        );
710        assert!(registry.contains("fluent-button"));
711        assert_eq!(registry.len(), 1);
712    }
713
714    #[test]
715    fn test_valid_component_css_only() {
716        let mut registry = ComponentRegistry::new();
717        let result = registry.register_component(ComponentRegistration::new(
718            "styled-widget",
719            "",
720            Some(".widget { color: blue; }"),
721            true,
722        ));
723
724        assert!(
725            result.is_ok(),
726            "Component with empty HTML and CSS should be accepted"
727        );
728        assert!(registry.contains("styled-widget"));
729
730        let component = registry
731            .get("styled-widget")
732            .expect("Failed to retrieve registered component");
733        assert_eq!(component.html_content, "");
734        assert_eq!(
735            component.css_content.as_deref(),
736            Some(".widget { color: blue; }")
737        );
738    }
739
740    #[test]
741    fn test_component_name_requires_hyphen() {
742        let mut registry = ComponentRegistry::new();
743        let result = registry.register_component(ComponentRegistration::new(
744            "single",
745            "<p>Single word</p>",
746            None,
747            true,
748        ));
749
750        assert!(
751            result.is_err(),
752            "Single-word component name should be rejected"
753        );
754        assert!(
755            matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
756            "Wrong error type or message: {:?}",
757            result
758        );
759        assert_eq!(registry.len(), 0);
760    }
761
762    #[test]
763    fn test_multiple_hyphens_valid() {
764        let mut registry = ComponentRegistry::new();
765        let result = registry.register_component(ComponentRegistration::new(
766            "my-custom-element",
767            "<div>Custom element</div>",
768            None,
769            true,
770        ));
771
772        assert!(
773            result.is_ok(),
774            "Component name with multiple hyphens should be accepted"
775        );
776        assert!(registry.contains("my-custom-element"));
777        assert_eq!(registry.len(), 1);
778    }
779
780    #[test]
781    fn test_empty_component_name_rejected() {
782        let mut registry = ComponentRegistry::new();
783        let result = registry.register_component(ComponentRegistration::new(
784            "",
785            "<p>Empty name</p>",
786            None,
787            true,
788        ));
789
790        assert!(result.is_err(), "Empty component name should be rejected");
791        assert!(
792            matches!(result, Err(ParserError::Component(ref msg)) if msg.contains("must contain a hyphen")),
793            "Wrong error type or message: {:?}",
794            result
795        );
796        assert_eq!(registry.len(), 0);
797    }
798
799    #[test]
800    fn test_register_component_extracts_css_fallback_requirements() {
801        let mut registry = ComponentRegistry::new();
802        registry
803            .register_component(ComponentRegistration::new(
804                "my-btn",
805                "<button>Click</button>",
806                Some(":host { color: var(--text-color); padding: var(--spacing-m); }"),
807                true,
808            ))
809            .expect("register failed");
810
811        let component = registry.get("my-btn").expect("component not found");
812        assert_eq!(component.css_fallback_chains.len(), 2);
813    }
814
815    #[test]
816    fn test_register_component_no_css_no_requirements() {
817        let mut registry = ComponentRegistry::new();
818        registry
819            .register_component(ComponentRegistration::new(
820                "my-card",
821                "<div>Card</div>",
822                None,
823                true,
824            ))
825            .expect("register failed");
826
827        let component = registry.get("my-card").expect("component not found");
828        assert!(component.css_fallback_chains.is_empty());
829    }
830
831    #[test]
832    fn test_register_component_tracks_css_fallback_requirements() {
833        let mut registry = ComponentRegistry::new();
834        registry
835            .register_component(ComponentRegistration::new(
836                "my-widget",
837                "<div>W</div>",
838                Some(":host { --local: 5px; margin: var(--external); width: var(--local); }"),
839                true,
840            ))
841            .expect("register failed");
842
843        let component = registry.get("my-widget").expect("component not found");
844        assert_eq!(component.css_fallback_chains.len(), 2);
845    }
846}