From 08a04858ba1538a62b47ad79f28a3a494d75b17a Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 9 Oct 2026 18:17:24 +0900 Subject: [PATCH] test(extractor): lock imported css composition precedence in both modes Refs #688 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- e2e/css-composition.spec.ts | 223 ++++++++++++++++++++++++++++++++++++ libs/extractor/src/lib.rs | 124 ++++++++++++++++++++ 2 files changed, 347 insertions(+) create mode 100644 e2e/css-composition.spec.ts diff --git a/e2e/css-composition.spec.ts b/e2e/css-composition.spec.ts new file mode 100644 index 000000000..54463931b --- /dev/null +++ b/e2e/css-composition.spec.ts @@ -0,0 +1,223 @@ +import { expect, test } from '@playwright/test' + +import { + codeExtract, + exportCanonicalMap, + exportClassMap, + exportFileMap, + exportSheet, + getCss, + getPrefix, + importCanonicalMap, + importClassMap, + importFileMap, + importSheet, + isDebug, + setDebug, + setModuleResolver, + setPrefix, +} from '../bindings/devup-ui-wasm/pkg/index.js' + +const api = "import { css } from '@devup-ui/react';" +const leafRules = `{ + color: ['orange', 'red', 'purple'], p: 2, + _hover: { color: ['tomato', 'red', 'purple'] } +}` +const laterRules = `{ + color: ['blue', null, 'green', null, 'pink'], margin: 3, + _hover: { color: ['navy', null, 'lime'] } +}` + +function exportClasses(code: string, name: string): string { + const literal = code.match( + new RegExp(`export const ${name}\\s*=\\s*("(?:[^"\\\\]|\\\\.)*")\\s*;`), + )?.[1] + if (literal === undefined) { + throw new TypeError(`Expected a compiled literal for ${name}: ${code}`) + } + const classes: unknown = JSON.parse(literal) + if (typeof classes !== 'string') { + throw new TypeError(`Expected class string for ${name}`) + } + expect(classes).toMatch(/^[a-zA-Z0-9_-]+(?: [a-zA-Z0-9_-]+)*$/) + return classes +} + +function compile(filename: string, source: string, singleCss: boolean) { + const output = codeExtract( + filename, + source, + '@devup-ui/react', + 'df', + singleCss, + false, + false, + {}, + ) + try { + const css = singleCss ? '' : output.css + if (css === undefined) { + throw new TypeError(`Expected per-file CSS for ${filename}`) + } + return { code: output.code, css, dependencies: output.dependencies } + } finally { + output.free() + } +} + +function artifacts( + singleCss: boolean, + parentSource: string, + childSource?: string, +) { + const saved = { + sheet: exportSheet(), + classes: exportClassMap(), + files: exportFileMap(), + canonical: exportCanonicalMap(), + debug: isDebug(), + prefix: getPrefix(), + } + try { + importClassMap({}) + importFileMap({}) + importCanonicalMap({}) + setDebug(false) + setPrefix(null) + setModuleResolver((specifier: string, importer: string) => + childSource !== undefined && + specifier === './child' && + importer === '/src/parent.ts' + ? { path: '/src/child.ts', code: childSource } + : undefined, + ) + const child = + childSource === undefined + ? undefined + : compile('/src/child.ts', childSource, singleCss) + const parent = compile('/src/parent.ts', parentSource, singleCss) + if (child !== undefined) { + expect(parent.dependencies).toContain('/src/child.ts') + } + return { + css: singleCss + ? getCss(undefined, false) + : `${child?.css ?? ''}\n${parent.css}`, + parent: exportClasses(parent.code, 'base'), + child: exportClasses( + child?.code ?? parent.code, + child === undefined ? 'leaf' : 'base', + ), + } + } finally { + setModuleResolver(null) + importSheet(JSON.parse(saved.sheet)) + importClassMap(JSON.parse(saved.classes)) + importFileMap(JSON.parse(saved.files)) + importCanonicalMap(JSON.parse(saved.canonical)) + setDebug(saved.debug) + setPrefix(saved.prefix) + } +} + +for (const singleCss of [true, false]) { + test.describe(`css composition (${singleCss ? 'single' : 'per-file'} CSS)`, () => { + test('later argument wins across the imported W35i leaf', async ({ + page, + }) => { + const emitted = artifacts( + singleCss, + `${api} import { base as child } from './child'; export const base = css(child, { color: 'blue', margin: 3 });`, + `${api} export const base = css({ color: 'orange', p: 2 });`, + ) + await page.setContent(` +
Parent
+
Child
`) + await expect(page.locator('#parent')).toHaveCSS('color', 'rgb(0, 0, 255)') + await expect(page.locator('#parent')).toHaveCSS('padding', '8px') + await expect(page.locator('#parent')).toHaveCSS('margin', '12px') + await expect(page.locator('#child')).toHaveCSS( + 'color', + 'rgb(255, 165, 0)', + ) + await expect(page.locator('#child')).toHaveCSS('padding', '8px') + }) + + for (const chain of [false, true]) { + test(`later hover/responsive arguments win across ${chain ? 'a same-module three-level chain' : 'an imported leaf'}`, async ({ + page, + }) => { + const emitted = chain + ? artifacts( + singleCss, + `${api} + export const leaf = css(${leafRules}); + const middle = css(leaf, ${laterRules}); + export const base = css(middle, { + color: ['cyan', null, null, null, 'gold'], + _hover: { color: ['aqua', null, null, null, 'yellow'] } + });`, + ) + : artifacts( + singleCss, + `${api} import { base as child } from './child'; export const base = css(child, ${laterRules});`, + `${api} export const base = css(${leafRules});`, + ) + await page.setContent(` +
Parent
+
Child
`) + const widths = [ + [ + 320, + chain ? 'rgb(0, 255, 255)' : 'rgb(0, 0, 255)', + chain ? 'rgb(0, 255, 255)' : 'rgb(0, 0, 128)', + 'rgb(255, 165, 0)', + 'rgb(255, 99, 71)', + ], + [ + 600, + 'rgb(255, 0, 0)', + 'rgb(255, 0, 0)', + 'rgb(255, 0, 0)', + 'rgb(255, 0, 0)', + ], + [ + 900, + 'rgb(0, 128, 0)', + 'rgb(0, 255, 0)', + 'rgb(128, 0, 128)', + 'rgb(128, 0, 128)', + ], + [ + 1400, + chain ? 'rgb(255, 215, 0)' : 'rgb(255, 192, 203)', + chain ? 'rgb(255, 255, 0)' : 'rgb(0, 255, 0)', + 'rgb(128, 0, 128)', + 'rgb(128, 0, 128)', + ], + ] as const + for (const [ + width, + parentColor, + hoverColor, + childColor, + childHoverColor, + ] of widths) { + await page.setViewportSize({ width, height: 600 }) + await page.mouse.move(width - 1, 599) + await expect(page.locator('#parent')).toHaveCSS('color', parentColor) + await expect(page.locator('#parent')).toHaveCSS('padding', '8px') + await expect(page.locator('#parent')).toHaveCSS('margin', '12px') + await expect(page.locator('#child')).toHaveCSS('color', childColor) + await page.locator('#parent').hover() + await expect(page.locator('#parent')).toHaveCSS('color', hoverColor) + await page.locator('#child').hover() + await expect(page.locator('#child')).toHaveCSS( + 'color', + childHoverColor, + ) + } + }) + } + }) +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 7a0def3fc..46fec6bde 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -14813,6 +14813,130 @@ export const d = css(ordered, danger, called);", } } + fn assert_composed_export_atoms(code: &str, export: &str, expected: &[&str]) { + let declaration = format!("export const {export} = \""); + let classes = code + .split_once(&declaration) + .expect("compiled export must be a static class string") + .1 + .split_once('"') + .unwrap() + .0; + let mut actual: Vec<_> = classes + .split_whitespace() + .map(|class| class.trim_end_matches("-a")) + .collect(); + actual.sort_unstable(); + let mut expected = expected.to_vec(); + expected.sort_unstable(); + assert_eq!(actual, expected, "{code}"); + } + + #[rstest] + #[case( + "{ color: 'orange', p: 2 }", + "{ color: 'blue', margin: 3 }", + &["color-0-blue--255", "padding-0-8px--255", "margin-0-12px--255"] + )] + #[case( + "{ color: ['orange', 'red', 'purple'], p: 2, _hover: { color: ['orange', 'red', 'purple'] } }", + "{ color: ['blue', null, 'green', null, 'pink'], margin: 3, _hover: { color: ['blue', null, 'green'] } }", + &[ + "color-0-blue--255", "color-1-red--255", "color-2-green--255", + "color-4-pink--255", "padding-0-8px--255", "margin-0-12px--255", + "color-0-blue-_a__c_hover-255", "color-1-red-_a__c_hover-255", + "color-2-green-_a__c_hover-255" + ] + )] + #[serial] + fn test_css_later_argument_wins_across_leaf( + #[case] earlier: &'static str, + #[case] later: &str, + #[case] expected: &[&str], + #[values(false, true)] imported: bool, + #[values(false, true)] single_css: bool, + ) { + reset_class_map(); + reset_file_map(); + let modules: &[(&str, &str)] = &[ + ( + "/src/child.ts", + "import { css } from '@devup-ui/react'; export const base = css({ color: 'orange', p: 2 });", + ), + ( + "/src/responsive.ts", + "import { css } from '@devup-ui/react'; export const base = css({ color: ['orange', 'red', 'purple'], p: 2, _hover: { color: ['orange', 'red', 'purple'] } });", + ), + ]; + let leaf = if earlier.contains("_hover") { + "responsive" + } else { + "child" + }; + let binding = if imported { + format!("import {{ base as child }} from './{leaf}';") + } else { + format!("const child = css({earlier});") + }; + let source = format!( + "import {{ css }} from '@devup-ui/react'; {binding} export const base = css(child, {later});" + ); + let resolver = memory_resolver(modules); + css::debug::set_debug(true); + let output = extract_with_modules( + "/src/parent.ts", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + false, + &resolver, + ); + css::debug::set_debug(false); + let output = output.unwrap(); + assert_composed_export_atoms(&output.code, "base", expected); + if imported { + assert!(output.dependencies.contains(&format!("/src/{leaf}.ts"))); + } + } + + #[rstest] + #[serial] + fn test_css_later_argument_wins_across_three_levels(#[values(false, true)] single_css: bool) { + reset_class_map(); + reset_file_map(); + let resolver = memory_resolver(&[]); + css::debug::set_debug(true); + let output = extract_with_modules( + "/src/chain.ts", + "import { css } from '@devup-ui/react'; +const leaf = css({ color: ['orange', 'red', 'purple'], p: 2, _hover: { color: ['orange', 'red', 'purple'] }, styleOrder: 3 }); +const middle = css(leaf, { color: ['blue', null, 'green'], margin: 3, _hover: { color: ['blue', null, 'green'] } }); +export const base = css(middle, { color: ['cyan', null, null, null, 'pink'], _hover: { color: 'cyan' } });", + ExtractOption { single_css, ..ExtractOption::default() }, + false, + &resolver, + ); + css::debug::set_debug(false); + let output = output.unwrap(); + assert_composed_export_atoms( + &output.code, + "base", + &[ + "color-0-cyan--255", + "color-1-red--3", + "color-2-green--255", + "color-4-pink--255", + "padding-0-8px--3", + "margin-0-12px--255", + "color-0-cyan-_a__c_hover-255", + "color-1-red-_a__c_hover-3", + "color-2-green-_a__c_hover-255", + ], + ); + } + // Each Tailwind class becomes the classes of its styles; every other class, // and every class that runs into an interpolation, stays as written #[rstest]