From b95b5e72593505a64012ac0b2c41505afd7c7431 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 8 Oct 2026 00:55:30 +0900 Subject: [PATCH 1/6] test: isolate compiler globals and execute real Bun registration hooks Refs #683 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_release_compat.json | 10 + bindings/devup-ui-wasm/README.md | 19 +- bindings/devup-ui-wasm/src/lib.rs | 77 +++-- bindings/devup-ui-wasm/src/state.rs | 42 +++ bindings/devup-ui-wasm/src/state/tests.rs | 120 +++++++ bun.setup.ts | 100 ++++-- bunfig.toml | 8 +- libs/css/src/atom_hoist.rs | 1 + libs/css/src/class_map.rs | 2 + libs/css/src/debug.rs | 1 + libs/css/src/file_map.rs | 6 + libs/css/src/file_routes.rs | 3 + libs/css/src/lib.rs | 25 ++ libs/css/src/test_state.rs | 95 ++++++ libs/css/src/test_state/tests.rs | 107 ++++++ libs/css/src/theme_tokens.rs | 56 +++- libs/extractor/src/lib.rs | 69 +++- libs/extractor/src/test_state.rs | 30 ++ libs/extractor/src/vanilla_extract.rs | 15 + .../src/vanilla_extract/test_state.rs | 79 +++++ libs/extractor/tests/vanilla_extract.rs | 3 + libs/sheet/src/lib.rs | 46 +++ .../bun-plugin/src/__tests__/plugin.test.ts | 316 +++++++++--------- .../src/__tests__/coordinator.test.ts | 9 +- .../src/__tests__/css-loader.test.ts | 4 + .../next-plugin/src/__tests__/wasm.test.ts | 39 ++- packages/next-plugin/src/wasm.ts | 22 +- .../src/__tests__/wasm-abi.test.ts | 64 +++- 28 files changed, 1101 insertions(+), 267 deletions(-) create mode 100644 .changepacks/changepack_log_release_compat.json create mode 100644 bindings/devup-ui-wasm/src/state.rs create mode 100644 bindings/devup-ui-wasm/src/state/tests.rs create mode 100644 libs/css/src/test_state.rs create mode 100644 libs/css/src/test_state/tests.rs create mode 100644 libs/extractor/src/test_state.rs create mode 100644 libs/extractor/src/vanilla_extract/test_state.rs diff --git a/.changepacks/changepack_log_release_compat.json b/.changepacks/changepack_log_release_compat.json new file mode 100644 index 000000000..c82e922e7 --- /dev/null +++ b/.changepacks/changepack_log_release_compat.json @@ -0,0 +1,10 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/next-plugin/package.json": "Patch", + "packages/bun-plugin/package.json": "Patch", + "packages/plugin-utils/package.json": "Patch" + }, + "note": "Add an internal aggregate compiler-state reset for isolated tests, scoped native cleanup and Next test-cache isolation; verify real Bun registration callbacks and packed production consumers across bundlers and browsers. No new application build errors are introduced.", + "date": "2026-10-07T00:00:00.000Z" +} diff --git a/bindings/devup-ui-wasm/README.md b/bindings/devup-ui-wasm/README.md index 1a0976d80..344330695 100644 --- a/bindings/devup-ui-wasm/README.md +++ b/bindings/devup-ui-wasm/README.md @@ -41,13 +41,24 @@ Everything is exported from `pkg/index.js` (types in `pkg/index.d.ts`). ## State The module keeps its state, the sheet, class maps, theme, prefix and debug flag, -in one global per process, and there is no single reset. Two consequences: +in one global per process. Two consequences: - Register the theme, shorthands, prefix and resolver before the first `codeExtract`, and extract the files of one build in one process. -- Tests that share a process share that state. Set what a test depends on - (`registerTheme`, `setPrefix`, `setDebug`, `importSheet`) at its start, and - run files that need an empty sheet in their own process. +- Tests that share a process share that state. The internal/testing-only + `resetStateForTesting()` export (available in normal artifacts) clears the + sheet, resolver, prefix/debug/hoist configuration, class/file/canonical/route + maps, shorthands, theme-token registry and imported stylesheet caches. Call + only at a quiescent test boundary, never in a resolver callback or between + environments of a build that intentionally share state. Native caches reset + on the calling thread. + +Rust tests use hidden `css::test_state::TestStateGuard` and +`extractor::test_state::TestStateGuard` support compiled into dependencies so +dependent-crate tests can restore owned snapshots on normal exit, nesting and +unwind. Binding tests compose these with sheet/resolver snapshots. Keep a named +guard on its creating thread and retain `#[serial]`: guards clean up but do not +serialize concurrent tests. Cleanup does not run for panic=abort. ## Errors diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 0a2347ac2..d77270307 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -15,6 +15,11 @@ use std::fmt::Display; use std::sync::{LazyLock, Mutex}; use wasm_bindgen::prelude::*; +mod state; +#[cfg(test)] +use state::TestStateGuard; +pub use state::{reset_state_for_testing, reset_state_internal}; + static GLOBAL_STYLE_SHEET: LazyLock> = LazyLock::new(|| Mutex::new(StyleSheet::default())); @@ -683,6 +688,10 @@ mod tests { use serial_test::serial; use sheet::theme::{ColorTheme, Theme, Typography}; + mod state_tests { + include!("state/tests.rs"); + } + fn make_named_color_theme(name: &str, value: &str) -> ColorTheme { let mut ct = ColorTheme::default(); ct.add_color(name, value); @@ -697,6 +706,7 @@ mod tests { use css::file_map::reset_file_map; use css::file_routes::{reset_file_routes, set_file_routes}; use std::collections::{HashMap, HashSet}; + let _state = TestStateGuard::new(); { let mut s = GLOBAL_STYLE_SHEET.lock().unwrap(); @@ -784,11 +794,10 @@ mod tests { #[allow(clippy::items_after_statements, clippy::format_push_string)] fn emit_split_measurement_artifacts() { use css::atom_hoist::set_atom_hoist; - use css::class_map::reset_class_map; - use css::file_map::reset_file_map; use css::file_routes::{reset_file_routes, set_file_routes}; use std::collections::{HashMap, HashSet}; use std::fs; + let _state = TestStateGuard::new(); if std::env::var("DEVUP_EMIT_MEASURE").is_err() { return; @@ -815,16 +824,7 @@ mod tests { els.join("") ) }; - let reset = || { - { - let mut s = GLOBAL_STYLE_SHEET.lock().unwrap(); - *s = StyleSheet::default(); - } - reset_class_map(); - reset_file_map(); - reset_file_routes(); - register_theme_internal(sheet::theme::Theme::default()); - }; + let reset = reset_state_internal; let out = std::env::temp_dir().join("devup-split-measure"); let _ = fs::remove_dir_all(&out); @@ -969,8 +969,6 @@ mod tests { #[allow(clippy::cast_precision_loss, clippy::doc_markdown)] fn atom_b_beats_per_file_on_session_and_invalidation() { use css::atom_hoist::set_atom_hoist; - use css::class_map::reset_class_map; - use css::file_map::reset_file_map; use css::file_routes::{reset_file_routes, set_file_routes}; use std::collections::{HashMap, HashSet}; @@ -979,6 +977,7 @@ mod tests { const ROUTES: usize = 8; const UNIVERSAL: usize = 80; const PRIVATE: usize = 25; + let _state = TestStateGuard::new(); let props = ["w", "h", "p", "m", "minW", "minH", "maxW", "maxH"]; let atom = |key: &str, px: usize| format!(""); @@ -986,16 +985,7 @@ mod tests { let body = elements.join(""); format!("import {{ Box }} from \"@devup-ui/react\"; const x = <>{body};") }; - let reset_engine = || { - { - let mut s = GLOBAL_STYLE_SHEET.lock().unwrap(); - *s = StyleSheet::default(); - } - reset_class_map(); - reset_file_map(); - reset_file_routes(); - register_theme_internal(sheet::theme::Theme::default()); - }; + let reset_engine = reset_state_internal; let universal_atoms: Vec = (0..UNIVERSAL) .map(|i| atom(props[i % props.len()], 100_000 + i)) @@ -1110,6 +1100,7 @@ mod tests { use css::atom_hoist::atom_hoist_threshold; use css::file_routes::{get_file_routes, reset_file_routes}; use std::collections::{HashMap, HashSet}; + let _state = TestStateGuard::new(); // setAtomHoist binding controls the global threshold. set_atom_hoist(None); @@ -1133,6 +1124,7 @@ mod tests { #[serial] fn test_canonical_map_import_export_roundtrip() { use css::file_map::{get_canonical_map, reset_canonical_map}; + let _state = TestStateGuard::new(); reset_canonical_map(); let mut m = HashMap::new(); m.insert("src/child.tsx".to_string(), "src/parent.tsx".to_string()); @@ -1150,6 +1142,7 @@ mod tests { #[test] #[serial] fn test_code_extract() { + let _state = TestStateGuard::new(); { let mut sheet = GLOBAL_STYLE_SHEET.lock().unwrap(); *sheet = StyleSheet::default(); @@ -1178,6 +1171,7 @@ mod tests { #[test] #[serial] fn test_code_extract_with_modules() { + let _state = TestStateGuard::new(); { let mut sheet = GLOBAL_STYLE_SHEET.lock().unwrap(); *sheet = StyleSheet::default(); @@ -1209,6 +1203,7 @@ mod tests { #[test] #[serial] fn deserialize_theme() { + let _state = TestStateGuard::new(); { let theme: Theme = serde_json::from_str( r##"{ @@ -1291,6 +1286,7 @@ mod tests { #[test] #[serial] fn to_css_from_theme() { + let _state = TestStateGuard::new(); let mut theme = Theme::default(); let mut color_theme = ColorTheme::default(); color_theme.add_color("primary", "#000"); @@ -1401,6 +1397,7 @@ mod tests { #[test] #[serial] fn test_get_theme_interface() { + let _state = TestStateGuard::new(); register_shorthands_internal(BTreeMap::new()); let sheet = StyleSheet::default(); assert_eq!( @@ -1471,6 +1468,7 @@ mod tests { #[test] #[serial] fn test_debug() { + let _state = TestStateGuard::new(); assert!(!is_debug()); set_debug(true); assert!(is_debug()); @@ -1481,6 +1479,7 @@ mod tests { #[test] #[serial] fn test_prefix() { + let _state = TestStateGuard::new(); assert_eq!(get_prefix(), None); set_prefix(Some("du-".to_string())); assert_eq!(get_prefix(), Some("du-".to_string())); @@ -1491,6 +1490,7 @@ mod tests { #[test] #[serial] fn test_default_theme() { + let _state = TestStateGuard::new(); let mut theme = Theme::default(); theme.add_color_theme("light", ColorTheme::default()); theme.add_color_theme("dark", ColorTheme::default()); @@ -1520,6 +1520,7 @@ mod tests { #[test] #[serial] fn test_output_new_and_getters() { + let _state = TestStateGuard::new(); // Reset global state *GLOBAL_STYLE_SHEET.lock().unwrap() = StyleSheet::default(); css::class_map::reset_class_map(); @@ -1560,6 +1561,7 @@ mod tests { #[test] #[serial] fn test_output_updated_base_style() { + let _state = TestStateGuard::new(); // Reset global state *GLOBAL_STYLE_SHEET.lock().unwrap() = StyleSheet::default(); css::class_map::reset_class_map(); @@ -1585,6 +1587,7 @@ mod tests { #[test] #[serial] fn test_has_devup_ui_wasm_function() { + let _state = TestStateGuard::new(); // Test positive case assert!(has_devup_ui_wasm( "test.tsx", @@ -1610,6 +1613,7 @@ mod tests { #[test] #[serial] fn test_output_single_css_mode() { + let _state = TestStateGuard::new(); // Reset global state *GLOBAL_STYLE_SHEET.lock().unwrap() = StyleSheet::default(); css::class_map::reset_class_map(); @@ -1646,6 +1650,7 @@ mod tests { #[test] #[serial] fn test_output_with_global_css_removal() { + let _state = TestStateGuard::new(); // Reset global state let mut sheet = StyleSheet::default(); @@ -1687,16 +1692,7 @@ mod tests { // globalCss (@font-face / global selectors). When child.tsx collapses into // parent.tsx, extracting child must not delete parent's globalCss. fn collapse_setup() { - use css::class_map::reset_class_map; - use css::file_map::{reset_canonical_map, reset_file_map}; - { - let mut s = GLOBAL_STYLE_SHEET.lock().unwrap(); - *s = StyleSheet::default(); - } - reset_class_map(); - reset_file_map(); - reset_canonical_map(); - register_theme_internal(sheet::theme::Theme::default()); + reset_state_internal(); } fn extract_for_collapse(filename: &str, code: &str) { @@ -1720,6 +1716,7 @@ mod tests { #[test] #[serial] fn collapse_member_after_root_keeps_global_css() { + let _state = TestStateGuard::new(); collapse_setup(); let mut m = HashMap::new(); m.insert("footer.tsx".to_string(), "layout.tsx".to_string()); @@ -1749,6 +1746,7 @@ mod tests { #[test] #[serial] fn collapse_member_before_root_keeps_global_css() { + let _state = TestStateGuard::new(); collapse_setup(); let mut m = HashMap::new(); m.insert("footer.tsx".to_string(), "layout.tsx".to_string()); @@ -1773,6 +1771,7 @@ mod tests { #[test] #[serial] fn collapse_member_with_own_global_css_preserves_both() { + let _state = TestStateGuard::new(); collapse_setup(); let mut m = HashMap::new(); m.insert("footer.tsx".to_string(), "layout.tsx".to_string()); @@ -1800,6 +1799,7 @@ mod tests { #[test] #[serial] fn collapse_multiple_members_keep_root_global_css() { + let _state = TestStateGuard::new(); collapse_setup(); let mut m = HashMap::new(); m.insert("footer.tsx".to_string(), "layout.tsx".to_string()); @@ -1822,6 +1822,7 @@ mod tests { #[test] #[serial] fn collapse_member_reextract_clears_only_its_own_global_css() { + let _state = TestStateGuard::new(); collapse_setup(); let mut m = HashMap::new(); m.insert("footer.tsx".to_string(), "layout.tsx".to_string()); @@ -1864,6 +1865,7 @@ mod tests { #[test] #[serial] fn test_import_sheet_internal() { + let _state = TestStateGuard::new(); // Reset global state *GLOBAL_STYLE_SHEET.lock().unwrap() = StyleSheet::default(); css::class_map::reset_class_map(); @@ -1886,6 +1888,7 @@ mod tests { #[test] #[serial] fn test_export_sheet_internal() { + let _state = TestStateGuard::new(); // Reset global state *GLOBAL_STYLE_SHEET.lock().unwrap() = StyleSheet::default(); @@ -1902,6 +1905,7 @@ mod tests { #[test] #[serial] fn test_export_class_map_internal() { + let _state = TestStateGuard::new(); // Reset class map css::class_map::reset_class_map(); @@ -1917,6 +1921,7 @@ mod tests { #[test] #[serial] fn test_export_file_map_internal() { + let _state = TestStateGuard::new(); // Reset file map css::file_map::reset_file_map(); @@ -1932,6 +1937,7 @@ mod tests { #[test] #[serial] fn test_code_extract_internal_success() { + let _state = TestStateGuard::new(); // Reset global state *GLOBAL_STYLE_SHEET.lock().unwrap() = StyleSheet::default(); css::class_map::reset_class_map(); @@ -1958,6 +1964,7 @@ mod tests { #[test] #[serial] fn test_code_extract_without_source_map_internal() { + let _state = TestStateGuard::new(); *GLOBAL_STYLE_SHEET.lock().unwrap() = StyleSheet::default(); css::class_map::reset_class_map(); @@ -1982,6 +1989,7 @@ mod tests { #[test] #[serial] fn test_code_extract_internal_error() { + let _state = TestStateGuard::new(); // Reset global state *GLOBAL_STYLE_SHEET.lock().unwrap() = StyleSheet::default(); css::class_map::reset_class_map(); @@ -2007,6 +2015,7 @@ mod tests { #[test] #[serial] fn test_register_theme_internal() { + let _state = TestStateGuard::new(); // Reset global state *GLOBAL_STYLE_SHEET.lock().unwrap() = StyleSheet::default(); diff --git a/bindings/devup-ui-wasm/src/state.rs b/bindings/devup-ui-wasm/src/state.rs new file mode 100644 index 000000000..46a803c64 --- /dev/null +++ b/bindings/devup-ui-wasm/src/state.rs @@ -0,0 +1,42 @@ +use wasm_bindgen::prelude::*; + +/// Internal test boundary: clear the engine, including calling-thread caches. +/// Never call during extraction or a resolver callback, or between build environments. +pub fn reset_state_internal() { + super::with_style_sheet_mut(|sheet| *sheet = sheet::StyleSheet::default()); + super::MODULE_RESOLVER.with_borrow_mut(|resolver| *resolver = None); + css::test_state::reset_state_for_testing(); + extractor::test_state::reset_caches_for_testing(); +} + +/// Internal/testing-only reset, deliberately present in normal WASM artifacts. +#[wasm_bindgen(js_name = "resetStateForTesting")] +pub fn reset_state_for_testing() { + reset_state_internal(); +} + +#[cfg(test)] +pub(crate) struct TestStateGuard { + sheet: sheet::StyleSheet, + resolver: Option, + _engine: extractor::test_state::TestStateGuard, +} + +#[cfg(test)] +impl TestStateGuard { + pub(crate) fn new() -> Self { + Self { + sheet: super::with_style_sheet_mut(std::mem::take), + resolver: super::MODULE_RESOLVER.with_borrow_mut(Option::take), + _engine: extractor::test_state::TestStateGuard::new(), + } + } +} + +#[cfg(test)] +impl Drop for TestStateGuard { + fn drop(&mut self) { + super::with_style_sheet_mut(|sheet| *sheet = std::mem::take(&mut self.sheet)); + super::MODULE_RESOLVER.with_borrow_mut(|resolver| *resolver = self.resolver.take()); + } +} diff --git a/bindings/devup-ui-wasm/src/state/tests.rs b/bindings/devup-ui-wasm/src/state/tests.rs new file mode 100644 index 000000000..de4009b72 --- /dev/null +++ b/bindings/devup-ui-wasm/src/state/tests.rs @@ -0,0 +1,120 @@ +use crate::state::*; +use crate::*; +use serial_test::serial; +use std::collections::{BTreeMap, HashMap}; + +fn extract_fixture() -> Output { + code_extract_internal( + "fresh.tsx", + "import { Box } from '@devup-ui/react'; export const x = ;", + "@devup-ui/react", + "df".into(), + true, + false, + false, + HashMap::new(), + ) + .unwrap() +} + +#[test] +#[serial] +fn reset_returns_fresh_extraction_when_engine_is_dirty() { + let _state = TestStateGuard::new(); + let baseline = extract_fixture().code(); + let baseline_css = get_css(None, false).unwrap(); + set_prefix(Some("dirty".into())); + set_debug(true); + set_atom_hoist(Some(2)); + register_shorthands_internal(BTreeMap::from([("alias".into(), vec!["left".into()])])); + register_theme_internal( + serde_json::from_str( + r#"{ + "colors":{"default":{"brand":"red"}}, + "length":{"default":{"space":["1px",null,"2px"]}}, + "shadow":{"default":{"card":["0 1px 2px black",null,"0 2px 4px black"]}}, + "typography":{"body":{"fontSize":"12px"}}, + "breakpoints":[0,900] + }"#, + ) + .unwrap(), + ); + css::file_map::set_canonical_map(HashMap::from([("member".into(), "root".into())])); + import_file_routes_internal(HashMap::from([( + "fresh.tsx".into(), + std::collections::HashSet::from([1, 2]), + )])); + extract_fixture(); + reset_state_for_testing(); + assert_eq!( + export_sheet_internal().unwrap(), + serde_json::to_string(&sheet::StyleSheet::default()).unwrap() + ); + assert_eq!(get_default_theme().unwrap(), None); + assert!(MODULE_RESOLVER.with_borrow(Option::is_none)); + assert_eq!(get_prefix(), None); + assert!(!is_debug()); + assert_eq!(css::atom_hoist::atom_hoist_threshold(), None); + assert_eq!(css::class_map::get_class_map(), HashMap::new()); + assert_eq!(css::file_map::get_file_map().len(), 0); + assert_eq!(css::file_map::get_canonical_map(), HashMap::new()); + assert_eq!(css::file_routes::get_file_routes(), HashMap::new()); + assert_eq!(css::get_custom_shorthand_names(), Vec::::new()); + assert_eq!( + css::theme_tokens::get_responsive_theme_token("$space"), + None + ); + assert_eq!(css::theme_tokens::get_responsive_theme_token("$card"), None); + assert_eq!( + css::theme_tokens::get_typography_keys(), + Vec::::new() + ); + assert_eq!(extract_fixture().code(), baseline); + assert_eq!(get_css(None, false).unwrap(), baseline_css); +} + +#[test] +#[serial] +fn reset_is_idempotent_when_engine_is_empty() { + let _state = TestStateGuard::new(); + reset_state_internal(); + let baseline = export_sheet_internal().unwrap(); + reset_state_internal(); + assert_eq!(export_sheet_internal().unwrap(), baseline); +} + +#[test] +#[serial] +fn binding_scope_restores_sheet_when_nested() { + let _state = TestStateGuard::new(); + extract_fixture(); + let baseline = export_sheet_internal().unwrap(); + { + let _nested = TestStateGuard::new(); + assert_eq!( + get_css(None, false).unwrap(), + sheet::StyleSheet::default().create_css(None, false) + ); + set_prefix(Some("inner".into())); + extract_fixture(); + } + assert_eq!(export_sheet_internal().unwrap(), baseline); + assert_eq!(get_prefix(), None); +} + +#[test] +#[serial] +fn binding_scope_restores_sheet_when_unwinding() { + let _state = TestStateGuard::new(); + extract_fixture(); + let baseline = export_sheet_internal().unwrap(); + let result = std::panic::catch_unwind(|| { + let _nested = TestStateGuard::new(); + set_debug(true); + extract_fixture(); + panic!("test unwind"); + }); + assert!(result.is_err()); + assert_eq!(export_sheet_internal().unwrap(), baseline); + assert!(!is_debug()); +} diff --git a/bun.setup.ts b/bun.setup.ts index 6d9937530..4399dfa38 100644 --- a/bun.setup.ts +++ b/bun.setup.ts @@ -1,36 +1,64 @@ -export {} - -function cartesianProduct(arrays: T) { - return arrays.reduce( - (acc, curr) => acc.flatMap((x) => curr.map((y) => [...x, y])), - [[]], - ) -} - -function createTestMatrix>( - optionsMap: T, -): { - [K in keyof T]: T[K] extends (infer U)[] ? U : never -}[] { - const keys = Object.keys(optionsMap) - const values = Object.values(optionsMap) - - return cartesianProduct(values).map<{ - [K in keyof T]: T[K] extends (infer U)[] ? U : never - }>( - (combination) => - Object.fromEntries(keys.map((key, i) => [key, combination[i]])) as { - [K in keyof T]: T[K] extends (infer U)[] ? U : never - }, - ) -} - -globalThis.createTestMatrix = createTestMatrix - -declare global { - function createTestMatrix>( - optionsMap: T, - ): { - [K in keyof T]: T[K] extends (infer U)[] ? U : never - }[] -} +import { afterEach, beforeEach, spyOn } from 'bun:test' + +import { resetWasmForTesting } from './packages/next-plugin/src/wasm' +import { createModuleResolver } from './packages/plugin-utils/src' + +const { resetStateForTesting, setDebug, setModuleResolver } = + await import('./bindings/devup-ui-wasm/pkg/index.js') +const testModuleResolver = createModuleResolver() +const nativeBunPlugin = Bun.plugin +export const bunRegistration = Object.assign(spyOn(Bun, 'plugin'), { + clearAll: nativeBunPlugin.clearAll, +}) +try { + await import('./packages/bun-plugin/src/plugin') +} finally { + bunRegistration.mockRestore() + // Restoring the global property leaves the retained import callable inert. + bunRegistration.mockImplementation(nativeBunPlugin) +} + +function resetTestState() { + resetStateForTesting() + setDebug(true) + setModuleResolver(testModuleResolver) + resetWasmForTesting() +} + +beforeEach(resetTestState) +afterEach(resetTestState) + +function cartesianProduct(arrays: T) { + return arrays.reduce( + (acc, curr) => acc.flatMap((x) => curr.map((y) => [...x, y])), + [[]], + ) +} + +function createTestMatrix>( + optionsMap: T, +): { + [K in keyof T]: T[K] extends (infer U)[] ? U : never +}[] { + const keys = Object.keys(optionsMap) + const values = Object.values(optionsMap) + + return cartesianProduct(values).map<{ + [K in keyof T]: T[K] extends (infer U)[] ? U : never + }>( + (combination) => + Object.fromEntries(keys.map((key, i) => [key, combination[i]])) as { + [K in keyof T]: T[K] extends (infer U)[] ? U : never + }, + ) +} + +globalThis.createTestMatrix = createTestMatrix + +declare global { + function createTestMatrix>( + optionsMap: T, + ): { + [K in keyof T]: T[K] extends (infer U)[] ? U : never + }[] +} diff --git a/bunfig.toml b/bunfig.toml index 1241b1841..e423c0262 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,11 +1,9 @@ [test] root = "packages" -preload = ["./packages/bun-plugin/src/index.ts", "./bun.setup.ts", "bun-test-env-dom"] +preload = ["./bun.setup.ts", "./packages/bun-plugin/src/index.ts", "bun-test-env-dom"] coverage = true coverageReporter = ["text", "lcov"] coverageThreshold = { line = 1.0, function = 1.0, statement = 1.0 } coverageSkipTestFiles = true -# pkg/dist are generated artifacts. bun-plugin/src/plugin.ts registers a Bun -# plugin at import time; testing its private hooks would require exporting -# internals that are not part of the public API. -coveragePathIgnorePatterns = ["**/pkg/**", "**/dist/**", "**/bun-plugin/src/plugin.ts"] +# Generated artifacts are tested separately by the packed-consumer release gate. +coveragePathIgnorePatterns = ["**/pkg/**", "**/dist/**"] diff --git a/libs/css/src/atom_hoist.rs b/libs/css/src/atom_hoist.rs index 259e4a6a7..8fce02bf2 100644 --- a/libs/css/src/atom_hoist.rs +++ b/libs/css/src/atom_hoist.rs @@ -33,6 +33,7 @@ mod tests { #[test] #[serial] fn test_atom_hoist() { + let _state = crate::test_state::TestStateGuard::new(); set_atom_hoist(None); assert!(!is_atom_hoist()); assert_eq!(atom_hoist_threshold(), None); diff --git a/libs/css/src/class_map.rs b/libs/css/src/class_map.rs index 010715047..62f265773 100644 --- a/libs/css/src/class_map.rs +++ b/libs/css/src/class_map.rs @@ -78,6 +78,7 @@ mod tests { #[test] #[serial] fn test_set_and_get_class_map() { + let _state = crate::test_state::TestStateGuard::new(); let mut test_map = HashMap::new(); test_map.insert(String::new(), HashMap::new()); set_class_map(test_map.clone()); @@ -88,6 +89,7 @@ mod tests { #[test] #[serial] fn test_reset_class_map() { + let _state = crate::test_state::TestStateGuard::new(); let mut test_map = HashMap::new(); test_map.insert(String::new(), HashMap::new()); set_class_map(test_map); diff --git a/libs/css/src/debug.rs b/libs/css/src/debug.rs index 4966f0df1..2be1945e0 100644 --- a/libs/css/src/debug.rs +++ b/libs/css/src/debug.rs @@ -20,6 +20,7 @@ mod tests { #[test] #[serial] fn test_set_debug() { + let _state = crate::test_state::TestStateGuard::new(); set_debug(true); assert!(is_debug()); set_debug(false); diff --git a/libs/css/src/file_map.rs b/libs/css/src/file_map.rs index 2a8c86a94..4c128130d 100644 --- a/libs/css/src/file_map.rs +++ b/libs/css/src/file_map.rs @@ -199,6 +199,7 @@ mod tests { #[test] #[serial] fn test_set_and_get_file_map() { + let _state = crate::test_state::TestStateGuard::new(); let mut test_map = BiHashMap::new(); test_map.insert("test-key".to_string(), 42); set_file_map(test_map.clone()); @@ -212,6 +213,7 @@ mod tests { #[test] #[serial] fn test_reset_file_map() { + let _state = crate::test_state::TestStateGuard::new(); let mut test_map = BiHashMap::new(); test_map.insert("reset-key".to_string(), 1); set_file_map(test_map); @@ -223,6 +225,7 @@ mod tests { #[test] #[serial] fn test_canonical_identity_when_empty() { + let _state = crate::test_state::TestStateGuard::new(); reset_canonical_map(); assert_eq!(canonical("a.tsx"), "a.tsx"); } @@ -230,6 +233,7 @@ mod tests { #[test] #[serial] fn test_canonical_mapped_and_unmapped() { + let _state = crate::test_state::TestStateGuard::new(); let mut m = std::collections::HashMap::new(); m.insert("child.tsx".to_string(), "parent.tsx".to_string()); set_canonical_map(m); @@ -243,6 +247,7 @@ mod tests { #[test] #[serial] fn test_canonical_map_roundtrip() { + let _state = crate::test_state::TestStateGuard::new(); let mut m = std::collections::HashMap::new(); m.insert("a".to_string(), "b".to_string()); set_canonical_map(m.clone()); @@ -270,6 +275,7 @@ mod tests { const THREADS: usize = 8; const FILES: usize = 200; + let _state = crate::test_state::TestStateGuard::new(); reset_file_map(); diff --git a/libs/css/src/file_routes.rs b/libs/css/src/file_routes.rs index 341660f3a..508efbb75 100644 --- a/libs/css/src/file_routes.rs +++ b/libs/css/src/file_routes.rs @@ -110,6 +110,7 @@ mod tests { #[test] #[serial] fn test_set_get_reset_file_routes() { + let _state = crate::test_state::TestStateGuard::new(); let mut m = HashMap::new(); m.insert("a.tsx".to_string(), HashSet::from([0u32, 1])); set_file_routes(m.clone()); @@ -121,6 +122,7 @@ mod tests { #[test] #[serial] fn test_route_count_for_files_union() { + let _state = crate::test_state::TestStateGuard::new(); let mut m = HashMap::new(); m.insert("a.tsx".to_string(), HashSet::from([0u32, 1])); m.insert("b.tsx".to_string(), HashSet::from([1u32, 2])); @@ -138,6 +140,7 @@ mod tests { #[test] #[serial] fn test_route_count_for_files_without_registered_routes() { + let _state = crate::test_state::TestStateGuard::new(); let mut m = HashMap::new(); m.insert("registered.tsx".to_string(), HashSet::from([0u32])); set_file_routes(m); diff --git a/libs/css/src/lib.rs b/libs/css/src/lib.rs index 576ba53b9..6b811a126 100644 --- a/libs/css/src/lib.rs +++ b/libs/css/src/lib.rs @@ -12,6 +12,8 @@ pub mod optimize_value; pub mod rm_css_comment; mod selector_separator; pub mod style_selector; +#[doc(hidden)] +pub mod test_state; pub mod theme_tokens; pub mod utils; @@ -256,6 +258,11 @@ static CUSTOM_SHORTHANDS: LazyLock>>> = LazyLock::new(|| RwLock::new(BTreeMap::new())); static HAS_CUSTOM_SHORTHANDS: AtomicBool = AtomicBool::new(false); +/// Clear custom aliases at a quiescent test boundary, including the fast-path flag. +pub fn reset_custom_shorthands() { + test_state::replace_shorthands((BTreeMap::new(), false)); +} + /// Replace the custom shorthand registry used by style extraction. pub fn set_custom_shorthands(shorthands: BTreeMap>) { if let Ok(mut registry) = CUSTOM_SHORTHANDS.write() { @@ -654,6 +661,7 @@ mod tests { #[test] #[serial] fn test_with_prefix_borrows_host_prefix() { + let _state = test_state::TestStateGuard::new(); set_prefix(Some("host-".to_string())); let prefix = with_prefix(str::to_string); @@ -717,6 +725,7 @@ mod tests { #[test] #[serial] fn test_sheet_to_variable_name() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); set_debug(false); assert_eq!(sheet_to_variable_name("background", 0, None), "--a"); @@ -734,6 +743,7 @@ mod tests { #[test] #[serial] fn test_debug_sheet_to_variable_name() { + let _state = test_state::TestStateGuard::new(); set_debug(true); assert_eq!( sheet_to_variable_name("background", 0, None), @@ -756,6 +766,7 @@ mod tests { #[test] #[serial] fn test_sheet_to_classname() { + let _state = test_state::TestStateGuard::new(); set_debug(false); reset_class_map(); assert_eq!( @@ -1031,6 +1042,7 @@ mod tests { #[test] #[serial] fn test_debug_sheet_to_classname() { + let _state = test_state::TestStateGuard::new(); set_debug(true); assert_eq!( sheet_to_classname("background", 0, None, None, None, None), @@ -1053,6 +1065,7 @@ mod tests { #[test] #[serial] fn test_debug_sheet_to_classname_encodes_value() { + let _state = test_state::TestStateGuard::new(); set_debug(true); for (property, value, expected) in [ ("scale", "0.8", "scale-0-_d_8--255"), @@ -1076,6 +1089,7 @@ mod tests { #[test] #[serial] fn test_debug_sheet_to_classname_with_filename() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); set_debug(true); // Debug mode + filename triggers the file_suffix branch (lines 234-235) @@ -1167,6 +1181,7 @@ mod tests { #[test] #[serial] fn test_set_class_map() { + let _state = test_state::TestStateGuard::new(); let mut map = HashMap::new(); map.insert(String::new(), HashMap::new()); map.entry(String::new()) @@ -1179,6 +1194,7 @@ mod tests { #[test] #[serial] fn test_keyframes_to_keyframes_name() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); set_debug(false); assert_eq!(keyframes_to_keyframes_name("spin", None), num_to_nm_base(0)); @@ -1230,6 +1246,7 @@ mod tests { #[test] #[serial] fn test_sheet_to_classname_with_prefix() { + let _state = test_state::TestStateGuard::new(); set_debug(false); reset_class_map(); set_prefix(Some("app-".to_string())); @@ -1248,6 +1265,7 @@ mod tests { #[test] #[serial] fn test_debug_sheet_to_classname_with_prefix() { + let _state = test_state::TestStateGuard::new(); set_debug(true); set_prefix(Some("my-".to_string())); @@ -1264,6 +1282,7 @@ mod tests { #[test] #[serial] fn test_sheet_to_variable_name_with_prefix() { + let _state = test_state::TestStateGuard::new(); set_debug(false); reset_class_map(); set_prefix(Some("app-".to_string())); @@ -1277,6 +1296,7 @@ mod tests { #[test] #[serial] fn test_keyframes_with_prefix() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); set_debug(false); set_prefix(Some("app-".to_string())); @@ -1290,6 +1310,7 @@ mod tests { #[test] #[serial] fn test_empty_prefix_is_same_as_none() { + let _state = test_state::TestStateGuard::new(); set_debug(false); reset_class_map(); @@ -1306,6 +1327,7 @@ mod tests { #[test] #[serial] fn test_keyframes_to_keyframes_name_with_filename() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); set_debug(false); // Test with filename to cover lines 148-151 @@ -1325,6 +1347,7 @@ mod tests { #[test] #[serial] fn test_sheet_to_classname_with_filename() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); set_debug(false); // Test with filename to cover the filename branch @@ -1345,6 +1368,7 @@ mod tests { #[test] #[serial] fn test_disassemble_property_size_hint() { + let _state = test_state::TestStateGuard::new(); // Mapped arm: the hint comes straight from the borrowed slice iterator. let mapped = disassemble_property("bg"); assert_eq!(mapped.size_hint(), (1, Some(1))); @@ -1360,6 +1384,7 @@ mod tests { #[test] #[serial] fn test_custom_shorthand() { + let _state = test_state::TestStateGuard::new(); set_custom_shorthands(BTreeMap::from([( "insetX".to_string(), vec![ diff --git a/libs/css/src/test_state.rs b/libs/css/src/test_state.rs new file mode 100644 index 000000000..a3f179e16 --- /dev/null +++ b/libs/css/src/test_state.rs @@ -0,0 +1,95 @@ +//! Hidden cross-crate test support. Guards require the existing serial test lock; +//! they provide cleanup, not mutual exclusion. Keep scopes on their creating thread +//! and use them only when extraction and callbacks are quiescent. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::marker::PhantomData; +use std::rc::Rc; +use std::sync::atomic::Ordering; + +use crate::{atom_hoist, class_map, debug, file_map, file_routes, theme_tokens}; + +pub(crate) fn replace_shorthands( + state: (BTreeMap>, bool), +) -> (BTreeMap>, bool) { + let mut registry = crate::CUSTOM_SHORTHANDS + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let old = std::mem::replace(&mut *registry, state.0); + let present = crate::HAS_CUSTOM_SHORTHANDS.swap(state.1, Ordering::Relaxed); + drop(registry); + crate::CUSTOM_SHORTHANDS.clear_poison(); + (old, present) +} + +/// Reset CSS-owned persistent state without retaining a snapshot. +pub fn reset_state_for_testing() { + crate::set_prefix(None); + debug::set_debug(false); + atom_hoist::set_atom_hoist(None); + class_map::reset_class_map(); + file_map::reset_file_map(); + file_map::reset_canonical_map(); + file_routes::reset_file_routes(); + crate::reset_custom_shorthands(); + theme_tokens::reset_theme_tokens(); +} + +/// Starts with clean CSS state and restores the exact prior state on Drop, +/// including unwind and nested scopes. Hold a named binding and the serial lock. +#[must_use] +pub struct TestStateGuard { + prefix: Option, + debug: bool, + hoist: Option, + classes: HashMap>, + files: bimap::BiHashMap, + canonical: HashMap, + routes: HashMap>, + shorthands: (BTreeMap>, bool), + tokens: theme_tokens::ThemeTokenRegistry, + same_thread: PhantomData>, +} + +impl TestStateGuard { + /// Snapshot owned values; no state lock is held during the test body. + pub fn new() -> Self { + let guard = Self { + prefix: crate::get_prefix(), + debug: debug::is_debug(), + hoist: atom_hoist::atom_hoist_threshold(), + classes: class_map::get_class_map(), + files: file_map::get_file_map(), + canonical: file_map::get_canonical_map(), + routes: file_routes::get_file_routes(), + shorthands: replace_shorthands((BTreeMap::new(), false)), + tokens: theme_tokens::replace_registry(Default::default()), + same_thread: PhantomData, + }; + reset_state_for_testing(); + guard + } +} + +impl Default for TestStateGuard { + fn default() -> Self { + Self::new() + } +} + +impl Drop for TestStateGuard { + fn drop(&mut self) { + crate::set_prefix(self.prefix.take()); + debug::set_debug(self.debug); + atom_hoist::set_atom_hoist(self.hoist); + class_map::set_class_map(std::mem::take(&mut self.classes)); + file_map::set_file_map(std::mem::take(&mut self.files)); + file_map::set_canonical_map(std::mem::take(&mut self.canonical)); + file_routes::set_file_routes(std::mem::take(&mut self.routes)); + replace_shorthands(std::mem::take(&mut self.shorthands)); + theme_tokens::replace_registry(std::mem::take(&mut self.tokens)); + } +} + +#[cfg(test)] +mod tests; diff --git a/libs/css/src/test_state/tests.rs b/libs/css/src/test_state/tests.rs new file mode 100644 index 000000000..524e3942f --- /dev/null +++ b/libs/css/src/test_state/tests.rs @@ -0,0 +1,107 @@ +use super::*; +use serial_test::serial; + +fn seed() { + crate::set_prefix(Some("outer".into())); + debug::set_debug(true); + atom_hoist::set_atom_hoist(Some(3)); + class_map::set_class_map(HashMap::from([( + "x".into(), + HashMap::from([("y".into(), 7)]), + )])); + let _file_num = file_map::get_file_num_by_filename("outer.tsx"); + file_map::set_canonical_map(HashMap::from([("member".into(), "root".into())])); + file_routes::set_file_routes(HashMap::from([("outer.tsx".into(), HashSet::from([2]))])); + crate::set_custom_shorthands(BTreeMap::from([("insetX".into(), vec!["left".into()])])); + theme_tokens::set_theme_token_levels( + BTreeMap::from([("space".into(), vec![0, 2])]), + BTreeMap::from([("card".into(), vec![0, 3])]), + ); + theme_tokens::set_typography_keys(vec!["body".into()]); +} + +fn assert_seed() { + assert_eq!(crate::get_prefix().as_deref(), Some("outer")); + assert!(debug::is_debug()); + assert_eq!(atom_hoist::atom_hoist_threshold(), Some(3)); + assert_eq!(class_map::get_class_map()["x"]["y"], 7); + assert_eq!(file_map::get_file_map().len(), 1); + assert_eq!(file_map::get_canonical_map()["member"], "root"); + assert_eq!( + file_routes::get_file_routes()["outer.tsx"], + HashSet::from([2]) + ); + assert_eq!( + crate::disassemble_property("insetX").collect::>(), + vec!["left"] + ); + assert_eq!( + theme_tokens::get_responsive_theme_token("$space"), + Some(vec![0, 2]) + ); + assert_eq!( + theme_tokens::get_responsive_theme_token("$card"), + Some(vec![0, 3]) + ); + assert_eq!(theme_tokens::get_typography_keys(), vec!["body"]); +} + +fn assert_clean() { + assert_eq!(crate::get_prefix(), None); + assert!(!debug::is_debug()); + assert_eq!(atom_hoist::atom_hoist_threshold(), None); + assert_eq!(class_map::get_class_map(), HashMap::new()); + assert_eq!(file_map::get_file_map().len(), 0); + assert_eq!(file_map::get_canonical_map(), HashMap::new()); + assert_eq!(file_routes::get_file_routes(), HashMap::new()); + assert_eq!(crate::get_custom_shorthand_names(), Vec::::new()); + assert!(!crate::HAS_CUSTOM_SHORTHANDS.load(Ordering::Relaxed)); + assert_eq!(theme_tokens::get_responsive_theme_token("$space"), None); + assert_eq!(theme_tokens::get_responsive_theme_token("$card"), None); + assert_eq!(theme_tokens::get_typography_keys(), Vec::::new()); +} + +#[test] +#[serial] +fn reset_clears_all_groups_when_populated() { + let _state = TestStateGuard::default(); + seed(); + reset_state_for_testing(); + assert_clean(); +} + +#[test] +#[serial] +fn reset_is_idempotent_when_empty() { + let _state = TestStateGuard::new(); + reset_state_for_testing(); + reset_state_for_testing(); + assert_clean(); +} + +#[test] +#[serial] +fn scope_restores_all_groups_when_nested() { + let _state = TestStateGuard::new(); + seed(); + { + let _nested = TestStateGuard::new(); + assert_clean(); + crate::set_prefix(Some("inner".into())); + } + assert_seed(); +} + +#[test] +#[serial] +fn scope_restores_all_groups_when_unwinding() { + let _state = TestStateGuard::new(); + seed(); + let result = std::panic::catch_unwind(|| { + let _nested = TestStateGuard::new(); + crate::set_prefix(Some("inner".into())); + panic!("test unwind"); + }); + assert!(result.is_err()); + assert_seed(); +} diff --git a/libs/css/src/theme_tokens.rs b/libs/css/src/theme_tokens.rs index 3b2346aed..8a9915d94 100644 --- a/libs/css/src/theme_tokens.rs +++ b/libs/css/src/theme_tokens.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use std::sync::{LazyLock, RwLock}; #[derive(Default, Debug)] -struct ThemeTokenRegistry { +pub(crate) struct ThemeTokenRegistry { length: BTreeMap>, shadow: BTreeMap>, typography: Vec, @@ -11,6 +11,22 @@ struct ThemeTokenRegistry { static TOKEN_REGISTRY: LazyLock> = LazyLock::new(|| RwLock::new(ThemeTokenRegistry::default())); +pub(crate) fn replace_registry(registry: ThemeTokenRegistry) -> ThemeTokenRegistry { + let previous = std::mem::replace( + &mut *TOKEN_REGISTRY + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner), + registry, + ); + TOKEN_REGISTRY.clear_poison(); + previous +} + +/// Clear all responsive tokens and typography names at a quiescent test boundary. +pub fn reset_theme_tokens() { + replace_registry(ThemeTokenRegistry::default()); +} + pub fn set_theme_token_levels( length: BTreeMap>, shadow: BTreeMap>, @@ -83,6 +99,7 @@ mod tests { #[test] #[serial] fn test_get_responsive_theme_token() { + let _state = crate::test_state::TestStateGuard::new(); let mut length = BTreeMap::new(); length.insert("containerX".to_string(), vec![0, 2]); let mut shadow = BTreeMap::new(); @@ -98,6 +115,7 @@ mod tests { #[test] #[serial] fn test_is_responsive_theme_token() { + let _state = crate::test_state::TestStateGuard::new(); let mut length = BTreeMap::new(); length.insert("containerX".to_string(), vec![0, 2]); length.insert("single".to_string(), vec![0]); @@ -116,6 +134,7 @@ mod tests { #[test] #[serial] fn test_typography_keys() { + let _state = crate::test_state::TestStateGuard::new(); set_typography_keys(vec!["body".to_string(), "title".to_string()]); assert_eq!(get_typography_keys(), vec!["body", "title"]); set_typography_keys(vec![]); @@ -128,4 +147,39 @@ mod tests { assert!(!is_responsive_theme_token(value)); } + + #[test] + #[serial] + fn scope_restores_registries_when_private_locks_are_poisoned() { + let _state = crate::test_state::TestStateGuard::new(); + set_typography_keys(vec!["outer".into()]); + crate::set_custom_shorthands(BTreeMap::from([("alias".into(), vec!["left".into()])])); + let nested = crate::test_state::TestStateGuard::new(); + for poison_tokens in [false, true] { + let result = std::panic::catch_unwind(|| { + if poison_tokens { + let _lock = TOKEN_REGISTRY + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + panic!("poison tokens"); + } + let _lock = crate::CUSTOM_SHORTHANDS + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + panic!("poison shorthands"); + }); + assert!(result.is_err()); + } + crate::test_state::reset_state_for_testing(); + assert!(!crate::CUSTOM_SHORTHANDS.is_poisoned()); + assert!(!TOKEN_REGISTRY.is_poisoned()); + assert_eq!(crate::get_custom_shorthand_names(), Vec::::new()); + assert_eq!(get_typography_keys(), Vec::::new()); + drop(nested); + assert_eq!(get_typography_keys(), vec!["outer"]); + assert_eq!( + crate::disassemble_property("alias").collect::>(), + vec!["left"] + ); + } } diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 2557e540e..df229369d 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -18,6 +18,8 @@ mod style_values; mod styled_reads; mod stylex; mod tailwind; +#[doc(hidden)] +pub mod test_state; mod util_type; mod utils; mod vanilla_extract; @@ -886,6 +888,7 @@ mod tests { #[serial] fn extract_canonical_bucket_merge() { use css::file_map::{reset_canonical_map, set_canonical_map}; + let _state = test_state::TestStateGuard::default(); reset_class_map(); reset_file_map(); reset_canonical_map(); @@ -919,6 +922,7 @@ mod tests { #[serial] fn extract_global_hoist() { use css::file_map::{GLOBAL_BUCKET, reset_canonical_map, set_canonical_map}; + let _state = test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); reset_canonical_map(); @@ -11917,6 +11921,7 @@ globalCss({ #[test] #[serial] fn test_vanilla_extract_style_css_ts() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); // .css.ts file with style function (vanilla-extract API) @@ -11941,6 +11946,7 @@ export const container: string = style({ background: "red", padding: 16 }) #[test] #[serial] fn test_vanilla_extract_style_css_js() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); // .css.js file with style function @@ -11986,6 +11992,7 @@ export const link = style({ color: "blue", textDecoration: "underline" }) #[test] #[serial] fn test_vanilla_extract_css_ts_with_vanilla_extract_import() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); // .css.ts file with import from @vanilla-extract/css (not @devup-ui/react) @@ -12024,6 +12031,7 @@ export const text = style({ #[test] #[serial] fn test_vanilla_extract_with_variable() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); // Variables should be evaluated at execution time @@ -12050,6 +12058,7 @@ export const button = style({ background: primaryColor, padding: spacing }) #[test] #[serial] fn test_vanilla_extract_with_computed() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); // Computed values should be evaluated @@ -12075,6 +12084,7 @@ export const box = style({ padding: base * 2, margin: base / 2 }) #[test] #[serial] fn test_vanilla_extract_with_spread() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); // Spread operator should work @@ -12100,6 +12110,7 @@ export const extended = style({ ...baseStyle, background: "red" }) #[test] #[serial] fn test_vanilla_extract_with_pseudo_selector() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); // devup-ui extension: _hover pseudo selector @@ -12124,6 +12135,7 @@ export const hoverButton = style({ background: "gray", _hover: { background: "bl #[test] #[serial] fn test_vanilla_extract_with_responsive_array() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); // devup-ui extension: responsive arrays @@ -12148,6 +12160,7 @@ export const responsiveBox = style({ padding: [8, 16, 32] }) #[test] #[serial] fn test_vanilla_extract_with_keyframes_and_global() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); // .css.ts file with keyframes (vanilla-extract API) @@ -12193,6 +12206,7 @@ globalStyle("body", { margin: 0, padding: 0 }) #[test] #[serial] fn test_vanilla_extract_create_var() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); // createVar - CSS variable creation @@ -12246,6 +12260,7 @@ export const box = style({ #[test] #[serial] fn test_vanilla_extract_style_variants() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); // styleVariants - create multiple style variants @@ -12298,6 +12313,7 @@ export const button = styleVariants({ #[test] #[serial] fn test_vanilla_extract_font_face() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); reset_file_map(); @@ -12355,6 +12371,7 @@ export const body = style({ #[test] #[serial] fn test_vanilla_extract_theme() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); reset_file_map(); @@ -12432,6 +12449,7 @@ export const darkTheme = createTheme(vars, { #[test] #[serial] fn test_layer_record_places_every_declaration() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( @@ -12463,6 +12481,7 @@ const A = styled.div({ '@layer': { base: { #[test] #[serial] fn test_vanilla_extract_layer_records() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( @@ -12492,6 +12511,7 @@ export const button = style({ color: 'red', '@layer': { [components]: { color: ' #[test] #[serial] fn test_vanilla_extract_layer() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); reset_file_map(); @@ -12524,6 +12544,7 @@ globalStyle('*', { #[test] #[serial] fn test_vanilla_extract_container() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); // createContainer - container queries @@ -12559,6 +12580,7 @@ export const responsive = style({ #[test] #[serial] fn test_vanilla_extract_global_theme() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); // createGlobalTheme - global theme variables on :root @@ -12592,6 +12614,7 @@ export const vars = createGlobalTheme(':root', { #[test] #[serial] fn test_vanilla_extract_composition() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); // style composition - array of styles @@ -12627,6 +12650,7 @@ export const button = style([base, interactive, { #[test] #[serial] fn test_vanilla_extract_selectors() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); // complex selectors @@ -12700,6 +12724,7 @@ export const child = style({ #[test] #[serial] fn test_vanilla_extract_media_queries() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); // @media queries @@ -12738,6 +12763,7 @@ export const responsive = style({ #[test] #[serial] fn test_vanilla_extract_supports() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); // @supports queries @@ -12932,6 +12958,7 @@ export const grid = style({ #[test] #[serial] fn test_vanilla_extract_invalid_js_execution() { + let _state = test_state::TestStateGuard::new(); // Test vanilla-extract file with invalid JS (covers line 107 fallback) reset_class_map(); reset_file_map(); @@ -12958,6 +12985,7 @@ export const broken = style((() => { throw new Error("fail"); })()) #[test] #[serial] fn test_vanilla_extract_empty_styles() { + let _state = test_state::TestStateGuard::new(); // Test vanilla-extract file that produces empty styles (covers line 116) reset_class_map(); reset_file_map(); @@ -12983,6 +13011,7 @@ const unused = 1; #[test] #[serial] fn test_vanilla_extract_constant_exports() { + let _state = test_state::TestStateGuard::new(); // Test vanilla-extract file with constant exports (covers lines 576-577) reset_class_map(); reset_file_map(); @@ -13010,6 +13039,7 @@ export const box = style({ padding: SPACING }) #[test] #[serial] fn test_vanilla_extract_theme_with_vars() { + let _state = test_state::TestStateGuard::new(); // Test createTheme with array destructuring [themeClass, vars] (covers lines 406-430) reset_class_map(); reset_file_map(); @@ -13044,6 +13074,7 @@ export const [lightTheme, vars] = createTheme({ #[test] #[serial] fn test_vanilla_extract_non_exported_theme() { + let _state = test_state::TestStateGuard::new(); // Test non-exported createTheme (covers theme branches without export) reset_class_map(); reset_file_map(); @@ -13077,6 +13108,7 @@ export const themed = style({ #[test] #[serial] fn test_vanilla_extract_style_composition_empty() { + let _state = test_state::TestStateGuard::new(); // Test style with empty composition array reset_class_map(); reset_file_map(); @@ -13103,6 +13135,7 @@ export const withEmpty = style([{}]) #[test] #[serial] fn test_vanilla_extract_style_variants_with_base() { + let _state = test_state::TestStateGuard::new(); // Test styleVariants with base composition (covers lines 1165-1177) reset_class_map(); reset_file_map(); @@ -13136,6 +13169,7 @@ export const sizes = styleVariants({ #[test] #[serial] fn test_vanilla_extract_layer_and_container() { + let _state = test_state::TestStateGuard::new(); // Test layer() and createContainer() together (covers lines 1207-1216) reset_class_map(); reset_file_map(); @@ -13167,6 +13201,7 @@ export const containerStyle = style({ #[test] #[serial] fn test_vanilla_extract_all_imports() { + let _state = test_state::TestStateGuard::new(); // Test file that uses css, globalCss, and keyframes together (covers lines 1049, 1052) reset_class_map(); reset_file_map(); @@ -13204,6 +13239,7 @@ export const box = style({ #[test] #[serial] fn test_vanilla_extract_theme_without_vars_name() { + let _state = test_state::TestStateGuard::new(); // Test createTheme two-arg form (covers lines 1108, 1111) reset_class_map(); reset_file_map(); @@ -13240,6 +13276,7 @@ export const darkTheme = createTheme(contract, { #[test] #[serial] fn test_vanilla_extract_font_face_with_style() { + let _state = test_state::TestStateGuard::new(); // Test fontFace used in style (covers fontFace placeholder replacement) reset_class_map(); reset_file_map(); @@ -13271,6 +13308,7 @@ export const text = style({ #[test] #[serial] fn test_vanilla_extract_vars_only() { + let _state = test_state::TestStateGuard::new(); // Test createVar exports (covers lines 1191-1192) reset_class_map(); reset_file_map(); @@ -13301,6 +13339,7 @@ export const box = style({ #[test] #[serial] fn test_vanilla_extract_global_theme_empty_vars() { + let _state = test_state::TestStateGuard::new(); // Test createGlobalTheme with empty vars (covers line 1142 branch) reset_class_map(); reset_file_map(); @@ -13327,6 +13366,7 @@ export const box = style({ padding: 8 }) #[test] #[serial] fn test_vanilla_extract_non_exported_styles() { + let _state = test_state::TestStateGuard::new(); // Test non-exported styles mixed with exported (covers export flag branches) reset_class_map(); reset_file_map(); @@ -13351,6 +13391,7 @@ export const publicStyle = style({ margin: 8 }) #[test] #[serial] fn test_vanilla_extract_selector_references() { + let _state = test_state::TestStateGuard::new(); // Test styles referencing each other in selectors (covers find_selector_references) reset_class_map(); reset_file_map(); @@ -13608,6 +13649,7 @@ globalCss({ #[test] #[serial] fn test_stylesheet_evaluation_errors() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); let error = extract( @@ -13638,6 +13680,7 @@ globalCss({ #[test] #[serial] fn test_vanilla_extract_execution_fallback() { + let _state = test_state::TestStateGuard::new(); // Test vanilla-extract file with execution error (covers line 116 fallback) reset_class_map(); reset_file_map(); @@ -13663,6 +13706,7 @@ const x = style({ padding: [[[}}} // invalid syntax #[test] #[serial] fn test_import_alias_vanilla_extract_named() { + let _state = test_state::TestStateGuard::new(); // Test @vanilla-extract/css named exports in regular .tsx files (NOT .css.ts) // Note: .css.ts files use vanilla-extract's own processing which doesn't go through import aliases reset_class_map(); @@ -13695,6 +13739,7 @@ const buttonStyle = css({ bg: 'red', p: 4 }) #[test] #[serial] fn test_vanilla_extract_keyframes_export() { + let _state = test_state::TestStateGuard::new(); // Test exported keyframes (covers lines 1052, 1152-1153) reset_class_map(); reset_file_map(); @@ -13728,6 +13773,7 @@ export const spinner = style({ animation: spin }) #[test] #[serial] fn test_vanilla_extract_theme_vars_name_only() { + let _state = test_state::TestStateGuard::new(); // Test createTheme with vars_name but no vars_object_json (covers line 1301) reset_class_map(); reset_file_map(); @@ -13755,6 +13801,7 @@ export const myTheme = createTheme({ #[test] #[serial] fn test_vanilla_extract_style_variants_mixed() { + let _state = test_state::TestStateGuard::new(); // Test styleVariants with mixed base and no-base (covers lines 1161-1184) reset_class_map(); reset_file_map(); @@ -13785,6 +13832,7 @@ export const buttons = styleVariants({ #[test] #[serial] fn test_vanilla_extract_global_theme_with_vars() { + let _state = test_state::TestStateGuard::new(); // Test createGlobalTheme with CSS vars (covers lines 1142-1144) reset_class_map(); reset_file_map(); @@ -13820,6 +13868,7 @@ export const box = style({ padding: 8 }) #[test] #[serial] fn test_vanilla_extract_font_face_empty_props() { + let _state = test_state::TestStateGuard::new(); // Test fontFace with minimal properties (covers line 1132-1135 empty props branch) reset_class_map(); reset_file_map(); @@ -13846,6 +13895,7 @@ export const text = style({ fontFamily: minimalFont }) #[test] #[serial] fn test_vanilla_extract_imports_combination() { + let _state = test_state::TestStateGuard::new(); // Test file with multiple import types (covers lines 1049, 1052 import generation) reset_class_map(); reset_file_map(); @@ -13874,6 +13924,7 @@ export const animated = style({ animation: fadeIn }) #[test] #[serial] fn test_vanilla_extract_theme_export_variations() { + let _state = test_state::TestStateGuard::new(); // Test createTheme with different export patterns (covers lines 1103-1111) reset_class_map(); reset_file_map(); @@ -13908,6 +13959,7 @@ export const box = style({ padding: 8 }) #[test] #[serial] fn test_vanilla_extract_style_composition_multiple() { + let _state = test_state::TestStateGuard::new(); // Test style with multiple style objects in composition array (covers lines 728-729) reset_class_map(); reset_file_map(); @@ -13934,6 +13986,7 @@ export const complex = style([base, { margin: 4 }, { color: 'blue' }]) #[test] #[serial] fn test_vanilla_extract_selector_class_replacement() { + let _state = test_state::TestStateGuard::new(); // Test selector references that need class name replacement (covers collected_styles_to_code_with_classes) reset_class_map(); reset_file_map(); @@ -13964,6 +14017,7 @@ export const child = style({ #[test] #[serial] fn test_vanilla_extract_all_exports_combined() { + let _state = test_state::TestStateGuard::new(); // Test file with styles, keyframes, globalStyles, themes, vars, containers, layers, fontFaces combined // Covers multiple import generation paths and code generation reset_class_map(); @@ -13996,6 +14050,7 @@ export const box = style({ fontFamily: myFont }) #[test] #[serial] fn test_vanilla_extract_theme_array_destructure() { + let _state = test_state::TestStateGuard::new(); // Test createTheme with array destructuring [themeClass, vars] (covers lines 384, 386-387) reset_class_map(); reset_file_map(); @@ -14025,6 +14080,7 @@ export const themed = style({ color: themeVars.colors.primary }) #[test] #[serial] fn test_vanilla_extract_font_face_placeholder() { + let _state = test_state::TestStateGuard::new(); // Test fontFace placeholder remapping (covers lines 503-505) reset_class_map(); reset_file_map(); @@ -14059,6 +14115,7 @@ export const heading = style({ fontFamily: secondFont }) #[test] #[serial] fn test_vanilla_extract_global_theme_placeholder() { + let _state = test_state::TestStateGuard::new(); // Test createGlobalTheme placeholder remapping (covers global theme paths) reset_class_map(); reset_file_map(); @@ -14183,6 +14240,7 @@ globalCss({ #[test] #[serial] fn test_vanilla_extract_selector_refs_triggers_with_classes() { + let _state = test_state::TestStateGuard::new(); // Test that triggers collected_styles_to_code_with_classes path (selector references) reset_class_map(); reset_file_map(); @@ -14213,6 +14271,7 @@ globalStyle('body', { margin: 0 }) #[test] #[serial] fn test_vanilla_extract_theme_without_vars_json() { + let _state = test_state::TestStateGuard::new(); // Test createTheme that has vars_name but might not have vars_object_json (covers line 1111) reset_class_map(); reset_file_map(); @@ -14277,6 +14336,7 @@ globalCss({ #[test] #[serial] fn test_selector_refs_with_global_theme() { + let _state = test_state::TestStateGuard::new(); // Test that triggers append_non_style_code with global themes (covers lines 1142-1144, 1221-1222) // Need selector references + createGlobalTheme reset_class_map(); @@ -14311,6 +14371,7 @@ export const child = style({ #[test] #[serial] fn test_vanilla_extract_with_at_container_selector() { + let _state = test_state::TestStateGuard::new(); // Test @container with selector context (covers line 134 in extract_style_from_expression.rs) reset_class_map(); reset_file_map(); @@ -14559,6 +14620,7 @@ const Button = styled.button({ bg: 'red' }) /// `code` extracted with readable class names fn readable_code(code: &str) -> String { + let _state = test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); css::debug::set_debug(true); @@ -14573,7 +14635,6 @@ const Button = styled.button({ bg: 'red' }) import_aliases: HashMap::new(), }, ); - css::debug::set_debug(false); match output { Ok(output) => output.code, Err(error) => error.to_string(), @@ -14946,6 +15007,7 @@ export const d = css(base, { positioning: side, styleOrder: 2 })", #[test] #[serial] fn test_css_composes_imported_classes() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); let modules: &[(&str, &str)] = &[( @@ -15754,6 +15816,7 @@ const styles = stylex.create({ #[test] #[serial] fn test_vanilla_extract_numbers_follow_vanilla_extract_units() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); let output = extract( @@ -17574,6 +17637,7 @@ const composed = stylex.create({ combined: { ...stylex.include(base.root) } });" #[test] #[serial] fn test_length_token_extraction() { + let _state = test_state::TestStateGuard::new(); // Test $token on gap prop reset_class_map(); reset_file_map(); @@ -17638,6 +17702,7 @@ const composed = stylex.create({ combined: { ...stylex.include(base.root) } });" #[test] #[serial] fn test_shadow_token_extraction() { + let _state = test_state::TestStateGuard::new(); // Test $token on boxShadow prop reset_class_map(); reset_file_map(); @@ -17683,6 +17748,7 @@ const composed = stylex.create({ combined: { ...stylex.include(base.root) } });" #[serial] fn test_responsive_length_token_literal_vs_array() { use css::theme_tokens::set_theme_token_levels; + let _state = test_state::TestStateGuard::new(); let mut length = BTreeMap::new(); length.insert("containerX".to_string(), vec![0, 2]); @@ -17773,6 +17839,7 @@ const composed = stylex.create({ combined: { ...stylex.include(base.root) } });" #[serial] fn test_responsive_shadow_token_literal_vs_array() { use css::theme_tokens::set_theme_token_levels; + let _state = test_state::TestStateGuard::new(); let mut shadow = BTreeMap::new(); shadow.insert("card".to_string(), vec![0, 3]); diff --git a/libs/extractor/src/test_state.rs b/libs/extractor/src/test_state.rs new file mode 100644 index 000000000..7d17c721f --- /dev/null +++ b/libs/extractor/src/test_state.rs @@ -0,0 +1,30 @@ +//! Cross-crate test cleanup; retain the existing serial lock and keep scopes on +//! their creating thread. Only reset at quiescent extraction boundaries. + +/// Clear imported evaluation and TypeScript caches on the calling thread. +pub fn reset_caches_for_testing() { + crate::vanilla_extract::test_state::reset(); +} + +/// Clean CSS state and extraction caches, restoring both on scope exit/unwind. +#[must_use] +pub struct TestStateGuard { + _caches: crate::vanilla_extract::test_state::CacheGuard, + _css: css::test_state::TestStateGuard, +} + +impl TestStateGuard { + /// Own snapshots without holding locks across extraction. + pub fn new() -> Self { + Self { + _caches: crate::vanilla_extract::test_state::CacheGuard::new(), + _css: css::test_state::TestStateGuard::new(), + } + } +} + +impl Default for TestStateGuard { + fn default() -> Self { + Self::new() + } +} diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 5c80e370a..f1e4e5916 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -309,6 +309,8 @@ thread_local! { static STRIPPED: RefCell> = RefCell::default(); } +pub(crate) mod test_state; + /// A name a top-level variable declaration of the stylesheet binds struct Binding { name: String, @@ -1459,6 +1461,7 @@ mod tests { #[test] fn test_strip_typescript_once_per_source() { + let _state = test_state::CacheGuard::new(); let stripped = strip_typescript("export const a: number = 1;", "strip-cache.ts"); assert_eq!( strip_typescript("export const a: number = 1;", "strip-cache.ts"), @@ -1497,6 +1500,7 @@ mod tests { #[test] #[serial] fn test_stylesheet_import_errors() { + let _state = crate::test_state::TestStateGuard::new(); assert_eq!( execute_stylesheet( "import { b } from './b'\nexport const x = b", @@ -1587,6 +1591,7 @@ mod tests { #[test] #[serial] fn test_names_follow_the_values_variables_hold() { + let _state = crate::test_state::TestStateGuard::new(); assert_eq!( generate_with( "export const sizes = { sm: style({ padding: 1 }), lg: style({ padding: 2 }) } @@ -1622,6 +1627,7 @@ export const animated = css({"animationName":"__style_0__"})"# #[test] #[serial] fn test_exported_values_become_code() { + let _state = crate::test_state::TestStateGuard::new(); assert_eq!( generate( "const box = style({ color: 'red' }) @@ -1666,6 +1672,7 @@ export const symbol = Symbol('x')"# #[test] #[serial] fn test_style_variants_are_styles() { + let _state = crate::test_state::TestStateGuard::new(); assert_eq!( generate( "export const tone = styleVariants({ primary: { color: 'red' }, 0: { color: 'blue' }, [Symbol('s')]: { color: 'green' } }) @@ -1691,6 +1698,7 @@ export const none = {}"# #[test] #[serial] fn test_composition_keeps_later_declarations() { + let _state = crate::test_state::TestStateGuard::new(); assert_eq!( generate( "const first = style({ color: 'red', margin: 1 }) @@ -1709,6 +1717,7 @@ const second = css({"color":"blue"})"# #[test] #[serial] fn test_composition_is_transitive() { + let _state = crate::test_state::TestStateGuard::new(); assert_eq!( generate( "const a = style({ color: 'red' }) @@ -1736,6 +1745,7 @@ export const hover = css({"selectors":{".f0_a:hover &":{"color":"blue"}}})"# #[test] #[serial] fn test_font_faces() { + let _state = crate::test_state::TestStateGuard::new(); assert_eq!( generate( "export const body = fontFace({ src: 'local(a)' }, 'Body Font') @@ -1755,6 +1765,7 @@ export const icons = "font-0-1""# #[test] #[serial] fn test_vars_follow_vanilla_extract() { + let _state = crate::test_state::TestStateGuard::new(); assert_eq!( generate( "export const plain = createVar() @@ -1785,6 +1796,7 @@ export const none = """# #[test] #[serial] fn test_layers_and_containers() { + let _state = crate::test_state::TestStateGuard::new(); assert_eq!( generate( "export const reset = layer() @@ -1814,6 +1826,7 @@ export const anonymous = "container-0-5""# #[test] #[serial] fn test_themes_and_contracts() { + let _state = crate::test_state::TestStateGuard::new(); assert_eq!( generate( "const contract = createThemeContract({ color: { brand: null, text: null }, list: [1], flag: true }) @@ -1854,6 +1867,7 @@ export const notObject = ["theme-0-9", {}]"# #[test] #[serial] fn test_nothing_collected() { + let _state = crate::test_state::TestStateGuard::new(); assert_eq!(generate("const x = 1"), ""); reset_file_map(); assert!(execute_vanilla_extract("throw new Error('x')", PACKAGE, "test.css.ts").is_err()); @@ -1897,6 +1911,7 @@ export const notObject = ["theme-0-9", {}]"# #[test] #[serial] fn test_referenced_keyframes() { + let _state = crate::test_state::TestStateGuard::new(); reset_file_map(); let collected = execute_vanilla_extract( "import { style, keyframes } from '@devup-ui/react' diff --git a/libs/extractor/src/vanilla_extract/test_state.rs b/libs/extractor/src/vanilla_extract/test_state.rs new file mode 100644 index 000000000..83b1e6815 --- /dev/null +++ b/libs/extractor/src/vanilla_extract/test_state.rs @@ -0,0 +1,79 @@ +use std::marker::PhantomData; + +use super::{CollectedStyles, FxHashMap, IMPORTED_RUNS, Rc, STRIPPED}; + +pub(crate) fn reset() { + IMPORTED_RUNS.with_borrow_mut(FxHashMap::clear); + STRIPPED.with_borrow_mut(FxHashMap::clear); +} + +pub(crate) struct CacheGuard { + imported: FxHashMap, + stripped: FxHashMap, + same_thread: PhantomData>, +} + +impl CacheGuard { + pub(crate) fn new() -> Self { + Self { + imported: IMPORTED_RUNS.with_borrow_mut(std::mem::take), + stripped: STRIPPED.with_borrow_mut(std::mem::take), + same_thread: PhantomData, + } + } +} + +impl Drop for CacheGuard { + fn drop(&mut self) { + IMPORTED_RUNS.with_borrow_mut(|runs| *runs = std::mem::take(&mut self.imported)); + STRIPPED.with_borrow_mut(|entries| *entries = std::mem::take(&mut self.stripped)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn caches_restore_when_nested_and_unwinding() { + let _state = CacheGuard::new(); + super::super::strip_typescript("export const n: number = 1", "outer.ts"); + IMPORTED_RUNS.with_borrow_mut(|runs| { + runs.insert( + "outer.css.ts".into(), + (1, "source".into(), "run".into(), CollectedStyles::default()), + ); + }); + let baseline = STRIPPED.with_borrow(Clone::clone); + let result = std::panic::catch_unwind(|| { + let _nested = CacheGuard::new(); + assert_eq!(IMPORTED_RUNS.with_borrow(FxHashMap::len), 0); + super::super::strip_typescript("export const n: number = 2", "inner.ts"); + panic!("test unwind"); + }); + assert!(result.is_err()); + assert_eq!(STRIPPED.with_borrow(Clone::clone), baseline); + assert_eq!(IMPORTED_RUNS.with_borrow(|runs| runs["outer.css.ts"].0), 1); + let nested = CacheGuard::new(); + drop(nested); + assert_eq!(STRIPPED.with_borrow(Clone::clone), baseline); + } + + #[test] + fn caches_clear_when_populated_and_empty() { + let _state = CacheGuard::new(); + super::super::strip_typescript("export const n: number = 1", "outer.ts"); + IMPORTED_RUNS.with_borrow_mut(|runs| { + runs.insert( + "outer.css.ts".into(), + (1, "source".into(), "run".into(), CollectedStyles::default()), + ); + }); + reset(); + assert_eq!(STRIPPED.with_borrow(FxHashMap::len), 0); + assert_eq!(IMPORTED_RUNS.with_borrow(FxHashMap::len), 0); + reset(); + assert_eq!(STRIPPED.with_borrow(FxHashMap::len), 0); + assert_eq!(IMPORTED_RUNS.with_borrow(FxHashMap::len), 0); + } +} diff --git a/libs/extractor/tests/vanilla_extract.rs b/libs/extractor/tests/vanilla_extract.rs index a3c938b86..f2de3cac5 100644 --- a/libs/extractor/tests/vanilla_extract.rs +++ b/libs/extractor/tests/vanilla_extract.rs @@ -26,6 +26,7 @@ fn extract_stylesheet(code: &str) -> String { #[test] #[serial] fn font_faces_extract_through_the_library() { + let _state = extractor::test_state::TestStateGuard::new(); let output = extract_stylesheet( r"const body = fontFace({ src: 'local(a)' }, 'Body') const icons = fontFace([{ src: 'local(b)' }, {}]) @@ -42,6 +43,7 @@ export const text = style({ fontFamily: body, content: icons })", #[test] #[serial] fn an_unreadable_var_declaration_is_reported() { + let _state = extractor::test_state::TestStateGuard::new(); let code = "import { createVar } from '@devup-ui/react'\nexport const v = createVar({ syntax: Symbol() })"; let error = extract( "broken.css.ts", @@ -63,6 +65,7 @@ fn an_unreadable_var_declaration_is_reported() { #[test] #[serial] fn vars_themes_and_layers_extract_through_the_library() { + let _state = extractor::test_state::TestStateGuard::new(); let output = extract_stylesheet( r"const plain = createVar() const typed = createVar({ syntax: ['', ''], inherits: false, initialValue: '0px' }, 'size') diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs index 9a989df8f..df796b235 100644 --- a/libs/sheet/src/lib.rs +++ b/libs/sheet/src/lib.rs @@ -1408,6 +1408,7 @@ mod tests { #[test] #[serial] fn test_create_css_sort_test() { + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); sheet.add_property("test", "background-color", 1, "red", None, None, None); sheet.add_property("test", "background", 1, "some", None, None, None); @@ -1431,6 +1432,7 @@ mod tests { use css::atom_hoist::set_atom_hoist; use css::file_routes::{reset_file_routes, set_file_routes}; use std::collections::{HashMap, HashSet}; + let _state = css::test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); @@ -1535,6 +1537,7 @@ mod tests { use css::atom_hoist::set_atom_hoist; use css::file_routes::{get_file_routes, set_file_routes}; use std::collections::{HashMap, HashSet}; + let _state = css::test_state::TestStateGuard::new(); let previous_threshold = atom_hoist_threshold(); let previous_routes = get_file_routes(); @@ -1577,6 +1580,7 @@ mod tests { #[serial] fn rm_global_css_clears_collapsed_globals_from_canonical_bucket() { use css::file_map::{reset_canonical_map, set_canonical_map}; + let _state = css::test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); reset_canonical_map(); @@ -1642,6 +1646,7 @@ mod tests { #[test] #[serial] fn font_faces_deduplicated_across_file_keys() { + let _state = css::test_state::TestStateGuard::new(); let props = BTreeMap::from([ ("font-family".to_string(), "Roboto".to_string()), ("src".to_string(), "url(/r.woff2)".to_string()), @@ -1663,6 +1668,7 @@ mod tests { #[test] #[serial] fn rm_global_css_clears_imports() { + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); sheet.add_import("a.tsx", "\"https://example.com/stale.css\""); assert!(sheet.create_css(None, false).contains("stale.css")); @@ -1676,6 +1682,7 @@ mod tests { #[test] #[serial] fn test_create_css_with_selector_sort_test() { + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); sheet.add_property( "test", @@ -1710,6 +1717,7 @@ mod tests { #[test] #[serial] fn test_create_css_with_basic_sort_test() { + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); sheet.add_property("test", "background-color", 1, "red", None, Some(0), None); sheet.add_property("test", "background", 1, "some", None, None, None); @@ -1729,6 +1737,7 @@ mod tests { #[test] #[serial] fn test_create_css_with_selector_and_basic_sort_test() { + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); sheet.add_property( "test", @@ -1752,6 +1761,7 @@ mod tests { #[test] #[serial] fn test_import_css() { + let _state = css::test_state::TestStateGuard::new(); let sheet = StyleSheet::default(); assert_debug_snapshot!( sheet @@ -1765,6 +1775,7 @@ mod tests { #[test] #[serial] fn test_create_css() { + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); sheet.add_property("test", "margin", 1, "40px", None, None, None); assert_debug_snapshot!(sheet.create_css(None, false).split("*/").nth(1).unwrap()); @@ -1925,6 +1936,7 @@ mod tests { #[test] #[serial] fn test_reset_global_css() { + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); sheet.add_css("test.tsx", "div {display:flex;}"); sheet.add_css("test2.tsx", "div {display:flex;}"); @@ -1942,6 +1954,7 @@ mod tests { #[test] #[serial] fn test_style_order_create_css() { + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); sheet.add_property("test", "margin-left", 0, "40px", None, Some(1), None); sheet.add_property("test", "margin-right", 0, "40px", None, Some(1), None); @@ -1971,6 +1984,7 @@ mod tests { #[test] #[serial] fn wrong_breakpoint() { + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); sheet.add_property("test", "margin-left", 10, "40px", None, None, None); sheet.add_property("test", "margin-right", 10, "40px", None, None, None); @@ -1980,6 +1994,7 @@ mod tests { #[test] #[serial] fn test_selector_with_prefix() { + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); sheet.add_property( "test", @@ -2023,6 +2038,7 @@ mod tests { #[test] #[serial] fn test_theme_selector() { + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); sheet.add_property( "test", @@ -2166,6 +2182,7 @@ mod tests { #[test] #[serial] fn test_print_selector() { + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); sheet.add_property( "test", @@ -2270,6 +2287,7 @@ mod tests { #[test] #[serial] fn test_screen_selector() { + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); sheet.add_property( "test", @@ -2287,6 +2305,7 @@ mod tests { #[test] #[serial] fn test_motion_reduce_selector() { + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); sheet.add_property( "test", @@ -2304,6 +2323,7 @@ mod tests { #[test] #[serial] fn test_all_media_selector() { + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); sheet.add_property( "test", @@ -2321,6 +2341,7 @@ mod tests { #[test] #[serial] fn test_selector_with_query() { + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); sheet.add_property( "test", @@ -2359,6 +2380,7 @@ mod tests { #[test] #[serial] fn test_selector_with_supports() { + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); sheet.add_property( "test", @@ -2382,6 +2404,7 @@ mod tests { #[test] #[serial] fn test_selector_with_container() { + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); sheet.add_property( "test", @@ -2480,6 +2503,7 @@ mod tests { #[test] #[serial] fn test_create_css_with_global_selector() { + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); sheet.add_property( "test", @@ -2656,6 +2680,7 @@ mod tests { #[test] #[serial] fn test_create_css_with_imports() { + let _state = css::test_state::TestStateGuard::new(); { let mut sheet = StyleSheet::default(); sheet.add_import("test.tsx", "@devup-ui/core/css/global.css"); @@ -2687,6 +2712,7 @@ mod tests { #[test] #[serial] fn test_get_theme_interface() { + let _state = css::test_state::TestStateGuard::new(); let sheet = StyleSheet::default(); assert_eq!( sheet.create_interface( @@ -2830,6 +2856,7 @@ mod tests { #[test] #[serial] fn test_keyframes() { + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); let mut keyframes: BTreeMap> = BTreeMap::new(); @@ -2868,6 +2895,7 @@ mod tests { #[test] #[serial] fn test_font_face() { + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); let mut font_face_props = BTreeMap::new(); font_face_props.insert("font-family".to_string(), "Roboto".to_string()); @@ -2891,6 +2919,7 @@ mod tests { #[test] #[serial] fn test_update_styles() { + let _state = css::test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); let mut sheet = StyleSheet::default(); @@ -2913,6 +2942,7 @@ mod tests { #[serial] fn test_update_styles_with_typography() { use extractor::extract_style::extract_style_value::ExtractStyleValue; + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); let mut styles = FxHashSet::default(); @@ -2926,6 +2956,7 @@ mod tests { #[test] #[serial] fn test_global_styles_with_custom_layer() { + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); // Add global style with layer sheet.add_property_with_layer( @@ -2979,6 +3010,7 @@ mod tests { #[test] #[serial] fn test_custom_layer_keeps_selector_order() { + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); for (selector, value) in [("a:active", "blue"), ("a:hover", "red"), ("a", "black")] { sheet.add_property_with_layer( @@ -3005,6 +3037,7 @@ mod tests { #[test] #[serial] fn test_at_rules_with_breakpoints() { + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); // Add @supports with breakpoint (level 1) sheet.add_property( @@ -3031,6 +3064,7 @@ mod tests { #[test] #[serial] fn test_container_with_breakpoints() { + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); // Add @container with breakpoint (level 1) sheet.add_property( @@ -3057,6 +3091,7 @@ mod tests { #[test] #[serial] fn test_theme_layer_in_css() { + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); let mut theme = Theme::default(); let mut color_theme = ColorTheme::default(); @@ -3076,6 +3111,7 @@ mod tests { #[test] #[serial] fn test_layer_with_breakpoints() { + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); // Add @layer with breakpoint (level 1) sheet.add_property( @@ -3168,6 +3204,7 @@ mod tests { #[test] #[serial] fn test_existing_collection_buckets_are_reused() { + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); assert!(sheet.add_property("a", "color", 0, "red", None, None, Some("test.tsx"))); assert!(sheet.add_property("b", "display", 0, "block", None, None, Some("test.tsx"))); @@ -3192,6 +3229,7 @@ mod tests { #[test] #[serial] fn test_compute_hoisted_atoms_skips_base_style_order() { + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); sheet.add_property("base", "color", 0, "red", None, Some(0), Some("test.tsx")); @@ -3201,6 +3239,7 @@ mod tests { #[test] #[serial] fn test_keyframes_multi_property() { + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); let mut keyframes: BTreeMap> = BTreeMap::new(); // Multiple properties in a single keyframe step to cover the semicolon separator (line 548) @@ -3229,6 +3268,7 @@ mod tests { #[test] #[serial] fn test_first_value_theme_token_resolution_uses_base_value_only() { + let _state = css::test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); let mut sheet = StyleSheet::default(); @@ -3261,6 +3301,7 @@ mod tests { #[test] #[serial] fn test_first_value_without_dollar_prefix_uses_raw_value() { + let _state = css::test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); let mut sheet = StyleSheet::default(); @@ -3282,6 +3323,7 @@ mod tests { #[test] #[serial] fn test_first_value_box_shadow_resolves_shadow_token() { + let _state = css::test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); let mut sheet = StyleSheet::default(); @@ -3313,6 +3355,7 @@ mod tests { #[test] #[serial] fn test_important_in_css_via_add_property() { + let _state = css::test_state::TestStateGuard::new(); // Verify that !important in the value is preserved in the final CSS output let mut sheet = StyleSheet::default(); sheet.add_property( @@ -3333,6 +3376,7 @@ mod tests { } fn pipeline_css(theme: Theme, source: &str) -> String { + let _state = extractor::test_state::TestStateGuard::new(); css::debug::set_debug(false); css::set_prefix(None); css::atom_hoist::set_atom_hoist(None); @@ -3658,6 +3702,7 @@ mod tests { #[test] #[serial] fn test_rm_global_css_drops_global_at_rules() { + let _state = css::test_state::TestStateGuard::new(); let mut sheet = StyleSheet::default(); let output = extract( "global.tsx", @@ -3683,6 +3728,7 @@ mod tests { #[test] #[serial] fn test_dynamic_base_style_updates_base_sheet() { + let _state = css::test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); let mut sheet = StyleSheet::default(); diff --git a/packages/bun-plugin/src/__tests__/plugin.test.ts b/packages/bun-plugin/src/__tests__/plugin.test.ts index e93d67418..784000e0a 100644 --- a/packages/bun-plugin/src/__tests__/plugin.test.ts +++ b/packages/bun-plugin/src/__tests__/plugin.test.ts @@ -1,199 +1,201 @@ import * as fs from 'node:fs' import * as fsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' import { join } from 'node:path' import * as wasm from '@devup-ui/wasm' +import type { BunPlugin, PluginBuilder } from 'bun' import { + afterAll, afterEach, beforeEach, describe, expect, it, - mock, spyOn, } from 'bun:test' -let getDefaultThemeSpy: ReturnType -let existsSyncSpy: ReturnType -let readFileSpy: ReturnType -let writeFileSpy: ReturnType -let mkdirSpy: ReturnType -let registerThemeSpy: ReturnType -let getThemeInterfaceSpy: ReturnType -let getCssSpy: ReturnType -let consoleErrorSpy: ReturnType -let setDebugSpy: ReturnType -let hasDevupUISpy: ReturnType -let codeExtractSpy: ReturnType - -type CodeExtractResult = ReturnType - -function createCodeExtractResult(): CodeExtractResult { - return { - code: 'code', - css: '', - cssFile: null, - map: null, - updatedBaseStyle: false, - free: mock(), - [Symbol.dispose]: mock(), - } as unknown as CodeExtractResult -} +import { bunRegistration } from '../../../../bun.setup' +import { register } from '../plugin' + +type LoadCallback = Parameters[1] +type ResolveCallback = Parameters[1] +const root = fs.mkdtempSync(join(tmpdir(), 'devup-ui-bun-hooks-')) +const scopedRoot = join(root, '@devup-ui/fixture') +fs.mkdirSync(scopedRoot, { recursive: true }) +const realPlugin = Bun.plugin +const loads: { + options: Parameters[0] + callback: LoadCallback +}[] = [] +const resolves: ResolveCallback[] = [] +let exists: ReturnType> +let readConfig: ReturnType> +let write: ReturnType> +let writeSync: ReturnType> +let mkdir: ReturnType> beforeEach(() => { - getDefaultThemeSpy = spyOn(wasm, 'getDefaultTheme').mockReturnValue('default') - existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(false) - readFileSpy = spyOn(fsPromises, 'readFile').mockResolvedValue('{}') - writeFileSpy = spyOn(fsPromises, 'writeFile').mockResolvedValue(undefined) - mkdirSpy = spyOn(fsPromises, 'mkdir').mockResolvedValue(undefined) - registerThemeSpy = spyOn(wasm, 'registerTheme').mockReturnValue(undefined) - getThemeInterfaceSpy = spyOn(wasm, 'getThemeInterface').mockReturnValue('') - getCssSpy = spyOn(wasm, 'getCss').mockReturnValue('css') - consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}) - setDebugSpy = spyOn(wasm, 'setDebug').mockReturnValue(undefined) - hasDevupUISpy = spyOn(wasm, 'hasDevupUI').mockReturnValue(false) - codeExtractSpy = spyOn(wasm, 'codeExtract').mockReturnValue( - createCodeExtractResult(), + loads.length = 0 + resolves.length = 0 + exists = spyOn(fs, 'existsSync').mockReturnValue(false) + readConfig = spyOn(fsPromises, 'readFile').mockResolvedValue('{}') + write = spyOn(fsPromises, 'writeFile').mockResolvedValue(undefined) + writeSync = spyOn(fs, 'writeFileSync').mockReturnValue(undefined) + mkdir = spyOn(fsPromises, 'mkdir').mockResolvedValue(undefined) + const capturePlugin = Object.assign( + (config: T) => + realPlugin({ + ...config, + setup(builder) { + builder.onLoad = (options, callback) => { + loads.push({ options, callback }) + return builder + } + builder.onResolve = (_options, callback) => { + resolves.push(callback) + return builder + } + return config.setup(builder) + }, + }), + { clearAll: realPlugin.clearAll }, ) + bunRegistration.mockImplementation(capturePlugin) }) afterEach(() => { - getDefaultThemeSpy.mockRestore() - existsSyncSpy.mockRestore() - readFileSpy.mockRestore() - writeFileSpy.mockRestore() - mkdirSpy.mockRestore() - registerThemeSpy.mockRestore() - getThemeInterfaceSpy.mockRestore() - getCssSpy.mockRestore() - consoleErrorSpy.mockRestore() - setDebugSpy.mockRestore() - hasDevupUISpy.mockRestore() - codeExtractSpy.mockRestore() + bunRegistration.mockRestore() + bunRegistration.mockImplementation(realPlugin) + exists.mockRestore() + readConfig.mockRestore() + write.mockRestore() + writeSync.mockRestore() + mkdir.mockRestore() }) +afterAll(() => fs.rmSync(root, { recursive: true, force: true })) -describe('getDevupDefine', () => { - it('should return define object with theme', async () => { - getDefaultThemeSpy.mockReturnValue('dark') - expect(getDefaultThemeSpy()).toBe('dark') - }) - - it('should return empty object when no theme', async () => { - getDefaultThemeSpy.mockReturnValue(undefined) - expect(getDefaultThemeSpy()).toBe(undefined) +async function load(path: string, namespace = 'file') { + const hook = loads.find( + ({ options }) => + (options.namespace ?? 'file') === namespace && options.filter.test(path), + ) + if (!hook) throw new Error(`No load hook for ${path}`) + return hook.callback({ + path, + namespace, + loader: 'file', + defer: async () => {}, }) -}) - -describe('writeDataFiles behavior', () => { - it('should register theme from devup.json when it exists', async () => { - readFileSpy.mockResolvedValue('{"theme": {"colors": {"primary": "#000"}}}') - getThemeInterfaceSpy.mockReturnValue('interface CustomColors {}') - - // Simulate writeDataFiles behavior without exporting private plugin helpers. - const content = '{"theme": {"colors": {"primary": "#000"}}}' - const parsed = JSON.parse(content) - registerThemeSpy(parsed?.['theme'] ?? {}) +} - expect(registerThemeSpy).toHaveBeenCalledWith({ - colors: { primary: '#000' }, +describe('registered Bun hooks', () => { + it('creates cold directories before writing configuration and CSS', async () => { + await register() + expect(mkdir).toHaveBeenCalledWith('df', { recursive: true }) + expect(mkdir).toHaveBeenCalledWith(expect.stringContaining('devup-ui'), { + recursive: true, }) - }) - - it('should write theme.d.ts when interfaceCode is returned', async () => { - getThemeInterfaceSpy.mockReturnValue('interface CustomColors {}') - - const interfaceCode = getThemeInterfaceSpy( - '@devup-ui/react', - 'CustomColors', - 'DevupThemeTypography', - 'CustomLength', - 'CustomShadows', - 'DevupTheme', - ) - - if (interfaceCode) { - await writeFileSpy(join('df', 'theme.d.ts'), interfaceCode, 'utf-8') - } - - expect(writeFileSpy).toHaveBeenCalledWith( + expect(write).toHaveBeenCalledWith( join('df', 'theme.d.ts'), - 'interface CustomColors {}', + expect.any(String), 'utf-8', ) + expect(wasm.isDebug()).toBe(true) }) - it('should register empty theme when devup.json does not exist', async () => { - existsSyncSpy.mockReturnValue(false) - - // Simulate the missing config branch. - const content = undefined - if (!content) { - registerThemeSpy({}) - } - - expect(registerThemeSpy).toHaveBeenCalledWith({}) - }) - - it('should handle error and register empty theme on catch', async () => { - existsSyncSpy.mockImplementation((path: string) => path === 'devup.json') - readFileSpy.mockRejectedValue(new Error('Read error')) - - try { - await readFileSpy('devup.json', 'utf-8') - } catch (error) { - consoleErrorSpy(error) - registerThemeSpy({}) - } - - expect(consoleErrorSpy).toHaveBeenCalled() - expect(registerThemeSpy).toHaveBeenCalledWith({}) + it('loads the configured theme through the public registration API', async () => { + exists.mockReturnValue(true) + readConfig.mockResolvedValue( + '{"theme":{"colors":{"default":{"primary":"#123456"}}}}', + ) + await register() + expect(wasm.getCss(null, false)).toContain('#123456') + expect(mkdir).not.toHaveBeenCalled() }) - it('should handle JSON without theme key', async () => { - existsSyncSpy.mockImplementation((path: string) => path === 'devup.json') - - const content = '{"otherKey": "value"}' - const parsed = JSON.parse(content) - registerThemeSpy(parsed?.['theme'] ?? {}) - - expect(registerThemeSpy).toHaveBeenCalledWith({}) + it('registers an empty theme when reading configuration fails', async () => { + exists.mockReturnValue(true) + readConfig.mockRejectedValue(new Error('unreadable fixture config')) + await register() + expect(wasm.getDefaultTheme()).toBeUndefined() }) - it('should create css directory when it does not exist', async () => { - existsSyncSpy.mockReturnValue(false) - - // Simulate the directory creation branch without exposing internals. - if (!existsSyncSpy('df/devup-ui')) { - await mkdirSpy('df/devup-ui', { recursive: true }) - } - - expect(mkdirSpy).toHaveBeenCalledWith('df/devup-ui', { recursive: true }) + it('resolves generated stylesheets and loads their runtime placeholder', async () => { + await register() + const resolve = resolves[0] + if (!resolve) throw new Error('No CSS resolver registered') + const result = await resolve({ + path: './df/devup-ui/devup-ui.css', + importer: join(process.cwd(), 'entry.tsx'), + namespace: 'file', + kind: 'import-statement', + resolveDir: process.cwd(), + }) + if (!result?.namespace || !result.path) + throw new Error('Expected virtual CSS resolution') + expect(await load(result.path, result.namespace)).toEqual({ + contents: '', + loader: 'js', + }) }) -}) -describe('plugin preload coverage', () => { - // The plugin is preloaded via bunfig.toml, which covers the main execution paths - // These tests verify the plugin's behavior through its side effects - - it('should have called setDebug during initialization', () => { - // Plugin calls setDebug(true) in setup - // This is covered by the preload - expect(setDebugSpy).toBeDefined() + for (const [extension, loader] of [ + ['tsx', 'tsx'], + ['ts', 'ts'], + ['jsx', 'jsx'], + ['mjs', 'js'], + ['js', 'js'], + ] as const) { + it(`compiles a ${extension} source through the captured onLoad callback`, async () => { + const source = + extension === 'tsx' || extension === 'jsx' + ? "import { Box } from '@devup-ui/react'; export const view = " + : "import { css } from '@devup-ui/react'; export const className = css({ color: 'red' })" + const path = join( + extension === 'js' ? scopedRoot : root, + `source.${extension}`, + ) + await Bun.write(path, source) + await register() + const result = await load(path) + if (!result || !('contents' in result)) + throw new Error('Expected transformed source contents') + expect(result?.loader).toBe(loader) + expect(result.contents).not.toContain( + extension === 'tsx' || extension === 'jsx' ? ' { + const path = join(root, 'plain.ts') + await Bun.write(path, 'export const value = 42') + await register() + expect(await load(path)).toEqual({ + contents: 'export const value = 42', + loader: 'ts', + }) + expect(writeSync).not.toHaveBeenCalled() }) - it('should have registered theme during initialization', () => { - // Plugin calls registerTheme during writeDataFiles - // This is covered by the preload - expect(registerThemeSpy).toBeDefined() + it('propagates source read errors instead of returning partial output', async () => { + await register() + await expect(load(join(root, 'missing.ts'))).rejects.toThrow() }) - it('should resolve devup-ui css path', async () => { - // This import triggers the onResolve callback for CSS files - try { - await import('df/devup-ui/devup-ui.css') - } catch { - // File may not exist, but the resolver still runs - } - expect(true).toBe(true) + it('propagates build errors through the actual callback', async () => { + const path = join(root, 'invalid.ts') + await Bun.write( + path, + "import { css } from '@devup-ui/react'; export const styles = css({ color: window.name })", + ) + await register() + await expect(load(path)).rejects.toThrow(/invalid\.ts:\d+:\d+:/) }) }) diff --git a/packages/next-plugin/src/__tests__/coordinator.test.ts b/packages/next-plugin/src/__tests__/coordinator.test.ts index 72d812adb..403804ae7 100644 --- a/packages/next-plugin/src/__tests__/coordinator.test.ts +++ b/packages/next-plugin/src/__tests__/coordinator.test.ts @@ -1,9 +1,11 @@ import * as fs from 'node:fs' import { request } from 'node:http' +import { tmpdir } from 'node:os' import { join } from 'node:path' import * as wasm from '@devup-ui/wasm' import { + afterAll, afterEach, beforeEach, describe, @@ -29,7 +31,9 @@ let exportFileMapSpy: ReturnType let writeFileSpy: ReturnType let writeFileSyncSpy: ReturnType -const tmpDir = join(process.cwd(), '.tmp-coordinator-test') +const tmpDir = fs.mkdtempSync(join(tmpdir(), 'devup-ui-coordinator-')) + +afterAll(() => fs.rmSync(tmpDir, { recursive: true, force: true })) function makeOptions( overrides: Partial = {}, @@ -97,7 +101,8 @@ beforeEach(() => { writeFileSyncSpy = spyOn(fs, 'writeFileSync').mockReturnValue(undefined) }) -afterEach(() => { +afterEach(async () => { + await flushCoordinatorWrites() resetCoordinator() codeExtractSpy.mockRestore() codeExtractWithoutSourceMapSpy.mockRestore() diff --git a/packages/next-plugin/src/__tests__/css-loader.test.ts b/packages/next-plugin/src/__tests__/css-loader.test.ts index 1ff6f7187..c29ff55f1 100644 --- a/packages/next-plugin/src/__tests__/css-loader.test.ts +++ b/packages/next-plugin/src/__tests__/css-loader.test.ts @@ -6,6 +6,7 @@ import { afterAll, afterEach, beforeAll, + beforeEach, describe, expect, it, @@ -45,6 +46,9 @@ beforeAll(() => { importFileMapSpy = spyOn(wasm, 'importFileMap').mockReturnValue(undefined) existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(false) readFileSyncSpy = spyOn(fs, 'readFileSync').mockReturnValue('{}') +}) + +beforeEach(() => { setWasmForTesting(wasm) }) diff --git a/packages/next-plugin/src/__tests__/wasm.test.ts b/packages/next-plugin/src/__tests__/wasm.test.ts index 172244aa8..d3cc7068c 100644 --- a/packages/next-plugin/src/__tests__/wasm.test.ts +++ b/packages/next-plugin/src/__tests__/wasm.test.ts @@ -24,12 +24,12 @@ import { loadWasm, loadWebpackPlugin, requireFromPlugin, + resetWasmForTesting, setWasmForTesting, setWebpackPluginForTesting, withModuleResolver, } from '../wasm' -const originalCwd = process.cwd() let tempRoots: string[] = [] beforeAll(() => { @@ -37,9 +37,7 @@ beforeAll(() => { }) afterEach(() => { - process.chdir(originalCwd) - setWasmForTesting(undefined) - setWebpackPluginForTesting(undefined) + resetWasmForTesting() }) afterAll(() => { @@ -73,6 +71,31 @@ describe('WASM loading', () => { expect(loadWasm()).toBe(loaded) }) + it('installs a fresh resolver after clearing the loaded namespace cache', () => { + const root = mkdtempSync(join(tmpdir(), 'devup-ui-next-resolver-')) + tempRoots.push(root) + writeFileSync(join(root, 'tokens.ts'), "export const tone = 'green'") + const engine = loadWasm() + engine.setModuleResolver(() => ({ + path: join(root, 'tokens.ts'), + code: "export const tone = 'blue'", + })) + resetWasmForTesting() + const fresh = loadWasm() + fresh.codeExtract( + join(root, 'view.tsx'), + "import { Box } from '@devup-ui/react'; import { tone } from './tokens'; export const view = ", + '@devup-ui/react', + 'df', + true, + false, + false, + {}, + ) + expect(fresh.getCss(null, false)).toContain('green') + expect(fresh.getCss(null, false)).not.toContain('blue') + }) + it('resolves dependencies from a Bun-style isolated install', () => { const root = mkdtempSync(join(tmpdir(), 'devup-ui-next-isolated-')) tempRoots.push(root) @@ -104,11 +127,9 @@ describe('WASM loading', () => { process.platform === 'win32' ? 'junction' : 'dir', ) - process.chdir(root) - - expect(requireFromPlugin<{ isolated: boolean }>('@devup-ui/wasm')).toEqual({ - isolated: true, - }) + expect( + requireFromPlugin<{ isolated: boolean }>('@devup-ui/wasm', root), + ).toEqual({ isolated: true }) }) it('loads or injects the Webpack plugin without a static dependency', () => { diff --git a/packages/next-plugin/src/wasm.ts b/packages/next-plugin/src/wasm.ts index 38446a31d..634340c2a 100644 --- a/packages/next-plugin/src/wasm.ts +++ b/packages/next-plugin/src/wasm.ts @@ -13,20 +13,20 @@ let loadedWasm: DevupWasm | undefined let webpackPlugin: DevupWebpackPlugin | undefined /** @internal Resolve dependencies from the plugin's physical install location. */ -export function requireFromPlugin(specifier: string): T { +export function requireFromPlugin( + specifier: string, + root = process.cwd(), +): T { const installedPackage = join( - process.cwd(), + root, 'node_modules/@devup-ui/next-plugin/package.json', ) - const workspacePackage = join( - process.cwd(), - 'packages/next-plugin/package.json', - ) + const workspacePackage = join(root, 'packages/next-plugin/package.json') const requireBase = existsSync(installedPackage) ? installedPackage : existsSync(workspacePackage) ? workspacePackage - : join(process.cwd(), 'package.json') + : join(root, 'package.json') return createRequire(realpathSync(requireBase))(specifier) as T } @@ -72,3 +72,11 @@ export function setWebpackPluginForTesting( ): void { webpackPluginForTesting = value } + +/** @internal Clear overrides and cached namespaces between tests. */ +export function resetWasmForTesting(): void { + wasmForTesting = undefined + webpackPluginForTesting = undefined + loadedWasm = undefined + webpackPlugin = undefined +} diff --git a/packages/plugin-utils/src/__tests__/wasm-abi.test.ts b/packages/plugin-utils/src/__tests__/wasm-abi.test.ts index 3c0609caf..d561b0e1b 100644 --- a/packages/plugin-utils/src/__tests__/wasm-abi.test.ts +++ b/packages/plugin-utils/src/__tests__/wasm-abi.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { describe, expect, it, mock } from 'bun:test' // The compiled module as a plugin loads it: the Rust tests cover the crates, not // this boundary (every wrapper is excluded from tarpaulin), so the contract the @@ -33,16 +33,6 @@ function extract(code: string, filename = 'abi.tsx') { return wasm.codeExtract(filename, code, PACKAGE, 'df', true, false, false, {}) } -let saved: ReturnType - -beforeEach(() => { - saved = saveState() -}) - -afterEach(() => { - restoreState(saved) -}) - describe('@devup-ui/wasm exports', () => { it('exposes exactly the functions the plugins call', () => { // default is the CommonJS module object a Node-style import adds @@ -71,6 +61,7 @@ describe('@devup-ui/wasm exports', () => { 'isDebug', 'registerShorthands', 'registerTheme', + 'resetStateForTesting', 'setAtomHoist', 'setDebug', 'setModuleResolver', @@ -182,6 +173,57 @@ describe('@devup-ui/wasm theme', () => { }) describe('@devup-ui/wasm state', () => { + it('resets every compiler-state group through the real WASM ABI', () => { + const source = `import { Box } from '${PACKAGE}'; export const view = ` + wasm.resetStateForTesting() + const emptyCss = wasm.getCss(null, false) + const baseline = extract(source, 'fresh.tsx') + const baselineCode = baseline.code + const baselineCss = wasm.getCss(null, false) + const resolver = mock(() => undefined) + wasm.setPrefix('dirty-') + wasm.setDebug(true) + wasm.setAtomHoist(2) + wasm.importCanonicalMap({ 'fresh.tsx': 'previous.tsx' }) + wasm.importFileRoutes({ 'previous.tsx': [1, 2] }) + wasm.registerShorthands({ customInset: ['left', 'right'] }) + wasm.registerTheme({ + colors: { default: { stale: '#abcdef' } }, + typography: { + staleHeading: [{ fontSize: '12px' }, { fontSize: '18px' }], + }, + length: { default: { gutter: ['5px', '9px'] } }, + shadow: { default: { staleShadow: ['0 1px 2px red', '0 2px 4px blue'] } }, + }) + wasm.setModuleResolver(resolver) + extract( + `import { Box } from '${PACKAGE}'; export const dirty = `, + 'dirty.tsx', + ) + + wasm.resetStateForTesting() + + expect(wasm.getPrefix()).toBeUndefined() + expect(wasm.isDebug()).toBe(false) + expect(wasm.getDefaultTheme()).toBeUndefined() + expect(JSON.parse(wasm.exportClassMap())).toEqual({}) + expect(JSON.parse(wasm.exportFileMap())).toEqual({}) + expect(JSON.parse(wasm.exportCanonicalMap())).toEqual({}) + expect(wasm.getCss(null, false)).toBe(emptyCss) + resolver.mockClear() + const fresh = extract(source, 'fresh.tsx') + expect(fresh.code).toBe(baselineCode) + expect(wasm.getCss(null, false)).toBe(baselineCss) + expect(resolver).not.toHaveBeenCalled() + }) + + it('starts each test from the preload baseline after previous mutations', () => { + expect(wasm.getPrefix()).toBeUndefined() + expect(wasm.isDebug()).toBe(true) + expect(JSON.parse(wasm.exportSheet()).css).toEqual({}) + expect(JSON.parse(wasm.exportCanonicalMap())).toEqual({}) + }) + it('puts the process back as a test found it', () => { const before = saveState() wasm.setDebug(!before.debug) From d29f6f3ccbaad6051fb7a3663186df152a26bd92 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 8 Oct 2026 01:14:20 +0900 Subject: [PATCH 2/6] test: add public-API production consumer inputs for every bundler Refs #683 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- e2e/release/fixtures/Fixture.jsx | 33 ++++++++++++++++++++ e2e/release/fixtures/base-theme.json | 6 ++++ e2e/release/fixtures/build-webpack.cjs | 41 +++++++++++++++++++++++++ e2e/release/fixtures/devup.json | 1 + e2e/release/fixtures/index.html | 5 +++ e2e/release/fixtures/jsx-loader.cjs | 12 ++++++++ e2e/release/fixtures/layout.jsx | 3 ++ e2e/release/fixtures/main.jsx | 5 +++ e2e/release/fixtures/next.config.mjs | 11 +++++++ e2e/release/fixtures/page.jsx | 5 +++ e2e/release/fixtures/rsbuild.config.mjs | 12 ++++++++ e2e/release/fixtures/styles.mjs | 3 ++ e2e/release/fixtures/vite.config.mjs | 10 ++++++ e2e/release/fixtures/webpack.config.cjs | 25 +++++++++++++++ 14 files changed, 172 insertions(+) create mode 100644 e2e/release/fixtures/Fixture.jsx create mode 100644 e2e/release/fixtures/base-theme.json create mode 100644 e2e/release/fixtures/build-webpack.cjs create mode 100644 e2e/release/fixtures/devup.json create mode 100644 e2e/release/fixtures/index.html create mode 100644 e2e/release/fixtures/jsx-loader.cjs create mode 100644 e2e/release/fixtures/layout.jsx create mode 100644 e2e/release/fixtures/main.jsx create mode 100644 e2e/release/fixtures/next.config.mjs create mode 100644 e2e/release/fixtures/page.jsx create mode 100644 e2e/release/fixtures/rsbuild.config.mjs create mode 100644 e2e/release/fixtures/styles.mjs create mode 100644 e2e/release/fixtures/vite.config.mjs create mode 100644 e2e/release/fixtures/webpack.config.cjs diff --git a/e2e/release/fixtures/Fixture.jsx b/e2e/release/fixtures/Fixture.jsx new file mode 100644 index 000000000..6249b9a32 --- /dev/null +++ b/e2e/release/fixtures/Fixture.jsx @@ -0,0 +1,33 @@ +'use client' + +import { Box, Flex, globalCss } from '@devup-ui/react' +import { useState } from 'react' + +import { badge } from './styles.mjs' + +globalCss({ body: { margin: 0 } }) + +export function Fixture() { + const [color, setColor] = useState('#123456') + return ( + + + Static extraction + + + Extended theme + + + Responsive extraction + + + Hover extraction + + + Dynamic CSS variable + + +
Imported module
+
+ ) +} diff --git a/e2e/release/fixtures/base-theme.json b/e2e/release/fixtures/base-theme.json new file mode 100644 index 000000000..cc494bd2f --- /dev/null +++ b/e2e/release/fixtures/base-theme.json @@ -0,0 +1,6 @@ +{ + "theme": { + "colors": { "default": { "primary": "#17395b" } }, + "length": { "default": { "gutter": "11px" } } + } +} diff --git a/e2e/release/fixtures/build-webpack.cjs b/e2e/release/fixtures/build-webpack.cjs new file mode 100644 index 000000000..d4c6fe864 --- /dev/null +++ b/e2e/release/fixtures/build-webpack.cjs @@ -0,0 +1,41 @@ +const { writeFile } = require('node:fs/promises') +const { join } = require('node:path') +const { webpack } = require('next/dist/compiled/webpack/webpack') + +async function build() { + const compiler = webpack(require('./webpack.config.cjs')) + try { + const stats = await new Promise((resolve, reject) => + compiler.run((error, result) => + error ? reject(error) : resolve(result), + ), + ) + if (stats.hasErrors()) + throw new Error(stats.toString({ colors: false, errors: true })) + const assets = stats.toJson({ all: false, assets: true }).assets + const styles = assets + .filter(({ name }) => name.endsWith('.css')) + .map(({ name }) => ``) + .join('') + const scripts = assets + .filter(({ name }) => name.endsWith('.js')) + .map(({ name }) => ``) + .join('') + await writeFile( + join(__dirname, 'dist/index.html'), + `Release compatibility${styles}
${scripts}`, + ) + console.info( + `Standalone Webpack ${webpack.version}: production build passed`, + ) + } finally { + await new Promise((resolve, reject) => + compiler.close((error) => (error ? reject(error) : resolve())), + ) + } +} + +void build().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/e2e/release/fixtures/devup.json b/e2e/release/fixtures/devup.json new file mode 100644 index 000000000..1e807ba67 --- /dev/null +++ b/e2e/release/fixtures/devup.json @@ -0,0 +1 @@ +{ "extends": ["./base-theme.json"] } diff --git a/e2e/release/fixtures/index.html b/e2e/release/fixtures/index.html new file mode 100644 index 000000000..b6ddd8f7c --- /dev/null +++ b/e2e/release/fixtures/index.html @@ -0,0 +1,5 @@ + + + Release compatibility +
+ diff --git a/e2e/release/fixtures/jsx-loader.cjs b/e2e/release/fixtures/jsx-loader.cjs new file mode 100644 index 000000000..a17fed9b2 --- /dev/null +++ b/e2e/release/fixtures/jsx-loader.cjs @@ -0,0 +1,12 @@ +const ts = require('@typescript/typescript6') + +module.exports = function transformJsx(source) { + return ts.transpileModule(source, { + compilerOptions: { + jsx: ts.JsxEmit.ReactJSX, + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + }, + fileName: this.resourcePath, + }).outputText +} diff --git a/e2e/release/fixtures/layout.jsx b/e2e/release/fixtures/layout.jsx new file mode 100644 index 000000000..993cd4899 --- /dev/null +++ b/e2e/release/fixtures/layout.jsx @@ -0,0 +1,3 @@ +export default function Layout({ children }) { + return {children} +} diff --git a/e2e/release/fixtures/main.jsx b/e2e/release/fixtures/main.jsx new file mode 100644 index 000000000..78ab0d34c --- /dev/null +++ b/e2e/release/fixtures/main.jsx @@ -0,0 +1,5 @@ +import { createRoot } from 'react-dom/client' + +import { Fixture } from './Fixture' + +createRoot(document.getElementById('root')).render() diff --git a/e2e/release/fixtures/next.config.mjs b/e2e/release/fixtures/next.config.mjs new file mode 100644 index 000000000..239af8c33 --- /dev/null +++ b/e2e/release/fixtures/next.config.mjs @@ -0,0 +1,11 @@ +import { DevupUI } from '@devup-ui/next-plugin' + +export default DevupUI( + { + output: 'export', + experimental: { useTypeScriptCli: true }, + }, + { + singleCss: process.env.RELEASE_SINGLE_CSS === '1', + }, +) diff --git a/e2e/release/fixtures/page.jsx b/e2e/release/fixtures/page.jsx new file mode 100644 index 000000000..c31ef91b4 --- /dev/null +++ b/e2e/release/fixtures/page.jsx @@ -0,0 +1,5 @@ +import { Fixture } from '../src/Fixture' + +export default function Page() { + return +} diff --git a/e2e/release/fixtures/rsbuild.config.mjs b/e2e/release/fixtures/rsbuild.config.mjs new file mode 100644 index 000000000..6627345c3 --- /dev/null +++ b/e2e/release/fixtures/rsbuild.config.mjs @@ -0,0 +1,12 @@ +import { DevupUI } from '@devup-ui/rsbuild-plugin' +import { defineConfig } from '@rsbuild/core' +import { pluginReact } from '@rsbuild/plugin-react' + +export default defineConfig({ + plugins: [ + pluginReact(), + DevupUI({ singleCss: process.env.RELEASE_SINGLE_CSS === '1' }), + ], + source: { entry: { index: './src/main.jsx' } }, + html: { title: 'Release compatibility' }, +}) diff --git a/e2e/release/fixtures/styles.mjs b/e2e/release/fixtures/styles.mjs new file mode 100644 index 000000000..6543735ba --- /dev/null +++ b/e2e/release/fixtures/styles.mjs @@ -0,0 +1,3 @@ +import { css } from '@devup-ui/react' + +export const badge = css({ color: '#13579b', padding: '9px' }) diff --git a/e2e/release/fixtures/vite.config.mjs b/e2e/release/fixtures/vite.config.mjs new file mode 100644 index 000000000..48c1a5d88 --- /dev/null +++ b/e2e/release/fixtures/vite.config.mjs @@ -0,0 +1,10 @@ +import { DevupUI } from '@devup-ui/vite-plugin' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [ + react(), + DevupUI({ singleCss: process.env.RELEASE_SINGLE_CSS === '1' }), + ], +}) diff --git a/e2e/release/fixtures/webpack.config.cjs b/e2e/release/fixtures/webpack.config.cjs new file mode 100644 index 000000000..4e21d853c --- /dev/null +++ b/e2e/release/fixtures/webpack.config.cjs @@ -0,0 +1,25 @@ +const { DevupUIWebpackPlugin } = require('@devup-ui/webpack-plugin') +const { join } = require('node:path') + +module.exports = { + mode: 'production', + entry: './src/main.jsx', + resolve: { extensions: ['.jsx', '...'] }, + output: { + path: join(__dirname, 'dist'), + filename: '[name].[contenthash].js', + }, + experiments: { css: true }, + optimization: { minimize: false }, + module: { + rules: [ + { test: /\.jsx$/, use: [join(__dirname, 'jsx-loader.cjs')] }, + { test: /\.css$/, type: 'css' }, + ], + }, + plugins: [ + new DevupUIWebpackPlugin({ + singleCss: process.env.RELEASE_SINGLE_CSS === '1', + }), + ], +} From f27b55a1b140c90eb286574ade0a09c52a84c34c Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 8 Oct 2026 01:27:44 +0900 Subject: [PATCH 3/6] test: guard process trees and check browser-applied production styles Refs #683 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- e2e/release/check-styles.mjs | 51 ++++++++++ e2e/release/drive-browser.mjs | 25 +++++ e2e/release/guarded-process.mjs | 167 ++++++++++++++++++++++++++++++++ e2e/release/static-server.mjs | 57 +++++++++++ 4 files changed, 300 insertions(+) create mode 100644 e2e/release/check-styles.mjs create mode 100644 e2e/release/drive-browser.mjs create mode 100644 e2e/release/guarded-process.mjs create mode 100644 e2e/release/static-server.mjs diff --git a/e2e/release/check-styles.mjs b/e2e/release/check-styles.mjs new file mode 100644 index 000000000..318b45b38 --- /dev/null +++ b/e2e/release/check-styles.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict' + +export async function checkStyles(page, url) { + const edited = process.env.RELEASE_REVISION === 'edited' + const errors = [] + page.on('pageerror', (error) => errors.push(error.message)) + const response = await page.goto(url) + assert.equal(response.status(), 200) + await page.getByTestId('static').waitFor() + const style = (id, property) => + page + .getByTestId(id) + .evaluate( + (element, propertyName) => + getComputedStyle(element).getPropertyValue(propertyName), + property, + ) + assert.equal(await style('layout', 'display'), 'flex') + assert.equal(await style('layout', 'flex-direction'), 'column') + assert.equal(await style('layout', 'gap'), '12px') + assert.equal( + await style('static', 'background-color'), + edited ? 'rgb(172, 104, 36)' : 'rgb(36, 104, 172)', + ) + assert.equal(await style('static', 'padding-top'), '16px') + assert.equal(await style('static', 'border-radius'), edited ? '0px' : '7px') + assert.equal( + await style('theme', 'color'), + edited ? 'rgb(91, 57, 23)' : 'rgb(23, 57, 91)', + ) + assert.equal(await style('theme', 'padding-left'), '11px') + assert.equal(await style('imported', 'color'), 'rgb(19, 87, 155)') + assert.equal(await style('imported', 'padding-top'), edited ? '8px' : '9px') + await page.setViewportSize({ width: 320, height: 720 }) + assert.equal(await style('responsive', 'width'), '100px') + await page.setViewportSize({ width: 1440, height: 900 }) + assert.equal(await style('responsive', 'width'), '200px') + assert.equal(await style('hover', 'background-color'), 'rgb(17, 34, 51)') + await page.getByTestId('hover').hover() + assert.equal(await style('hover', 'background-color'), 'rgb(171, 205, 239)') + assert.equal(await style('dynamic', 'background-color'), 'rgb(18, 52, 86)') + await page.getByRole('button', { name: 'Change value' }).click() + await page.waitForFunction(() => { + const element = document.querySelector('[data-testid="dynamic"]') + return ( + element && + getComputedStyle(element).backgroundColor === 'rgb(101, 67, 33)' + ) + }) + assert.deepEqual(errors, []) +} diff --git a/e2e/release/drive-browser.mjs b/e2e/release/drive-browser.mjs new file mode 100644 index 000000000..59408cf13 --- /dev/null +++ b/e2e/release/drive-browser.mjs @@ -0,0 +1,25 @@ +import { chromium, firefox, webkit } from '@playwright/test' + +import { checkStyles } from './check-styles.mjs' +import { startStaticServer } from './static-server.mjs' + +const engines = { chromium, firefox, webkit } +const server = await startStaticServer(process.argv[2]) +try { + for (const name of ( + process.env.RELEASE_BROWSERS ?? 'chromium,firefox,webkit' + ).split(',')) { + const engine = engines[name] + if (!engine) throw new Error(`Unknown browser ${name}`) + const browser = await engine.launch({ headless: true }) + try { + const page = await browser.newPage() + await checkStyles(page, server.url) + console.info(`${name}: computed styles and runtime execution passed`) + } finally { + await browser.close() + } + } +} finally { + await server.close() +} diff --git a/e2e/release/guarded-process.mjs b/e2e/release/guarded-process.mjs new file mode 100644 index 000000000..9c4163f66 --- /dev/null +++ b/e2e/release/guarded-process.mjs @@ -0,0 +1,167 @@ +import { execFile, spawn } from 'node:child_process' +import { once } from 'node:events' +import { readFile } from 'node:fs/promises' +import { promisify } from 'node:util' + +const execute = promisify(execFile) +const memoryLimit = 6 * 1024 ** 3 + +async function processSnapshot() { + if (process.platform === 'win32') { + const { stdout } = await execute('powershell.exe', [ + '-NoProfile', + '-Command', + 'Get-CimInstance Win32_Process | ForEach-Object { @{ pid = [int]$_.ProcessId; parent = [int]$_.ParentProcessId; bytes = [double]$_.PrivatePageCount; born = $_.CreationDate.ToString("o") } } | ConvertTo-Json -Compress', + ]) + return JSON.parse(stdout) + } + const { stdout } = await execute('ps', ['-eo', 'pid=,ppid=']) + return stdout + .trim() + .split('\n') + .map((line) => { + const [pid, parent] = line.trim().split(/\s+/).map(Number) + return { pid, parent } + }) +} + +async function privateBytes(row) { + if (process.platform === 'win32') return row.bytes + try { + const contents = await readFile(`/proc/${row.pid}/smaps_rollup`, 'utf8') + return [ + ...contents.matchAll(/^Private_(?:Clean|Dirty|Hugetlb):\s+(\d+) kB$/gm), + ].reduce((total, match) => total + Number(match[1]) * 1024, 0) + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') + return 0 + throw error + } +} + +async function stopTree(pids) { + const current = await processSnapshot() + for (const [pid, born] of [...pids].reverse()) { + if (process.platform === 'win32') { + if (!born || current.find((row) => row.pid === pid)?.born !== born) + continue + try { + await execute('taskkill.exe', ['/PID', String(pid), '/T', '/F']) + } catch (error) { + if ( + !(error instanceof Error) || + !('code' in error) || + ![128, 255].includes(error.code) + ) + throw error + } + } else { + try { + process.kill(pid, 'SIGKILL') + } catch (error) { + if ( + !(error instanceof Error) || + !('code' in error) || + error.code !== 'ESRCH' + ) + throw error + } + } + } +} + +export async function runGuarded(command, args, options = {}) { + const child = spawn(command, args, { + cwd: options.cwd, + env: { ...process.env, CARGO_BUILD_JOBS: '2', ...options.env }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + const completion = once(child, 'close') + child.stdout.on('data', (data) => { + process.stdout.write(data) + options.onOutput?.(data.toString()) + }) + child.stderr.on('data', (data) => { + process.stderr.write(data) + options.onOutput?.(data.toString()) + }) + const owned = new Map([[child.pid, undefined]]) + const started = performance.now() + let peakBytes = 0 + let stopReason + let polling = false + let watchdogError + const poll = async () => { + if (polling) return + polling = true + try { + const rows = await processSnapshot() + if (child.exitCode === null && child.signalCode === null) { + owned.set(child.pid, rows.find((row) => row.pid === child.pid)?.born) + } + let added + do { + added = false + for (const row of rows) { + const parentBorn = owned.get(row.parent) + const parentOwned = + owned.has(row.parent) && + (process.platform !== 'win32' || + (parentBorn !== undefined && + rows.find((parent) => parent.pid === row.parent)?.born === + parentBorn)) + if (parentOwned && !owned.has(row.pid)) { + owned.set(row.pid, row.born) + added = true + } + } + } while (added) + const bytes = ( + await Promise.all( + rows.filter((row) => owned.has(row.pid)).map(privateBytes), + ) + ).reduce((total, value) => total + value, 0) + peakBytes = Math.max(peakBytes, bytes) + if (bytes > (options.memoryLimit ?? memoryLimit)) + stopReason = `private memory exceeded ${options.memoryLimit ?? memoryLimit} bytes` + if (performance.now() - started > (options.timeoutMs ?? 600_000)) + stopReason = 'command timeout' + if (options.signal?.aborted) stopReason = 'command cancelled' + if (stopReason) await stopTree(owned) + } catch (error) { + watchdogError = error + await stopTree(owned) + } finally { + polling = false + } + } + let pendingPoll = poll() + const timer = setInterval(() => { + if (!polling) pendingPoll = poll() + }, 1000) + let code + let signal + try { + await pendingPoll + ;[code, signal] = await completion + } finally { + clearInterval(timer) + await pendingPoll + await stopTree(owned) + } + const result = { + command, + args, + code, + signal, + peakBytes, + durationMs: performance.now() - started, + stopReason, + } + options.onResult?.(result) + if (watchdogError) throw watchdogError + if (stopReason) + throw new Error(`FINDING: watchdog stopped ${command}: ${stopReason}`) + if (code !== 0) throw new Error(`${command} exited ${code ?? signal}`) + return result +} diff --git a/e2e/release/static-server.mjs b/e2e/release/static-server.mjs new file mode 100644 index 000000000..5b243e8fa --- /dev/null +++ b/e2e/release/static-server.mjs @@ -0,0 +1,57 @@ +import { once } from 'node:events' +import { readFile, stat } from 'node:fs/promises' +import { createServer } from 'node:http' +import { extname, isAbsolute, join, relative, resolve } from 'node:path' + +const types = { + '.html': 'text/html', + '.js': 'application/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.txt': 'text/plain', +} +export async function startStaticServer(outputRoot) { + const root = resolve(outputRoot) + const server = createServer(async (request, response) => { + const path = resolve( + root, + `.${new URL(request.url, 'http://localhost').pathname}`, + ) + if ( + relative(root, path).startsWith('..') || + isAbsolute(relative(root, path)) + ) { + response.writeHead(403).end() + return + } + try { + const file = (await stat(path)).isDirectory() + ? join(path, 'index.html') + : path + response.writeHead(200, { + 'Content-Type': types[extname(file)] ?? 'application/octet-stream', + }) + response.end(await readFile(file)) + } catch (error) { + if ( + !(error instanceof Error) || + !('code' in error) || + error.code !== 'ENOENT' + ) + throw error + response.writeHead(404).end() + } + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + if (!address || typeof address === 'string') + throw new Error('Expected TCP address') + return { + url: `http://127.0.0.1:${address.port}`, + close: () => + new Promise((resolveClose, reject) => + server.close((error) => (error ? reject(error) : resolveClose())), + ), + } +} From d5889cb42b749d84864b60b0a311ec2c70d44933 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 8 Oct 2026 01:41:07 +0900 Subject: [PATCH 4/6] test: build packed release consumers and report compatibility findings Refs #683 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .gitignore | 1 + e2e/release/README.md | 57 +++++++++++ e2e/release/consumer.mjs | 101 ++++++++++++++++++++ e2e/release/run.mjs | 200 +++++++++++++++++++++++++++++++++++++++ package.json | 1 + 5 files changed, 360 insertions(+) create mode 100644 e2e/release/README.md create mode 100644 e2e/release/consumer.mjs create mode 100644 e2e/release/run.mjs diff --git a/.gitignore b/.gitignore index 0eb1f2217..20daa41ee 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ playwright-report /.changepacks/publish.tgz benchmark-run.json wasm-size.json +release-results.json diff --git a/e2e/release/README.md b/e2e/release/README.md new file mode 100644 index 000000000..fdfbf7fc0 --- /dev/null +++ b/e2e/release/README.md @@ -0,0 +1,57 @@ +# Release compatibility gate + +`bun run test:release` packs the built Devup UI packages, installs those tarballs +into isolated consumer projects, builds production artifacts, and drives their +actual output in Chromium, Firefox and WebKit. It never imports plugin internals +or asserts generated class names, stylesheet names, or chunk layouts. + +Build the repository packages first with `bun run build`. The gate reuses the +tool versions already installed by the repository lockfile, rather than adding +another version policy for the fixtures. Each consumer starts without generated +compiler state or build output and exercises both per-file and single CSS modes. + +The standalone Webpack consumer calls the public Webpack compiler API directly, +not Next's build pipeline, and uses the CJS Devup UI plugin entry. Its Webpack +engine is the version vendored in the already-installed Next package (5.98.0 on +the approved base); JSX uses the repository's existing TypeScript 6 compiler. +This avoids adding an external dependency. The vendored-engine result is not a +claim about an independently installed or different Webpack version. +Next's vendored engine references a standalone default-minifier module that its +package does not ship. This consumer uses the public production configuration +`optimization.minimize: false`; it verifies compilation and CSS delivery, not +minifier compatibility. The missing default-minifier module is retained as a +toolchain finding rather than attributed to Devup UI. + +The browser checks layout primitives, static spacing/color, an imported styling +module, responsive rules, hover selectors, extended theme tokens, and a dynamic +value delivered through the compiler's CSS variable path. A browser error or +missing style fails the gate even if the bundler exited successfully. + +Each consumer also rebuilds after same-length style and extended-theme edits and +removing a style prop, then rechecks the browser output. The Vite consumer includes +an unsafe runtime style input that must fail its production build with a source +location. These are production rebuild checks, not a claim of dev-server HMR +coverage. + +All builds and browser runs are sequential. A foreground watchdog samples the +private memory of the complete command process tree every second, stops that +tree above 6 GiB or after ten minutes, and treats a stop as a finding. Servers use +ephemeral ports and close in `finally`; remaining owned child processes are +terminated before the command returns. CI applies a 30-minute job timeout too. + +`release-results.json` records failures, command output, duration, and peak +observed private memory. CI uploads it even on a failed fixture. A known plugin +bug remains a failing gate until the owning PR fixes it; do not add fixture +workarounds, expected failures, or skipped assertions. + +For a local focused run, set `RELEASE_TARGETS` to a comma-separated target list +and `RELEASE_BROWSERS` to a comma-separated browser list. CI leaves both unset +and always runs the full matrix. `RELEASE_FIXTURE_ROOT` selects the parent of the +run-unique temporary directory; supervised workers must set their private +worker directory. Failed consumer directories are retained for investigation; +successful runs remove only their own temporary root. + +The Bun 100% unit metric covers handwritten package source, including the Bun +registration hooks. Generated `pkg`/`dist` code is not counted by that metric: +the real WASM ABI suite, packed consumers, and browser results are separate +behavioral evidence, not a claim of 100% generated-WASM coverage. diff --git a/e2e/release/consumer.mjs b/e2e/release/consumer.mjs new file mode 100644 index 000000000..bfbf07dbf --- /dev/null +++ b/e2e/release/consumer.mjs @@ -0,0 +1,101 @@ +import { existsSync, readFileSync } from 'node:fs' +import { cp, mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const repository = fileURLToPath(new URL('../../', import.meta.url)) +const templates = fileURLToPath(new URL('./fixtures/', import.meta.url)) + +function installedVersion(name, context) { + const require = createRequire(join(repository, context, 'package.json')) + let directory = dirname(require.resolve(name)) + for (;;) { + const manifestPath = join(directory, 'package.json') + if (existsSync(manifestPath)) { + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + if (typeof manifest.version === 'string') return manifest.version + } + const parent = dirname(directory) + if (parent === directory) + throw new Error(`Cannot locate installed manifest for ${name}`) + directory = parent + } +} + +export async function prepareConsumer(name, artifacts, run) { + const directory = await mkdtemp(join(artifacts.root, `${name}-`)) + await mkdir(join(directory, 'src'), { recursive: true }) + for (const filename of ['Fixture.jsx', 'styles.mjs', 'main.jsx']) + await cp(join(templates, filename), join(directory, 'src', filename)) + for (const filename of ['devup.json', 'base-theme.json', 'index.html']) + await cp(join(templates, filename), join(directory, filename)) + const isNext = name.startsWith('next-') + const plugin = isNext ? 'next-plugin' : `${name}-plugin` + const dependencies = { + react: installedVersion('react', 'apps/vite'), + 'react-dom': installedVersion('react-dom', 'apps/vite'), + '@devup-ui/react': artifacts.tarballs.react, + [`@devup-ui/${plugin}`]: artifacts.tarballs[plugin], + } + if (name === 'vite') { + dependencies.vite = installedVersion('vite', 'apps/vite') + dependencies['@vitejs/plugin-react'] = installedVersion( + '@vitejs/plugin-react', + 'apps/vite', + ) + } + if (name === 'rsbuild') { + dependencies['@rsbuild/core'] = installedVersion( + '@rsbuild/core', + 'apps/rsbuild', + ) + dependencies['@rsbuild/plugin-react'] = installedVersion( + '@rsbuild/plugin-react', + 'apps/rsbuild', + ) + } + if (isNext || name === 'webpack') + dependencies.next = installedVersion('next', 'apps/next') + if (isNext) { + await mkdir(join(directory, 'app')) + for (const filename of ['page.jsx', 'layout.jsx']) + await cp(join(templates, filename), join(directory, 'app', filename)) + } + if (name === 'webpack') { + dependencies['@typescript/typescript6'] = installedVersion( + '@typescript/typescript6', + '.', + ) + dependencies.typescript = installedVersion('typescript', '.') + for (const filename of ['build-webpack.cjs', 'jsx-loader.cjs']) + await cp(join(templates, filename), join(directory, filename)) + } + await writeFile( + join(directory, 'package.json'), + JSON.stringify( + { + name: `release-${name}`, + private: true, + type: 'module', + dependencies, + overrides: Object.fromEntries( + Object.entries(artifacts.tarballs).map(([item, path]) => [ + `@devup-ui/${item}`, + path, + ]), + ), + }, + null, + 2, + ), + ) + const config = isNext + ? 'next.config.mjs' + : name === 'webpack' + ? 'webpack.config.cjs' + : `${name}.config.mjs` + await cp(join(templates, config), join(directory, config)) + await run(process.env.BUN_BINARY ?? 'bun', ['install'], { cwd: directory }) + return directory +} diff --git a/e2e/release/run.mjs b/e2e/release/run.mjs new file mode 100644 index 000000000..f5ea6c51c --- /dev/null +++ b/e2e/release/run.mjs @@ -0,0 +1,200 @@ +import assert from 'node:assert/strict' +import { + mkdir, + mkdtemp, + readdir, + readFile, + rm, + writeFile, +} from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { prepareConsumer } from './consumer.mjs' +import { runGuarded } from './guarded-process.mjs' + +const repository = fileURLToPath(new URL('../../', import.meta.url)) +const root = await mkdtemp( + join( + process.env.RELEASE_FIXTURE_ROOT ?? process.env.RUNNER_TEMP ?? tmpdir(), + 'release-compat-', + ), +) +const measurements = [] +const findings = [] +const bun = process.env.BUN_BINARY ?? 'bun' +const packages = [ + 'wasm', + 'plugin-utils', + 'react', + 'vite-plugin', + 'rsbuild-plugin', + 'webpack-plugin', + 'next-plugin', + 'bun-plugin', +] +const tarballs = {} + +const run = (command, args, options) => { + let output = '' + return runGuarded(command, args, { + ...options, + onOutput: (chunk) => { + output = (output + chunk).slice(-8192) + }, + onResult: (result) => measurements.push({ ...result, output }), + }) +} + +async function buildAndCheck(name, directory, singleCss) { + const env = { + RELEASE_SINGLE_CSS: String(Number(singleCss)), + NEXT_TELEMETRY_DISABLED: '1', + } + const require = createRequire(join(directory, 'package.json')) + let args + switch (name) { + case 'webpack': + args = [join(directory, 'build-webpack.cjs')] + break + case 'vite': + args = [ + join(dirname(require.resolve('vite/package.json')), 'bin/vite.js'), + 'build', + ] + break + case 'rsbuild': + args = [ + join( + dirname(require.resolve('@rsbuild/core/package.json')), + 'bin/rsbuild.js', + ), + 'build', + ] + break + case 'next-webpack': + args = [require.resolve('next/dist/bin/next'), 'build', '--webpack'] + break + case 'next-turbopack': + args = [require.resolve('next/dist/bin/next'), 'build', '--turbopack'] + break + default: + throw new Error(`Unknown fixture ${name}`) + } + for (const revision of ['initial', 'edited']) { + if (revision === 'edited') { + const source = join(directory, 'src/Fixture.jsx') + await writeFile( + source, + (await readFile(source, 'utf8')) + .replace('#2468ac', '#ac6824') + .replace('borderRadius="7px"', ''), + ) + const theme = join(directory, 'base-theme.json') + await writeFile( + theme, + (await readFile(theme, 'utf8')).replace('#17395b', '#5b3917'), + ) + const imported = join(directory, 'src/styles.mjs') + await writeFile( + imported, + (await readFile(imported, 'utf8')).replace('9px', '8px'), + ) + } + await run(process.execPath, args, { cwd: directory, env }) + const output = join(directory, name.startsWith('next-') ? 'out' : 'dist') + await run( + process.execPath, + [fileURLToPath(new URL('./drive-browser.mjs', import.meta.url)), output], + { cwd: repository, env: { RELEASE_REVISION: revision } }, + ) + } + if (name === 'vite' && singleCss) { + await writeFile( + join(directory, 'src/styles.mjs'), + "import { css } from '@devup-ui/react'; export const badge = css({ color: window.name })", + ) + let rejected = false + try { + await run(process.execPath, args, { cwd: directory, env }) + } catch (error) { + if (!(error instanceof Error)) throw error + const measurement = measurements.at(-1) + assert.equal(measurement.code, 1) + assert.match( + measurement.output, + /styles\.mjs:\d+:\d+:.*cannot use.*at build time/, + ) + measurement.acceptedBuildError = true + rejected = true + } + assert.equal( + rejected, + true, + 'unsafe runtime style must fail the production build', + ) + } + console.info(`${name} singleCss=${singleCss}: production artifact passed`) +} + +try { + for (const name of packages) { + const source = join( + repository, + name === 'wasm' ? 'bindings/devup-ui-wasm' : `packages/${name}`, + ) + const destination = join(root, 'packs', name) + await mkdir(destination, { recursive: true }) + await run(bun, ['pm', 'pack', '--quiet', '--destination', destination], { + cwd: source, + }) + const files = await readdir(destination) + assert.equal(files.length, 1) + tarballs[name] = `file:${join(destination, files[0]).replaceAll('\\', '/')}` + } + const targets = ( + process.env.RELEASE_TARGETS ?? + 'vite,rsbuild,webpack,next-webpack,next-turbopack' + ).split(',') + for (const name of targets) { + for (const singleCss of [false, true]) { + try { + const directory = await prepareConsumer(name, { root, tarballs }, run) + for (const generated of ['df', 'dist', '.next', 'out']) + await rm(join(directory, generated), { recursive: true, force: true }) + await buildAndCheck(name, directory, singleCss) + } catch (error) { + if (!(error instanceof Error)) throw error + findings.push({ fixture: name, singleCss, message: error.message }) + console.error( + `FINDING ${name} singleCss=${singleCss}: ${error.message}`, + ) + } + } + } +} catch (error) { + if (!(error instanceof Error)) throw error + findings.push({ phase: 'preparation', message: error.message }) +} finally { + const summary = { + commandCount: measurements.length, + durationMs: measurements.reduce( + (total, result) => total + result.durationMs, + 0, + ), + peakBytes: Math.max(0, ...measurements.map((result) => result.peakBytes)), + findingCount: findings.length, + } + await writeFile( + join(repository, 'release-results.json'), + JSON.stringify({ root, summary, findings, measurements }, null, 2), + ) + console.info('Release compatibility summary:', summary) +} +if (findings.length) + throw new Error( + `${findings.length} release compatibility finding(s); see release-results.json`, + ) +await rm(root, { recursive: true, force: true }) diff --git a/package.json b/package.json index 795d7a498..bbb9867ed 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "test:e2e": "bunx playwright test", "test:e2e:next": "LANDING_BUILD_MODE=next bunx playwright test", "test:e2e:rsc-css": "bunx playwright test -c playwright.rsc-css.config.ts", + "test:release": "node e2e/release/run.mjs", "test:e2e:ui": "bunx playwright test --ui", "test:e2e:update": "bunx playwright test --update-snapshots", "build": "bun run --filter @devup-ui/wasm --filter @devup-ui/plugin-utils build && bun run --filter @devup-ui/react --filter @devup-ui/webpack-plugin build && bun run --filter @devup-ui/eslint-plugin --filter @devup-ui/vite-plugin --filter @devup-ui/next-plugin --filter @devup-ui/rsbuild-plugin --filter @devup-ui/bun-plugin --filter @devup-ui/components --filter @devup-ui/reset-css build", From 51f141b61c18d0b996bce27cab6877fd2966e880 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 8 Oct 2026 02:01:40 +0900 Subject: [PATCH 5/6] ci: gate release compatibility with cached timed production fixtures Refs #683 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .github/workflows/publish.yml | 60 +++++++++++++++- eslint.config.mjs | 131 +++++++++++++++++----------------- 2 files changed, 125 insertions(+), 66 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6d3289bae..868674010 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -303,6 +303,64 @@ jobs: - name: Run E2E Tests (singleCss) run: bun run test:e2e:rsc-css + release-compat: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + env: + CARGO_BUILD_JOBS: "2" + PLAYWRIGHT_VERSION: "1.63.0" + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2 + with: + toolchain: ${{ env.RUST_VERSION }} + components: clippy, rustfmt + - name: Cache cargo registry + target + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: cargo-release-compat-ubuntu-24.04-rust${{ env.RUST_VERSION }}-wasm-pack${{ env.WASM_PACK_VERSION }}-${{ hashFiles('**/Cargo.lock') }} + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: ${{ env.BUN_VERSION }} + - name: Cache bun dependencies + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ~/.bun/install/cache + key: bun-release-compat-ubuntu-24.04-bun${{ env.BUN_VERSION }}-node${{ env.NODE_VERSION }}-${{ hashFiles('**/bun.lock') }} + - uses: jetli/wasm-pack-action@0d096b08b4e5a7de8c28de67e11e945404e9eefa # v0.4.0 + with: + version: ${{ env.WASM_PACK_VERSION }} + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: ${{ env.NODE_VERSION }} + - run: bun install + - name: Build packages under the memory watchdog + run: | + node --input-type=module -e "import { runGuarded } from './e2e/release/guarded-process.mjs'; await runGuarded('bun', ['run', 'build'], { timeoutMs: 1200000 });" + - name: Cache Playwright browsers + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ~/.cache/ms-playwright + key: playwright-release-compat-ubuntu-24.04-node${{ env.NODE_VERSION }}-playwright${{ env.PLAYWRIGHT_VERSION }}-${{ hashFiles('**/bun.lock') }} + - name: Install Playwright browsers + run: bunx playwright install chromium firefox webkit --with-deps + - name: Build packed consumers and check production computed styles + run: bun run test:release + - name: Upload release compatibility evidence + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: release-compat-results + path: release-results.json + if-no-files-found: error + retention-days: 30 + # Validation: runs on pull requests and on pushes to main, read-only. The job # id stays `publish` because it is the status check the repository requires; # the privileged release steps live in `release` and `deploy-pages` below. @@ -520,7 +578,7 @@ jobs: # passed, and is the only job that can write contents, open pull requests or # mint an OIDC token for npm. release: - needs: [publish, landing-next-e2e, react-18] + needs: [publish, landing-next-e2e, react-18, release-compat] if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-24.04 timeout-minutes: 45 diff --git a/eslint.config.mjs b/eslint.config.mjs index 1e22b6085..9e1b1b206 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,65 +1,66 @@ -import devupUi from '@devup-ui/eslint-plugin' -import devup from 'eslint-plugin-devup' -import eslintPlugin from 'eslint-plugin-eslint-plugin' -import jsonc from 'eslint-plugin-jsonc' -import globals from 'globals' - -export default [ - { - ignores: [ - 'benchmark/next-panda-css/styled-system', - 'bindings/devup-ui-wasm/pkg', - 'test-results', - ], - }, - // eslint-plugin-devup - ...devup.configs.recommended.filter( - (config) => !('plugins' in config && '@devup-ui' in config.plugins), - ), - // eslint-plugin-jsonc - ...jsonc.configs['flat/recommended-with-json'], - ...jsonc.configs['flat/recommended-with-jsonc'], - // globals (node, browser, builtin) - { - files: ['**/*.{js,mjs,cjs}'], - languageOptions: { - globals: { - ...globals.browser, - ...globals.node, - ...globals.builtin, - }, - }, - rules: { - // js require import allowed - '@typescript-eslint/no-require-imports': 'off', - }, - }, - // benchmark no console rules - { - files: ['benchmark.js'], - rules: { - 'no-console': [ - 'error', - { - allow: ['info', 'debug', 'warn', 'error', 'profile', 'profileEnd'], - }, - ], - }, - }, - // create-style-context.mjs no children prop - { - files: ['**/*.mjs'], - rules: { - '@eslint-react/jsx-no-children-prop': 'off', - }, - }, - // eslint-plugin rule - { - ...eslintPlugin.configs.recommended, - files: ['packages/eslint-plugin/**/*.{js,jsx,ts,tsx}'], - }, - { - ignores: ['**/*.md'], - }, - ...devupUi.configs.recommended, -] +import devupUi from '@devup-ui/eslint-plugin' +import devup from 'eslint-plugin-devup' +import eslintPlugin from 'eslint-plugin-eslint-plugin' +import jsonc from 'eslint-plugin-jsonc' +import globals from 'globals' + +export default [ + { + ignores: [ + 'benchmark/next-panda-css/styled-system', + 'bindings/devup-ui-wasm/pkg', + 'test-results', + 'release-results.json', + ], + }, + // eslint-plugin-devup + ...devup.configs.recommended.filter( + (config) => !('plugins' in config && '@devup-ui' in config.plugins), + ), + // eslint-plugin-jsonc + ...jsonc.configs['flat/recommended-with-json'], + ...jsonc.configs['flat/recommended-with-jsonc'], + // globals (node, browser, builtin) + { + files: ['**/*.{js,mjs,cjs}'], + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + ...globals.builtin, + }, + }, + rules: { + // js require import allowed + '@typescript-eslint/no-require-imports': 'off', + }, + }, + // benchmark no console rules + { + files: ['benchmark.js'], + rules: { + 'no-console': [ + 'error', + { + allow: ['info', 'debug', 'warn', 'error', 'profile', 'profileEnd'], + }, + ], + }, + }, + // create-style-context.mjs no children prop + { + files: ['**/*.mjs'], + rules: { + '@eslint-react/jsx-no-children-prop': 'off', + }, + }, + // eslint-plugin rule + { + ...eslintPlugin.configs.recommended, + files: ['packages/eslint-plugin/**/*.{js,jsx,ts,tsx}'], + }, + { + ignores: ['**/*.md'], + }, + ...devupUi.configs.recommended, +] From f01f04cb75b8c2835f2442d5ad6d0a303a5db3e6 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 8 Oct 2026 03:01:08 +0900 Subject: [PATCH 6/6] fix(test): handle exited Linux processes and retain early build evidence Refs #683 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .github/workflows/publish.yml | 9 ++++++--- .gitignore | 1 + e2e/release/README.md | 6 ++++++ e2e/release/build-packages.mjs | 21 +++++++++++++++++++++ e2e/release/guarded-process.mjs | 14 +++----------- e2e/release/process-memory.mjs | 18 ++++++++++++++++++ e2e/release/process-memory.test.mjs | 29 +++++++++++++++++++++++++++++ eslint.config.mjs | 1 + 8 files changed, 85 insertions(+), 14 deletions(-) create mode 100644 e2e/release/build-packages.mjs create mode 100644 e2e/release/process-memory.mjs create mode 100644 e2e/release/process-memory.test.mjs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 868674010..a795754ee 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -340,9 +340,10 @@ jobs: with: node-version: ${{ env.NODE_VERSION }} - run: bun install + - name: Test exited-process memory sampling + run: node --test e2e/release/process-memory.test.mjs - name: Build packages under the memory watchdog - run: | - node --input-type=module -e "import { runGuarded } from './e2e/release/guarded-process.mjs'; await runGuarded('bun', ['run', 'build'], { timeoutMs: 1200000 });" + run: node e2e/release/build-packages.mjs - name: Cache Playwright browsers uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: @@ -357,7 +358,9 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: release-compat-results - path: release-results.json + path: | + release-package-build.json + release-results.json if-no-files-found: error retention-days: 30 diff --git a/.gitignore b/.gitignore index 20daa41ee..a267616ff 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ playwright-report benchmark-run.json wasm-size.json release-results.json +release-package-build.json diff --git a/e2e/release/README.md b/e2e/release/README.md index fdfbf7fc0..cbb1809ae 100644 --- a/e2e/release/README.md +++ b/e2e/release/README.md @@ -44,6 +44,12 @@ observed private memory. CI uploads it even on a failed fixture. A known plugin bug remains a failing gate until the owning PR fixes it; do not add fixture workarounds, expected failures, or skipped assertions. +The separately guarded package build writes `release-package-build.json` even +when it fails before consumer setup. CI first runs the process-memory tests: +an exited Linux process (`ENOENT` or `ESRCH` during a proc read) contributes zero, +while permission failures remain errors rather than silently undercounting a +live process. The 6 GiB cap is unchanged. + For a local focused run, set `RELEASE_TARGETS` to a comma-separated target list and `RELEASE_BROWSERS` to a comma-separated browser list. CI leaves both unset and always runs the full matrix. `RELEASE_FIXTURE_ROOT` selects the parent of the diff --git a/e2e/release/build-packages.mjs b/e2e/release/build-packages.mjs new file mode 100644 index 000000000..1dce84130 --- /dev/null +++ b/e2e/release/build-packages.mjs @@ -0,0 +1,21 @@ +import { writeFile } from 'node:fs/promises' + +import { runGuarded } from './guarded-process.mjs' + +const measurements = [] +const findings = [] +try { + await runGuarded(process.env.BUN_BINARY ?? 'bun', ['run', 'build'], { + timeoutMs: 1_200_000, + onResult: (result) => measurements.push(result), + }) +} catch (error) { + if (!(error instanceof Error)) throw error + findings.push({ phase: 'package-build', message: error.message }) + throw error +} finally { + await writeFile( + 'release-package-build.json', + JSON.stringify({ findings, measurements }, null, 2), + ) +} diff --git a/e2e/release/guarded-process.mjs b/e2e/release/guarded-process.mjs index 9c4163f66..883e15b15 100644 --- a/e2e/release/guarded-process.mjs +++ b/e2e/release/guarded-process.mjs @@ -1,8 +1,9 @@ import { execFile, spawn } from 'node:child_process' import { once } from 'node:events' -import { readFile } from 'node:fs/promises' import { promisify } from 'node:util' +import { readPrivateBytes } from './process-memory.mjs' + const execute = promisify(execFile) const memoryLimit = 6 * 1024 ** 3 @@ -27,16 +28,7 @@ async function processSnapshot() { async function privateBytes(row) { if (process.platform === 'win32') return row.bytes - try { - const contents = await readFile(`/proc/${row.pid}/smaps_rollup`, 'utf8') - return [ - ...contents.matchAll(/^Private_(?:Clean|Dirty|Hugetlb):\s+(\d+) kB$/gm), - ].reduce((total, match) => total + Number(match[1]) * 1024, 0) - } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') - return 0 - throw error - } + return readPrivateBytes(row.pid) } async function stopTree(pids) { diff --git a/e2e/release/process-memory.mjs b/e2e/release/process-memory.mjs new file mode 100644 index 000000000..b7194c9b6 --- /dev/null +++ b/e2e/release/process-memory.mjs @@ -0,0 +1,18 @@ +import { readFile } from 'node:fs/promises' + +export async function readPrivateBytes(pid, read = readFile) { + try { + const contents = await read(`/proc/${pid}/smaps_rollup`, 'utf8') + return [ + ...contents.matchAll(/^Private_(?:Clean|Dirty|Hugetlb):\s+(\d+) kB$/gm), + ].reduce((total, match) => total + Number(match[1]) * 1024, 0) + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + (error.code === 'ENOENT' || error.code === 'ESRCH') + ) + return 0 + throw error + } +} diff --git a/e2e/release/process-memory.test.mjs b/e2e/release/process-memory.test.mjs new file mode 100644 index 000000000..e37680caa --- /dev/null +++ b/e2e/release/process-memory.test.mjs @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { readPrivateBytes } from './process-memory.mjs' + +test('counts private memory and excludes shared pages when a proc read succeeds', async () => { + const reader = async () => + 'Private_Clean: 2 kB\nPrivate_Dirty: 3 kB\nPrivate_Hugetlb: 4 kB\nShared_Clean: 20 kB\n' + assert.equal(await readPrivateBytes(42, reader), 9 * 1024) +}) + +for (const code of ['ENOENT', 'ESRCH']) { + test(`counts an exited process as zero when a proc read reports ${code}`, async () => { + const reader = async () => { + throw Object.assign(new Error('process exited'), { code }) + } + assert.equal(await readPrivateBytes(42, reader), 0) + }) +} + +test('propagates permission failures instead of undercounting a live process', async () => { + const error = Object.assign(new Error('permission denied'), { + code: 'EACCES', + }) + const reader = async () => { + throw error + } + await assert.rejects(readPrivateBytes(42, reader), error) +}) diff --git a/eslint.config.mjs b/eslint.config.mjs index 9e1b1b206..5ce753ef3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -11,6 +11,7 @@ export default [ 'bindings/devup-ui-wasm/pkg', 'test-results', 'release-results.json', + 'release-package-build.json', ], }, // eslint-plugin-devup