From 71bfbad7acce4141aa95867b84425fc853d2de23 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 23 Oct 2026 23:54:23 +0900 Subject: [PATCH 1/2] test(extractor): preserve absence proof without supplied scoping Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/style_values/absence.rs | 27 ++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/libs/extractor/src/style_values/absence.rs b/libs/extractor/src/style_values/absence.rs index 10cef0a84..aa6b0b2d1 100644 --- a/libs/extractor/src/style_values/absence.rs +++ b/libs/extractor/src/style_values/absence.rs @@ -82,6 +82,33 @@ mod tests { use oxc_span::SourceType; use rstest::rstest; + #[rstest] + #[case("undefined;", true)] + #[case("let undefined = runtime; undefined;", false)] + fn absence_survives_cleared_ids_when_scoping_is_not_supplied( + #[case] source: &str, + #[case] absent: bool, + ) { + // Given authored references without a caller-supplied semantic analysis. + let arena = Allocator::default(); + let parsed = Parser::new(&arena, source, SourceType::tsx()).parse(); + let mut values = StyleValues::default(); + values.record_absence(&parsed.program); + let Statement::ExpressionStatement(statement) = + &parsed.program.body[parsed.program.body.len() - 1] + else { + panic!("expected expression"); + }; + let Expression::Identifier(identifier) = &statement.expression else { + panic!("expected identifier"); + }; + identifier.reference_id.set(None); + // When extraction consults the authored-span proof after IDs are cleared. + let result = values.is_unbound_undefined(&statement.expression); + // Then only the unbound global is absent, never a shadowing binding. + assert_eq!(result, absent); + } + #[rstest] #[case("undefined;", true)] #[case("let undefined = runtime; undefined;", false)] From 2c5e52074821871237666a2059d9ee1de470df50 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 1 Nov 2026 20:44:15 +0900 Subject: [PATCH 2/2] test(extractor): close native selector params coverage gaps Refs #791 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus Co-authored-by: sisyphus-dev-ai --- libs/extractor/src/dead_properties/params.rs | 49 +++--- libs/extractor/src/own_css_context.rs | 30 ++-- libs/extractor/src/own_css_selector.rs | 28 ++-- .../src/stylesheet_coverage_contract_tests.rs | 87 ++++++++++ .../coverage_edges.rs | 151 ++++++++++++++++++ .../src/vanilla_extract_css_capture.rs | 45 +++--- .../src/vanilla_extract_css_capture_tests.rs | 99 ++++++++++++ 7 files changed, 413 insertions(+), 76 deletions(-) diff --git a/libs/extractor/src/dead_properties/params.rs b/libs/extractor/src/dead_properties/params.rs index 4a0ce33d7..9b4fde703 100644 --- a/libs/extractor/src/dead_properties/params.rs +++ b/libs/extractor/src/dead_properties/params.rs @@ -125,8 +125,12 @@ pub(super) fn errors( } } for (name, property) in properties { - let mut child_path = path; - let child = match kind { + let mut push = |kind, frame: Option| { + let child_path = + frame.map_or(path, |frame| Some(contexts.push(path, frame))); + pending.push((&property.value, kind, child_path)); + }; + match kind { Kind::Styles => { if matches!( name.as_str(), @@ -135,40 +139,35 @@ pub(super) fn errors( { continue; } - if name == "selectors" { - Kind::Selectors + let child = if name == "selectors" { + (Kind::Selectors, None) } else if name == "@layer" { - Kind::Layers + (Kind::Layers, None) } else if let Some(kind) = crate::extractor::extract_style_from_expression::at_rule_record_kind( &name, ) { - Kind::At(kind) + (Kind::At(kind), None) } else { - if (name.starts_with([':', '.', '#', '[', '*', '>', '+', '~', '_']) + let frame = (name + .starts_with([':', '.', '#', '[', '*', '>', '+', '~', '_']) || name.contains('&') || css::at_rule::split_at_rule_key(&name).is_some()) - && let Some(frame) = under(&name) - { - child_path = Some(contexts.push(path, frame)); - } - Kind::Styles - } - } - Kind::Selectors | Kind::At(_) | Kind::Layers => { - let frame = match kind { - Kind::Selectors => under(&name), - Kind::At(kind) => Some(Frame::At(kind, name)), - Kind::Layers => Some(Frame::Layer(name)), - Kind::Styles => None, + .then(|| under(&name)) + .flatten(); + (Kind::Styles, frame) }; - let Some(frame) = frame else { continue }; - child_path = Some(contexts.push(path, frame)); - Kind::Styles + push(child.0, child.1); } - }; - pending.push((&property.value, child, child_path)); + Kind::Selectors => { + if let Some(frame) = under(&name) { + push(Kind::Styles, Some(frame)); + } + } + Kind::At(kind) => push(Kind::Styles, Some(Frame::At(kind, name))), + Kind::Layers => push(Kind::Styles, Some(Frame::Layer(name))), + } } } _ => {} diff --git a/libs/extractor/src/own_css_context.rs b/libs/extractor/src/own_css_context.rs index ed16c40bb..4964ae971 100644 --- a/libs/extractor/src/own_css_context.rs +++ b/libs/extractor/src/own_css_context.rs @@ -17,12 +17,11 @@ pub(crate) enum Frame { At(AtRuleKind, String), Layer(String), Params(String), - UncertainParams, } struct Link { parent: Option, - frame: Frame, + frame: Option, selected: bool, target: std::cell::OnceCell>, } @@ -39,7 +38,7 @@ impl Contexts { let id = self.links.len(); self.links.push(Link { parent, - frame, + frame: Some(frame), selected, target: std::cell::OnceCell::new(), }); @@ -51,7 +50,14 @@ impl Contexts { } pub fn uncertain(&mut self, path: Option) -> usize { - self.push(path, Frame::UncertainParams) + let id = self.links.len(); + self.links.push(Link { + parent: path, + frame: None, + selected: self.selected(path), + target: std::cell::OnceCell::from(Some(self.targets.uncertain())), + }); + id } pub fn requires_argument(&mut self, path: Option) -> bool { @@ -67,12 +73,13 @@ impl Contexts { cursor = self.links[id].parent; } for id in pending.into_iter().rev() { - target = match &self.links[id].frame { - Frame::At(..) | Frame::Layer(_) => target, - Frame::Selector(template) => Some(self.targets.nest(target, template)), - Frame::Params(_) => self.targets.consume(target), - Frame::UncertainParams => Some(self.targets.uncertain()), - }; + for frame in self.links[id].frame.as_slice() { + target = match frame { + Frame::At(..) | Frame::Layer(_) => target, + Frame::Selector(template) => Some(self.targets.nest(target, template)), + Frame::Params(_) => self.targets.consume(target), + }; + } self.links[id].target = std::cell::OnceCell::from(target); } self.targets.required(target) @@ -85,7 +92,7 @@ impl Contexts { let mut links = Vec::new(); let mut cursor = path; while let Some(id) = cursor { - links.push(&self.links[id].frame); + links.extend(self.links[id].frame.as_ref()); cursor = self.links[id].parent; } let mut selector = selector::SelectorText::default(); @@ -113,7 +120,6 @@ impl Contexts { } Frame::Layer(layer) => layers.push(layer.as_str()), Frame::Params(params) => selector.params(params), - Frame::UncertainParams => {} } } let selector = selector.text(); diff --git a/libs/extractor/src/own_css_selector.rs b/libs/extractor/src/own_css_selector.rs index c3722d67a..d1361e38d 100644 --- a/libs/extractor/src/own_css_selector.rs +++ b/libs/extractor/src/own_css_selector.rs @@ -138,9 +138,9 @@ struct Branch { } enum TargetsNode { - Item, - Join(usize, usize), - Unknown, + Item(Option), + Join(usize, usize, usize), + Unknown(Option), } #[derive(Default)] @@ -154,8 +154,7 @@ pub(crate) struct Targets { impl Targets { fn item(&mut self, required: bool, parent: Option) -> usize { let id = self.nodes.len(); - self.nodes.push(TargetsNode::Item); - self.tails.push(std::cell::OnceCell::from(parent)); + self.nodes.push(TargetsNode::Item(parent)); self.required.push(Some(required)); id } @@ -165,7 +164,8 @@ impl Targets { (None, other) | (other, None) => other, (Some(left), Some(right)) => { let id = self.nodes.len(); - self.nodes.push(TargetsNode::Join(left, right)); + self.nodes + .push(TargetsNode::Join(left, right, self.tails.len())); self.tails.push(std::cell::OnceCell::new()); self.required.push(self.required[right]); Some(id) @@ -177,16 +177,15 @@ impl Targets { let mut pending = Vec::new(); let mut cursor = root?; let mut tail = loop { - if let Some(tail) = self.tails[cursor].get().copied() { - break tail; - } match self.nodes[cursor] { - TargetsNode::Join(left, right) => { - pending.push((cursor, left)); + TargetsNode::Item(tail) | TargetsNode::Unknown(tail) => break tail, + TargetsNode::Join(left, right, cache) => { + if let Some(tail) = self.tails[cache].get().copied() { + break tail; + } + pending.push((cache, left)); cursor = right; } - TargetsNode::Item => break None, - TargetsNode::Unknown => break Some(cursor), } }; for (id, left) in pending.into_iter().rev() { @@ -217,8 +216,7 @@ impl Targets { pub fn uncertain(&mut self) -> usize { let id = self.nodes.len(); - self.nodes.push(TargetsNode::Unknown); - self.tails.push(std::cell::OnceCell::from(Some(id))); + self.nodes.push(TargetsNode::Unknown(Some(id))); self.required.push(None); self.save(vec![Branch { targets: Some(id), diff --git a/libs/extractor/src/stylesheet_coverage_contract_tests.rs b/libs/extractor/src/stylesheet_coverage_contract_tests.rs index 7fd3e609f..1216e8e44 100644 --- a/libs/extractor/src/stylesheet_coverage_contract_tests.rs +++ b/libs/extractor/src/stylesheet_coverage_contract_tests.rs @@ -702,3 +702,90 @@ fn nonparams_matrix_keeps_exact_legacy_materialization() -> Result<(), String> { } Ok(()) } + +#[rstest::rstest] +#[case( + "_nthChild", + "get params(){reads++;return ['2n']}", + "&:nth-child(2n):hover", + 1 +)] +#[case("_host", "set params(value){reads++}", "&:host:hover", 0)] +#[case("_nthChild", "params:[...values()]", "&:nth-child(2n):hover", 1)] +#[serial] +fn coverage_contract_partial_authored_params_use_native_values_once( + #[case] selector: &str, + #[case] metadata: &str, + #[case] expected_selector: &str, + #[case] reads: usize, + #[values(false, true)] single_css: bool, +) -> Result<(), String> { + // Given: valid native metadata sits beside an independently valid sibling. + let _state = crate::test_state::TestStateGuard::new(); + let expected = compile(&format!( + "export const card=css({{color:'red',selectors:{{'{expected_selector}':{{p:2}}}}}});" + ))?; + let source = format!( + "import{{css}}from'@devup-ui/react';let reads=0;function values(){{reads++;return ['2n']}}let data=0;for(let i=0;i<16;i++)data={{payload:data}};export const card=css({{props:data,color:'red',{selector}:{{{metadata},_hover:{{params:[],p:2}}}}}});export const count=reads;" + ); + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + // When + let actual = extract_without_source_map( + "/partial-params.css.ts", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .map_err(|error| error.to_string())?; + // Then + assert_eq!(actual.styles, expected.styles); + assert!( + actual + .code + .contains(&format!("export const count = {reads};")), + "{}", + actual.code + ); + Ok(()) +} + +#[rstest::rstest] +#[case("get params(){return ['2n']},_is:{params:['a',{}],p:2}", "{}")] +#[case("set params(value){},_is:{params:['a',{}],p:2}", "{}")] +#[case("params:[...values(),{}],p:2", "{}")] +#[case("get params(){return ['2n']},_nthChild:{params:[],p:2}", "[]")] +#[case("params:[...values()],_nthChild:{params:[],p:2}", "[]")] +fn coverage_contract_partial_params_preserve_known_leaf_and_required_errors( + #[case] rules: &str, + #[case] witness: &str, +) -> Result<(), String> { + // Given: uncertain parent params cannot hide a known bad leaf or a fresh required target. + let source = format!( + "import{{css}}from'@devup-ui/react';function values(){{return ['2n']}}export const card=css({{color:'red',_nthChild:{{{rules}}}}});" + ); + let offset = source + .rfind(witness) + .ok_or("missing authored refusal witness")?; + let location = crate::locate("/partial-errors.css.ts", &source, offset); + // When + let error = + crate::dead_properties::instrument(&source, "/partial-errors.css.ts", "@devup-ui/react") + .err() + .ok_or("known bad params were deferred")?; + // Then + assert_eq!( + error, + format!( + "{location}: {}", + crate::utils::build_time_error( + "css", + witness, + crate::extractor::selector_params::SELECTOR_PARAMS + ) + ) + ); + Ok(()) +} diff --git a/libs/extractor/src/unresolved_global_inputs_tests/coverage_edges.rs b/libs/extractor/src/unresolved_global_inputs_tests/coverage_edges.rs index 8e34fc227..1a8121221 100644 --- a/libs/extractor/src/unresolved_global_inputs_tests/coverage_edges.rs +++ b/libs/extractor/src/unresolved_global_inputs_tests/coverage_edges.rs @@ -224,3 +224,154 @@ fn evaluated_shadowed_undefined_is_value_proof_but_unresolved_shadow_is_not( assert!(output.styles.iter().any(|style| matches!(style, ExtractStyleValue::Static(style) if style.selector.as_ref().is_some_and(|selector| selector.to_string() == "&:hover")))); Ok(()) } + +#[rstest] +#[case("_nthChild", "[]", None)] +#[case("_nthChild", "['']", None)] +#[case("_nthChild", "['2n']", Some("&:nth-child(2n)"))] +#[case("_host", "[]", Some("&:host"))] +#[case("_host", "['.scope']", Some("&:host(.scope)"))] +#[serial] +fn media_and_layer_wrappers_preserve_native_params_requiredness( + #[case] target: &str, + #[case] params: &str, + #[case] selector: Option<&str>, + #[values(false, true)] single_css: bool, +) -> Result<(), String> { + // Given: media and layer frames wrap, rather than replace, the pending target. + let _state = crate::test_state::TestStateGuard::new(); + let compile = |source: &str| { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + crate::extract_without_source_map( + "/wrapped-params.css.ts", + source, + crate::ExtractOption { + single_css, + ..crate::ExtractOption::default() + }, + ) + .map_err(|error| error.to_string()) + }; + let source = format!( + "import{{css}}from'@devup-ui/react';let data=0;for(let i=0;i<16;i++)data={{payload:data}};export const card=css({{props:data,{target}:{{'@media':{{screen:{{'@layer':{{card:{{params:{params},p:2}}}}}}}}}}}});" + ); + let expected = selector.map(|selector| compile(&format!( + "import{{css}}from'@devup-ui/react';export const card=css({{'@layer':{{card:{{'@media':{{screen:{{selectors:{{'{selector}':{{p:2}}}}}}}}}}}}}});" + ))).transpose()?; + // When + let actual = compile(&source); + // Then + if let Some(expected) = expected { + assert_eq!(actual?.styles, expected.styles); + } else { + let witness = if params == "[]" { "[]" } else { "''" }; + let offset = source.find(witness).ok_or("missing empty params")?; + let location = crate::locate("/wrapped-params.css.ts", &source, offset); + let error = actual + .err() + .ok_or("wrapped required selector accepted empty params")?; + let printed = if params == "[]" { "[]" } else { "\"\"" }; + assert_eq!( + error, + format!( + "{location}: {}", + crate::utils::build_time_error( + "css", + printed, + crate::extractor::selector_params::SELECTOR_PARAMS + ) + ) + ); + } + Ok(()) +} + +#[rstest] +#[case(( + "", + "_nthChild:{selectors:{'& &':{params:['2n'],selectors:{'&':{params:['3n'],_host:{params:[],p:2},_cue:{params:[],w:3}}}}}}", + concat!("selectors:{'&:nth-child(3n) &:nth-child(2n):host':{", "p:2},'&:nth-child(3n) &:nth-child(2n)::cue':{", "w:3}}") +))] +#[case(( + "", + "_host:{selectors:{'&-context':{params:['.scope'],_hover:{params:[],p:2}}}}", + concat!("selectors:{'&:host-context(.scope):hover':{", "p:2}}") +))] +#[case(( + "let left={params:[],p:2};left={_host:left};left={params:['3n'],...left};left={selectors:{'&':left}};left={params:['2n'],...left};let right={params:[],w:3};right={_host:right};right={params:['5n'],...right};right={selectors:{'&':right}};right={params:['4n'],...right};let forks={selectors:{'&':left,'.copy &':right}};", + "_nthChild:{selectors:{'& &':forks}}", + concat!("selectors:{'&:nth-child(3n) &:nth-child(2n):host':{", "p:2},'.copy &:nth-child(5n) &:nth-child(4n):host':{", "w:3}}") +))] +#[serial] +fn native_pending_target_joins_and_lexical_replacement_match_flat_selectors( + #[case] fixture: (&str, &str, &str), + #[values(false, true)] single_css: bool, +) -> Result<(), String> { + // Given: independent flat selectors specify argument attachment and lexical replacement. + let _state = crate::test_state::TestStateGuard::new(); + let (setup, nested, flat) = fixture; + let compile = |body: &str| { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + crate::extract_without_source_map( + "/pending-params.css.ts", + &format!("import{{css}}from'@devup-ui/react';{body}"), + crate::ExtractOption { + single_css, + ..crate::ExtractOption::default() + }, + ) + .map_err(|error| error.to_string()) + }; + let expected = compile(&format!("export const card=css({{{flat}}});"))?; + let body = format!( + "let data=0;for(let i=0;i<16;i++)data={{payload:data}};{setup}export const card=css({{props:data,{nested}}});" + ); + // When + let actual = compile(&body)?; + // Then + assert_eq!(actual.styles, expected.styles); + assert_eq!(actual.code, expected.code); + Ok(()) +} + +#[rstest] +#[serial] +fn native_cached_join_tail_preserves_required_error_in_second_fork( + #[values(false, true)] single_css: bool, +) -> Result<(), String> { + // Given: the first fork consumes both targets; the second must retain one required target. + let _state = crate::test_state::TestStateGuard::new(); + let rule = "export const card=css({props:data,_nthChild:{selectors:{'& &':forks}}});"; + let source = format!( + "import{{css}}from'@devup-ui/react';\nlet data=0;for(let i=0;i<16;i++)data={{payload:data}};let left={{params:[],p:2}};left={{_host:left}};left={{params:['3n'],...left}};left={{selectors:{{'&':left}}}};left={{params:['2n'],...left}};let right={{params:[],w:3}};right={{selectors:{{'&':right}}}};right={{params:['4n'],...right}};let forks={{selectors:{{'&':left,'.copy &':right}}}};\n{rule}" + ); + let column = rule.find("css(").ok_or("missing native css call")? + 1; + // When + let error = crate::extract_without_source_map( + "/cached-required.css.ts", + &source, + crate::ExtractOption { + single_css, + ..crate::ExtractOption::default() + }, + ) + .err() + .ok_or("cached pop discarded the remaining required target")? + .to_string(); + // Then + assert!( + error.starts_with(&format!("/cached-required.css.ts:3:{column}:")), + "{error}" + ); + assert!( + error.contains(&crate::utils::build_time_error( + "css", + "_nthChild -> selectors -> & & -> selectors -> .copy & -> selectors -> & -> params", + crate::extractor::selector_params::SELECTOR_PARAMS, + )), + "{error}" + ); + Ok(()) +} diff --git a/libs/extractor/src/vanilla_extract_css_capture.rs b/libs/extractor/src/vanilla_extract_css_capture.rs index 0860efb13..0f376b761 100644 --- a/libs/extractor/src/vanilla_extract_css_capture.rs +++ b/libs/extractor/src/vanilla_extract_css_capture.rs @@ -259,6 +259,19 @@ pub(super) fn validate(plan: &Plan, root: usize) -> Vec<(String, &'static str)> Step::Key(property, kind, entry, mut context_path) => { path.push(property.name.clone()); pending.push(Step::Pop); + let mut push = |frame| { + if let Some(frame) = frame + && plan.has_params + { + context_path = Some(contexts.push(context_path, frame)); + } + pending.push(Step::Value( + property.value, + ObjectKind::Styles, + entry, + context_path, + )); + }; match kind { ObjectKind::Styles => { if let Some(requirement) = @@ -292,30 +305,14 @@ pub(super) fn validate(plan: &Plan, root: usize) -> Vec<(String, &'static str)> pending.push(Step::Value(property.value, kind, entry, context_path)); } } - ObjectKind::Selectors | ObjectKind::At(_) | ObjectKind::Layers => { - let frame = match kind { - ObjectKind::Selectors => crate::own_css_context::under(&property.name), - ObjectKind::At(kind) => Some(crate::own_css_context::Frame::At( - kind, - property.name.clone(), - )), - ObjectKind::Layers => { - Some(crate::own_css_context::Frame::Layer(property.name.clone())) - } - ObjectKind::Styles => None, - }; - if let Some(frame) = frame - && plan.has_params - { - context_path = Some(contexts.push(context_path, frame)); - } - pending.push(Step::Value( - property.value, - ObjectKind::Styles, - entry, - context_path, - )); - } + ObjectKind::Selectors => push(crate::own_css_context::under(&property.name)), + ObjectKind::At(kind) => push(Some(crate::own_css_context::Frame::At( + kind, + property.name.clone(), + ))), + ObjectKind::Layers => push(Some(crate::own_css_context::Frame::Layer( + property.name.clone(), + ))), } } Step::Value(id, kind, entry, mut context_path) => match &plan.nodes[id] { diff --git a/libs/extractor/src/vanilla_extract_css_capture_tests.rs b/libs/extractor/src/vanilla_extract_css_capture_tests.rs index 558138110..99e51e7fd 100644 --- a/libs/extractor/src/vanilla_extract_css_capture_tests.rs +++ b/libs/extractor/src/vanilla_extract_css_capture_tests.rs @@ -107,6 +107,8 @@ fn capture_validation_rejects_responsive_arrays_across_entry_objects( #[case(("_is", "['a',[]]"))] #[case(("_is", "['a',undefined]"))] #[case(("_is", "['a',null]"))] +#[case(("_is", "['a',1n]"))] +#[case(("_is", "['a',Infinity]"))] #[case(("_is", "['a',Object.defineProperty({},'payload',{enumerable:true,get(){throw Error('descendant')}})]"))] #[case(("_is", "['a',Object.defineProperty([],0,{enumerable:true,get(){throw Error('descendant')}})]"))] #[case(("_is", "['a',(()=>{const entry={};entry.self=entry;return entry})()]"))] @@ -688,3 +690,100 @@ fn evaluated_template_placement_owns_params_validation( } Ok(()) } + +#[test] +fn capture_preserves_root_and_nested_array_order_with_once_only_getters() -> Result<(), String> { + // Given: native array getters expose both repeated reads and traversal order. + let names = FxHashMap::default(); + let collector = Rc::new(RefCell::new(Collector::default())); + let mut values = Values { + names: &names, + collector: &collector, + active: FxHashSet::default(), + key_slots: FxHashMap::default(), + plan: Plan::default(), + }; + let mut context = Context::default(); + let value = context + .eval(Source::from_bytes(concat!( + "let reads=[];const nested=Object.defineProperties([],{", + "0:{get(){reads.push('nested:0');return 2}},", + "1:{get(){reads.push('nested:1');return 'last'}}});", + "Object.defineProperties([],{", + "0:{get(){reads.push('root:0');return 'first'}},", + "1:{get(){reads.push('root:1');return nested}},", + "2:{get(){reads.push('root:2');return null}}})" + ))) + .map_err(|error| error.to_string())?; + // When + let root = values + .capture(&value, &mut context) + .map_err(|error| error.to_string())?; + // Then: the array graph and values survive without duplicate getter evaluation. + assert_eq!(values.plan.code(root), r#"["first",[2,"last"],null]"#); + let reads = context + .eval(Source::from_bytes("reads.join(',')")) + .map_err(|error| error.to_string())?; + assert_eq!( + reads.as_string().map(|text| text.to_std_string_escaped()), + Some("root:0,root:1,root:2,nested:0,nested:1".into()) + ); + Ok(()) +} + +#[rstest::rstest] +#[case("{}", false)] +#[case("'not-a-list'", false)] +#[case("['2n']", true)] +#[serial] +fn bridge_rejects_invalid_params_roots_inside_data_named_layers( + #[case] value: &str, + #[case] accepted: bool, + #[values(false, true)] single_css: bool, +) -> Result<(), String> { + // Given: props is a layer name, and its captured descendants retain their real node shapes. + let _state = crate::test_state::TestStateGuard::new(); + let rule = "export const card=css(rules);"; + let source = format!( + "import{{css}}from'@devup-ui/react';\nlet data=0;for(let i=0;i<16;i++)data={{payload:data}};const rules={{props:data,'@layer':{{props:{{_nthChild:{{params:{value},p:2}}}}}}}};\n{rule}" + ); + let column = rule.find("css(").ok_or("missing real css call")? + 1; + // When + let result = extract_without_source_map( + "/layer-params.css.ts", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ); + // Then + if accepted { + let output = result.map_err(|error| error.to_string())?; + assert_eq!(output.styles.len(), 2); + assert!( + output.styles.iter().any(|entry| matches!(entry, crate::ExtractStyleValue::Static(style) + if style.property() == "padding" && style.value() == "8px" + && style.layer.as_deref() == Some("props") + && style.selector.as_ref().is_some_and(|selector| selector.to_string() == "&:nth-child(2n)"))) + ); + assert!(output.styles.iter().any(|entry| matches!(entry, + crate::ExtractStyleValue::LayerOrder(order) + if order.file == "/layer-params.css.ts" && order.layers == vec!["props".to_string()] + ))); + } else { + let error = result + .err() + .ok_or("invalid params root scheduled a declaration")? + .to_string(); + assert!( + error.starts_with(&format!("/layer-params.css.ts:3:{column}:")), + "{error}" + ); + assert!( + error.contains(crate::extractor::selector_params::SELECTOR_PARAMS), + "{error}" + ); + } + Ok(()) +}