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/.github/workflows/publish.yml b/.github/workflows/publish.yml index f2caf6817..5a478fa08 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -426,6 +426,66 @@ jobs: - name: Run E2E Tests (singleCss) run: bun run test:e2e:rsc-css + release-compat: + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + CARGO_BUILD_JOBS: "2" + steps: + - name: Checkout + uses: actions/checkout@v7 + - uses: actions-rust-lang/setup-rust-toolchain@v2 + with: + toolchain: stable + components: clippy, rustfmt + - name: Cache cargo registry + target + uses: actions/cache@v6 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: cargo-release-compat-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Cache bun dependencies + uses: actions/cache@v6 + with: + path: ~/.bun/install/cache + key: bun-release-compat-${{ runner.os }}-${{ hashFiles('**/bun.lock') }} + - uses: jetli/wasm-pack-action@v0.4.0 + with: + version: latest + - uses: actions/setup-node@v7 + with: + node-version: latest + - 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 e2e/release/build-packages.mjs + - name: Cache Playwright browsers + uses: actions/cache@v6 + with: + path: ~/.cache/ms-playwright + key: playwright-release-compat-${{ runner.os }}-${{ 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@v7 + with: + name: release-compat-results + path: | + release-package-build.json + release-results.json + if-no-files-found: error + retention-days: 30 + webpack-extends-theme: runs-on: ubuntu-latest timeout-minutes: 45 @@ -697,7 +757,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-latest timeout-minutes: 45 diff --git a/.gitignore b/.gitignore index 0eb1f2217..a267616ff 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,5 @@ playwright-report /.changepacks/publish.tgz benchmark-run.json wasm-size.json +release-results.json +release-package-build.json diff --git a/bindings/devup-ui-wasm/README.md b/bindings/devup-ui-wasm/README.md index 1a0976d80..1032dec5d 100644 --- a/bindings/devup-ui-wasm/README.md +++ b/bindings/devup-ui-wasm/README.md @@ -41,13 +41,30 @@ 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, reservation phase/miss count, frozen atom plan, shorthands, the complete + theme-token and Tailwind definition registries, 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. + +`resetBuildState()` remains the separate production build boundary. It clears +allocator bookkeeping, maps/routes, atom threshold/plan, prefix, sheet and resolver, +but leaves CSS theme-key configuration, shorthands and debug for the next build's +configuration path to overwrite. It is not an alias for the full testing reset. + +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/atom_hoist_measurement.rs b/bindings/devup-ui-wasm/src/atom_hoist_measurement.rs index 3fd5f4c1b..972a90b51 100644 --- a/bindings/devup-ui-wasm/src/atom_hoist_measurement.rs +++ b/bindings/devup-ui-wasm/src/atom_hoist_measurement.rs @@ -113,6 +113,7 @@ struct Measurement { } fn build(modules: &[Module], hoist: bool) -> Result { + let _scope = super::TestStateGuard::new(); reset_build_state_internal(); let _state = BuildState; register_theme_internal(sheet::theme::Theme::default()); diff --git a/bindings/devup-ui-wasm/src/atom_tests.rs b/bindings/devup-ui-wasm/src/atom_tests.rs index 93255cc8b..1fa448abb 100644 --- a/bindings/devup-ui-wasm/src/atom_tests.rs +++ b/bindings/devup-ui-wasm/src/atom_tests.rs @@ -93,6 +93,7 @@ export const view = ;"#; @@ -205,6 +208,7 @@ fn same_atom_stays_local_when_a_private_bucket_also_uses_it() { #[test] #[serial] fn compat_apis_reference_emitted_atoms_when_bucket_is_predeclared() { + let _scope = TestStateGuard::new(); // Given setup(); let sources = [ @@ -234,6 +238,7 @@ fn compat_apis_reference_emitted_atoms_when_bucket_is_predeclared() { #[test] #[serial] fn local_ordered_styles_signal_shared_layer_order_changes() { + let _scope = TestStateGuard::new(); // Given setup(); let plain = compile( diff --git a/bindings/devup-ui-wasm/src/atom_tests/lifecycle.rs b/bindings/devup-ui-wasm/src/atom_tests/lifecycle.rs index 7a00544f3..42a577b10 100644 --- a/bindings/devup-ui-wasm/src/atom_tests/lifecycle.rs +++ b/bindings/devup-ui-wasm/src/atom_tests/lifecycle.rs @@ -5,6 +5,7 @@ const RED: &str = "import {Box} from '@devup-ui/react'; export const x=;"; @@ -166,6 +170,7 @@ fn important_and_plain_dynamic_values_emit_distinct_rules_in_every_mode() { #[test] #[serial] fn keyframe_content_and_scope_resolve_when_using_single_css() { + let _scope = TestStateGuard::new(); // Given let source = r"import {Box,keyframes} from '@devup-ui/react'; const a=keyframes({from:{opacity:0,transform:'scale(0)'},to:{opacity:1}}); @@ -223,6 +228,7 @@ fn keyframe_content_and_scope_resolve_when_using_single_css() { #[test] #[serial] fn global_css_keyframes_use_the_same_scope_as_their_declaration() { + let _scope = TestStateGuard::new(); // Given setup(); let source = r"import {Box,keyframes,globalCss} from '@devup-ui/react'; diff --git a/bindings/devup-ui-wasm/src/atom_tests/resumption.rs b/bindings/devup-ui-wasm/src/atom_tests/resumption.rs index 9b32f2704..79b843936 100644 --- a/bindings/devup-ui-wasm/src/atom_tests/resumption.rs +++ b/bindings/devup-ui-wasm/src/atom_tests/resumption.rs @@ -3,6 +3,7 @@ use super::*; #[test] #[serial] fn full_state_restore_preserves_all_symbol_slots_and_appends_new_identities() { + let _scope = TestStateGuard::new(); // Given setup(); reserve(&[("a.tsx", SOURCE_A)]); diff --git a/bindings/devup-ui-wasm/src/atom_tests/single_css.rs b/bindings/devup-ui-wasm/src/atom_tests/single_css.rs index 7c758acc9..84bd048df 100644 --- a/bindings/devup-ui-wasm/src/atom_tests/single_css.rs +++ b/bindings/devup-ui-wasm/src/atom_tests/single_css.rs @@ -42,6 +42,7 @@ fn build(atom: bool, debug: bool) -> (String, String, String) { #[test] #[serial] fn w66_single_css_output_is_exactly_invariant_to_requested_atom_mode() { + let _scope = TestStateGuard::new(); // Given / When / Then for debug in [false, true] { assert_eq!(build(false, debug), build(true, debug)); @@ -54,6 +55,7 @@ fn w66_single_css_output_is_exactly_invariant_to_requested_atom_mode() { #[test] #[serial] fn w66_single_css_does_not_freeze_empty_reach_before_per_file_reservation() { + let _scope = TestStateGuard::new(); // Given setup(); css::file_routes::reset_file_routes(); @@ -88,6 +90,7 @@ fn w66_single_css_does_not_freeze_empty_reach_before_per_file_reservation() { #[test] #[serial] fn single_css_error_and_resolver_unwind_restore_effective_atom_mode() { + let _scope = TestStateGuard::new(); // Given setup(); let option = ExtractOption { @@ -124,6 +127,7 @@ fn single_css_error_and_resolver_unwind_restore_effective_atom_mode() { #[test] #[serial] fn canonical_global_placement_keeps_atom_mode_in_a_per_file_build() { + let _scope = TestStateGuard::new(); // Given setup(); import_canonical_map_internal(HashMap::from([("global.tsx".into(), "@global".into())])); diff --git a/bindings/devup-ui-wasm/src/cache_protocol_tests.rs b/bindings/devup-ui-wasm/src/cache_protocol_tests.rs index 990dfe2a1..b3545e669 100644 --- a/bindings/devup-ui-wasm/src/cache_protocol_tests.rs +++ b/bindings/devup-ui-wasm/src/cache_protocol_tests.rs @@ -12,6 +12,7 @@ fn legacy_import_changes_cache_generation_when_exported( #[case] with_styles: bool, #[case] atom_mode: bool, ) { + let _scope = TestStateGuard::new(); // Given reset_build_state_internal(); let mut legacy = StyleSheet::default(); @@ -47,6 +48,7 @@ fn legacy_import_changes_cache_generation_when_exported( #[test] #[serial] fn exported_generation_roundtrips_frozen_atom_placement() { + let _scope = TestStateGuard::new(); // Given reset_build_state_internal(); css::atom_hoist::set_atom_hoist(Some(2)); diff --git a/bindings/devup-ui-wasm/src/css_output_edge_tests.rs b/bindings/devup-ui-wasm/src/css_output_edge_tests.rs index 4d6676352..625a21918 100644 --- a/bindings/devup-ui-wasm/src/css_output_edge_tests.rs +++ b/bindings/devup-ui-wasm/src/css_output_edge_tests.rs @@ -62,6 +62,7 @@ fn numeric_edges_emit_css( #[case] property: &str, #[case] value: &str, ) -> Result<(), String> { + let _scope = super::TestStateGuard::new(); let source = format!("import {{ Box }} from '@devup-ui/react';\nconst e = ;"); let output = compile(&source)?; @@ -73,6 +74,7 @@ fn numeric_edges_emit_css( #[test] #[serial] fn flat_responsive_values_emit_css_after_base() -> Result<(), String> { + let _scope = super::TestStateGuard::new(); let source = "import { Box } from '@devup-ui/react';\nconst e = ;"; let output = compile(source)?; let css = output.css().ok_or("expected emitted CSS")?; @@ -88,6 +90,7 @@ fn flat_responsive_values_emit_css_after_base() -> Result<(), String> { #[test] #[serial] fn selector_escape_hatches_emit_scoped_css() -> Result<(), String> { + let _scope = super::TestStateGuard::new(); let source = "import { Box } from '@devup-ui/react';\nconst e = p': { color: 'blue' } }} />;"; let output = compile(source)?; let css = output.css().ok_or("expected emitted CSS")?; @@ -102,6 +105,7 @@ fn selector_escape_hatches_emit_scoped_css() -> Result<(), String> { #[test] #[serial] fn supported_experimental_and_unknown_properties_still_emit_css() -> Result<(), String> { + let _scope = super::TestStateGuard::new(); let source = "import { Box } from '@devup-ui/react';\nconst e = ;"; let output = compile(source)?; let css = output.css().ok_or("expected emitted CSS")?; @@ -129,6 +133,7 @@ fn supported_experimental_and_unknown_properties_still_emit_css() -> Result<(), )] #[serial] fn dead_properties_fail_before_css_emission(#[case] source: &str) -> Result<(), String> { + let _scope = super::TestStateGuard::new(); let error = compile(source) .err() .ok_or("expected a declaration error")?; @@ -140,6 +145,7 @@ fn dead_properties_fail_before_css_emission(#[case] source: &str) -> Result<(), #[test] #[serial] fn nested_responsive_arrays_fail_before_css_emission() -> Result<(), String> { + let _scope = super::TestStateGuard::new(); let source = "import { Box } from '@devup-ui/react';\nconst e = ;"; let error = compile(source) .err() @@ -156,6 +162,7 @@ fn nested_responsive_arrays_fail_before_css_emission() -> Result<(), String> { fn escaped_string_css_dead_names_fail_before_emission( #[case] statement: &str, ) -> Result<(), String> { + let _scope = super::TestStateGuard::new(); let source = format!("import {{ css }} from '@devup-ui/react';\n{statement}"); let error = compile(&source) .err() @@ -168,6 +175,7 @@ fn escaped_string_css_dead_names_fail_before_emission( #[test] #[serial] fn configured_shorthand_names_keep_their_valid_targets() -> Result<(), String> { + let _scope = super::TestStateGuard::new(); css::set_custom_shorthands(BTreeMap::from([( "boxAlign".to_string(), vec!["align-items".to_string()], @@ -199,6 +207,7 @@ fn evaluated_declarations_report_authored_calls( #[case] source: &str, #[case] line: usize, ) -> Result<(), String> { + let _scope = super::TestStateGuard::new(); let error = compile_file("css-edges.css.ts", source) .err() .ok_or("expected a declaration error")?; @@ -227,6 +236,7 @@ fn same_line_import_aliases_keep_authored_columns( #[case] source: &str, #[case] token: &str, ) -> Result<(), String> { + let _scope = super::TestStateGuard::new(); let column = source.find(token).ok_or("fixture has no expected token")? + 1; let error = compile_file("css-edges.css.ts", source) .err() @@ -241,6 +251,7 @@ fn same_line_import_aliases_keep_authored_columns( #[test] #[serial] fn computed_css_text_reports_its_authored_call() -> Result<(), String> { + let _scope = super::TestStateGuard::new(); let source = "import { css } from '@devup-ui/react';\nconst c = css('box-' + 'align:center');"; let error = compile(source) .err() @@ -253,6 +264,7 @@ fn computed_css_text_reports_its_authored_call() -> Result<(), String> { #[test] #[serial] fn stylex_dead_declarations_fail_before_css_emission() -> Result<(), String> { + let _scope = super::TestStateGuard::new(); let statement = "export const c = stylex.create({ x: { boxAlign: 'center' } });"; let source = format!("import * as stylex from '@stylexjs/stylex';\n{statement}"); let column = statement.find("boxAlign").ok_or("fixture lacks property")? + 1; @@ -281,6 +293,7 @@ fn stylesheet_nested_arrays_report_authored_locations( #[case] source: &str, #[case] location: &str, ) -> Result<(), String> { + let _scope = super::TestStateGuard::new(); let error = compile_file("css-edges.css.ts", source) .err() .ok_or("expected a responsive array error")?; diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index bb4e59488..3035834a1 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -20,6 +20,10 @@ 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}; #[cfg(test)] mod css_output_edge_tests; @@ -747,6 +751,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); @@ -761,6 +769,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(); @@ -857,6 +866,7 @@ mod tests { #[test] #[serial] fn emit_split_measurement_artifacts() { + let _state = TestStateGuard::new(); if std::env::var_os("DEVUP_EMIT_MEASURE").is_some() { let artifacts = super::atom_hoist_measurement::build_predeclared_measurement_artifacts() @@ -870,6 +880,7 @@ mod tests { #[test] #[serial] fn atom_b_beats_per_file_on_session_and_invalidation() { + let _state = TestStateGuard::new(); super::atom_hoist_measurement::assert_predeclared_shared_savings().unwrap(); } @@ -879,6 +890,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); @@ -902,6 +914,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()); @@ -919,6 +932,7 @@ mod tests { #[test] #[serial] fn test_code_extract() { + let _state = TestStateGuard::new(); { let mut sheet = GLOBAL_STYLE_SHEET.lock().unwrap(); *sheet = StyleSheet::default(); @@ -947,6 +961,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(); @@ -978,6 +993,7 @@ mod tests { #[test] #[serial] fn deserialize_theme() { + let _state = TestStateGuard::new(); { let theme: Theme = serde_json::from_str( r##"{ @@ -1060,6 +1076,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"); @@ -1170,6 +1187,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!( @@ -1240,6 +1258,7 @@ mod tests { #[test] #[serial] fn test_debug() { + let _state = TestStateGuard::new(); assert!(!is_debug()); set_debug(true); assert!(is_debug()); @@ -1250,6 +1269,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())); @@ -1260,6 +1280,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()); @@ -1289,6 +1310,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(); @@ -1329,6 +1351,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(); @@ -1354,6 +1377,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", @@ -1379,6 +1403,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(); @@ -1415,6 +1440,7 @@ mod tests { #[test] #[serial] fn test_output_with_global_css_removal() { + let _state = TestStateGuard::new(); // Reset global state let mut sheet = StyleSheet::default(); @@ -1456,16 +1482,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) { @@ -1489,6 +1506,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()); @@ -1518,6 +1536,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()); @@ -1542,6 +1561,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()); @@ -1569,6 +1589,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()); @@ -1591,6 +1612,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()); @@ -1633,6 +1655,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(); @@ -1655,6 +1678,7 @@ mod tests { #[test] #[serial] fn test_export_sheet_internal() { + let _state = TestStateGuard::new(); // Reset global state *GLOBAL_STYLE_SHEET.lock().unwrap() = StyleSheet::default(); @@ -1671,6 +1695,7 @@ mod tests { #[test] #[serial] fn test_export_class_map_internal() { + let _state = TestStateGuard::new(); // Reset class map css::class_map::reset_class_map(); @@ -1686,6 +1711,7 @@ mod tests { #[test] #[serial] fn test_export_file_map_internal() { + let _state = TestStateGuard::new(); // Reset file map css::file_map::reset_file_map(); @@ -1755,6 +1781,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(); @@ -1781,6 +1808,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(); @@ -1805,6 +1833,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(); @@ -1830,6 +1859,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/reservation/tests.rs b/bindings/devup-ui-wasm/src/reservation/tests.rs index d22cf015b..a0d7344f2 100644 --- a/bindings/devup-ui-wasm/src/reservation/tests.rs +++ b/bindings/devup-ui-wasm/src/reservation/tests.rs @@ -11,22 +11,15 @@ use serial_test::serial; pub(super) type TestResult = Result<(), Box>; -pub(super) struct Reset; -impl Drop for Reset { - fn drop(&mut self) { - reset_build_state_internal(); - register_theme_internal(sheet::theme::Theme::default()); - css::set_custom_shorthands(BTreeMap::new()); - css::debug::set_debug(false); - } -} +pub(super) type Reset = crate::state::TestStateGuard; pub(super) fn setup(single_css: bool) -> (Reset, ExtractOption) { + let state = Reset::new(); reset_build_state_internal(); css::debug::set_debug(false); register_theme_internal(sheet::theme::Theme::default()); ( - Reset, + state, ExtractOption { single_css, ..ExtractOption::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..69620b84f --- /dev/null +++ b/bindings/devup-ui-wasm/src/state/tests.rs @@ -0,0 +1,121 @@ +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 empty_sheet = export_sheet_internal().unwrap(); + 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(), + empty_sheet + ); + 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/e2e/release/README.md b/e2e/release/README.md new file mode 100644 index 000000000..cbb1809ae --- /dev/null +++ b/e2e/release/README.md @@ -0,0 +1,63 @@ +# 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. + +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 +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/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/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/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/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/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', + }), + ], +} diff --git a/e2e/release/guarded-process-surface.test.mjs b/e2e/release/guarded-process-surface.test.mjs new file mode 100644 index 000000000..7567b9478 --- /dev/null +++ b/e2e/release/guarded-process-surface.test.mjs @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { CommandExitError, runGuarded } from './guarded-process.mjs' +import { processRuntime } from './process-inspection.mjs' + +test('records a real successful command through the process surface', async () => { + const results = [] + const result = await runGuarded( + process.execPath, + ['-e', 'console.log("surface-ok")'], + { + onResult: (value) => results.push(value), + }, + ) + assert.equal(result.code, 0) + assert.deepEqual(result.errors, []) + assert.equal(results.length, 1) +}) + +test('distinguishes real compiler exit one from watchdog failures', async () => { + await assert.rejects( + runGuarded(process.execPath, ['-e', 'process.exitCode=1']), + (error) => + error instanceof CommandExitError && + error.result.code === 1 && + error.result.errors.length === 0, + ) +}) + +test( + 'drains a real root and its observed lingering child when cancelled', + { timeout: 30000 }, + async () => { + const controller = new AbortController() + let descendant + let identity + let output = '' + const results = [] + const runtime = { + ...processRuntime, + snapshot: async () => { + const rows = await processRuntime.snapshot() + identity = rows.find((row) => row.pid === descendant) + if (identity) controller.abort() + return rows + }, + } + await assert.rejects( + runGuarded( + process.execPath, + [ + '-e', + ` + const {spawn}=require('node:child_process'); + const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'}); + console.log(child.pid); + setInterval(()=>{},1000); + `, + ], + { + runtime, + signal: controller.signal, + pollIntervalMs: 20, + timeoutMs: 10000, + onOutput: (chunk) => { + output += chunk + descendant = Number(output.trim()) + }, + onResult: (result) => results.push(result), + }, + ), + (error) => + !(error instanceof CommandExitError) && + /command cancelled/.test(error.message), + ) + assert.ok(identity, 'descendant generation must actually be observed') + assert.equal(results[0].stopReason, 'command cancelled') + assert.deepEqual(results[0].errors, []) + assert.equal(await processRuntime.inspect(descendant), undefined) + }, +) diff --git a/e2e/release/guarded-process.mjs b/e2e/release/guarded-process.mjs new file mode 100644 index 000000000..cac1dfdb2 --- /dev/null +++ b/e2e/release/guarded-process.mjs @@ -0,0 +1,142 @@ +import { once } from 'node:events' + +import { processRuntime } from './process-inspection.mjs' +import { ProcessOwnership } from './process-ownership.mjs' + +export class CommandExitError extends Error { + constructor(result) { + super(`${result.command} exited ${result.code ?? result.signal}`) + this.result = result + } +} + +function bounded(operation, timeoutMs) { + let timer + return Promise.race([ + Promise.resolve().then(operation), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error('Process inspection/cleanup timed out')), + timeoutMs, + ) + }), + ]).finally(() => clearTimeout(timer)) +} + +export async function runGuarded(command, args, options = {}) { + const io = options.runtime ?? processRuntime + const operationTimeoutMs = options.drainTimeoutMs ?? 5000 + const runtime = Object.fromEntries( + ['inspect', 'snapshot', 'privateBytes', 'kill'].map((key) => [ + key, + (...values) => bounded(() => io[key](...values), operationTimeoutMs), + ]), + ) + const child = io.spawn(command, args, { + cwd: options.cwd, + env: { ...process.env, CARGO_BUILD_JOBS: '2', ...options.env }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + // Observe the error/close promise immediately, including spawn failures. + const completion = once(child, 'close').then( + ([code, signal]) => ({ code, signal }), + (error) => ({ error }), + ) + for (const stream of [child.stdout, child.stderr]) { + stream.on('data', (data) => { + ;(stream === child.stdout ? process.stdout : process.stderr).write(data) + options.onOutput?.(data.toString()) + }) + } + const owned = new ProcessOwnership(runtime) + const started = performance.now() + const errors = [] + let peakBytes = 0 + let stopReason + let timer + let pendingPoll = Promise.resolve() + let outcome = {} + let stopped = false + let wake + const failure = new Promise((resolve) => { + wake = resolve + }) + const poll = async () => { + const rows = await runtime.snapshot() + if (stopped) return + await owned.discover(rows) + const bytes = await owned.memory() + peakBytes = Math.max(peakBytes, bytes) + if (bytes > (options.memoryLimit ?? 6 * 1024 ** 3)) + stopReason = `private memory exceeded ${options.memoryLimit ?? 6 * 1024 ** 3} bytes` + if (performance.now() - started > (options.timeoutMs ?? 600_000)) + stopReason = 'command timeout' + if (options.signal?.aborted) stopReason = 'command cancelled' + if (stopReason) wake() + } + const schedule = () => { + // This handler is attached in the SAME turn, never after child completion. + pendingPoll = poll().catch((error) => { + errors.push(error) + wake() + }) + pendingPoll.then(() => { + if (!stopped && !errors.length && !stopReason) + timer = setTimeout(schedule, options.pollIntervalMs ?? 1000) + }) + } + try { + // Establish exactly once, before the first fallible global snapshot. + if (child.pid !== undefined) await owned.establish(child.pid) + schedule() + const completed = await Promise.race([completion, failure]) + if (completed) outcome = completed + if (outcome.error) errors.push(outcome.error) + } catch (error) { + errors.push(error) + } finally { + stopped = true + owned.closed = true + clearTimeout(timer) + try { + await bounded(() => pendingPoll, operationTimeoutMs) + } catch (error) { + errors.push(error) + } finally { + // Known identities do not depend on another global snapshot succeeding. + errors.push(...(await owned.stop(operationTimeoutMs))) + try { + outcome = await bounded(() => completion, operationTimeoutMs) + if (outcome.error && !errors.includes(outcome.error)) + errors.push(outcome.error) + } catch (error) { + errors.push(error) + child.stdout.destroy?.() + child.stderr.destroy?.() + } + } + } + const result = { + command, + args, + code: outcome.code, + signal: outcome.signal, + peakBytes, + durationMs: performance.now() - started, + stopReason, + errors: errors.map((error) => + error instanceof Error ? error.message : String(error), + ), + } + try { + options.onResult?.(result) + } catch (error) { + errors.push(error) + } + if (errors.length) + throw new AggregateError(errors, `Supervisor failed: ${command}`) + if (stopReason) + throw new Error(`FINDING: watchdog stopped ${command}: ${stopReason}`) + if (result.code !== 0) throw new CommandExitError(result) + return result +} diff --git a/e2e/release/guarded-process.test.mjs b/e2e/release/guarded-process.test.mjs new file mode 100644 index 000000000..4d483b8ad --- /dev/null +++ b/e2e/release/guarded-process.test.mjs @@ -0,0 +1,235 @@ +import assert from 'node:assert/strict' +import { EventEmitter } from 'node:events' +import { test } from 'node:test' + +import { CommandExitError, runGuarded } from './guarded-process.mjs' + +function fixture() { + const child = Object.assign(new EventEmitter(), { + pid: 41001, + exitCode: null, + signalCode: null, + stdout: new EventEmitter(), + stderr: new EventEmitter(), + }) + const root = { + pid: child.pid, + parent: 0, + born: '100', + bornOrder: '100', + bytes: 1, + } + let rows = [root] + const killed = [] + const sampled = [] + const results = [] + const close = () => { + if (child.exitCode !== null) return + child.exitCode = 0 + child.emit('close', 0, null) + } + const runtime = { + spawn: () => child, + snapshot: async () => rows, + inspect: async (pid) => rows.find((row) => row.pid === pid), + privateBytes: async (row) => { + sampled.push(row.born) + return row.bytes + }, + kill: async (pid) => { + killed.push(pid) + rows = rows.filter((row) => row.pid !== pid) + if (pid === child.pid) close() + }, + } + return { + child, + root, + runtime, + killed, + sampled, + results, + close, + setRows: (next) => { + rows = next + }, + run: () => + runGuarded('controlled', [], { + runtime, + pollIntervalMs: 5, + drainTimeoutMs: 50, + onResult: (result) => results.push(result), + }), + } +} + +test('does not sample or kill a reused child or adopt a reused parent descendant', async () => { + // Given: one owned child survives while another parent PID is reused. + const f = fixture() + const old = { + pid: 41002, + parent: f.root.pid, + born: '101', + bornOrder: '101', + bytes: 2, + } + const survivor = { + pid: 41003, + parent: f.root.pid, + born: '102', + bornOrder: '102', + bytes: 3, + } + const replacement = { + ...old, + parent: 0, + born: '999', + bornOrder: '999', + bytes: 1000, + } + const outsider = { + pid: 41004, + parent: old.pid, + born: '1000', + bornOrder: '1000', + bytes: 2000, + } + let polls = 0 + f.runtime.snapshot = async () => { + polls += 1 + const rows = + polls === 1 + ? [f.root, old, survivor] + : [f.root, replacement, survivor, outsider] + f.setRows(rows) + if (polls === 2) setImmediate(f.close) + return rows + } + // When: the supervisor polls both generations then cleans up. + await f.run() + // Then: only the surviving owned generations are charged or signalled. + assert.equal(f.results[0].peakBytes, 6) + assert.ok(!f.sampled.includes('999') && !f.sampled.includes('1000')) + assert.ok(!f.killed.includes(old.pid) && !f.killed.includes(outsider.pid)) + assert.ok(f.killed.includes(survivor.pid)) +}) + +test('does not rebind an established root to a replacement generation', async () => { + const f = fixture() + let polls = 0 + f.runtime.snapshot = async () => { + polls += 1 + const rows = + polls === 1 + ? [f.root] + : [{ ...f.root, born: '999', bornOrder: '999', bytes: 1000 }] + f.setRows(rows) + if (polls === 2) setImmediate(f.close) + return rows + } + await f.run() + assert.equal(f.results[0].peakBytes, 1) + assert.deepEqual(f.killed, []) +}) + +for (const phase of ['initial', 'later']) { + test(`cleans known identities and emits evidence when ${phase} snapshots reject`, async () => { + const f = fixture() + const failure = new Error(`${phase} snapshot failure`) + let polls = 0 + f.runtime.snapshot = async () => { + polls += 1 + if (phase === 'later' && polls === 1) return [f.root] + // Bound the old implementation's wait without signalling any real process. + setTimeout(f.close, 30) + throw failure + } + await assert.rejects( + f.run(), + (error) => error === failure || error.errors?.includes(failure), + ) + assert.ok(f.killed.includes(f.root.pid)) + assert.equal(f.results.length, 1) + }) +} + +test('attempts remaining identities and preserves evidence when one kill rejects', async () => { + const f = fixture() + const failure = new Error('individual kill failure') + const descendant = { + pid: 41002, + parent: f.root.pid, + born: '101', + bornOrder: '101', + bytes: 2, + } + f.setRows([f.root, descendant]) + f.runtime.privateBytes = async () => { + setImmediate(f.close) + return 1 + } + const kill = f.runtime.kill + f.runtime.kill = async (pid) => { + if (pid === descendant.pid) throw failure + await kill(pid) + } + await assert.rejects( + f.run(), + (error) => error === failure || error.errors?.includes(failure), + ) + assert.ok(f.killed.includes(f.root.pid)) + assert.equal(f.results.length, 1) +}) + +test('preserves primary and cleanup failures without accepting a compiler exit', async () => { + const f = fixture() + const primary = new Error('snapshot rejected') + const cleanup = new Error('kill rejected') + f.runtime.snapshot = async () => { + throw primary + } + f.runtime.kill = async () => { + setImmediate(f.close) + throw cleanup + } + await assert.rejects( + f.run(), + (error) => + error instanceof AggregateError && + !(error instanceof CommandExitError) && + error.errors.includes(primary) && + error.errors.includes(cleanup), + ) + assert.equal(f.results.length, 1) + assert.ok(f.results[0].errors.includes(primary.message)) + assert.ok(f.results[0].errors.includes(cleanup.message)) +}) + +test('bounds drain failure and still emits result evidence when termination does not drain', async () => { + const f = fixture() + f.runtime.privateBytes = async () => { + setImmediate(f.close) + return 1 + } + f.runtime.kill = async () => {} + await assert.rejects( + f.run(), + (error) => + error instanceof AggregateError && + error.errors.some((cause) => /drain timed out/.test(cause.message)), + ) + assert.equal(f.results.length, 1) +}) + +test('bounds a stuck poll and cleans known identities before surfacing failure', async () => { + const f = fixture() + f.runtime.snapshot = () => new Promise(() => {}) + await assert.rejects( + f.run(), + (error) => + error instanceof AggregateError && + error.errors.some((cause) => /timed out/.test(cause.message)), + ) + assert.deepEqual(f.killed, [f.root.pid]) + assert.equal(f.results.length, 1) +}) diff --git a/e2e/release/process-inspection.mjs b/e2e/release/process-inspection.mjs new file mode 100644 index 000000000..4af78ab5f --- /dev/null +++ b/e2e/release/process-inspection.mjs @@ -0,0 +1,83 @@ +import { execFile, spawn } from 'node:child_process' +import { readdir, readFile } from 'node:fs/promises' +import { promisify } from 'node:util' + +import { readPrivateBytes } from './process-memory.mjs' + +const execute = promisify(execFile) +const windowsFields = + '@{ pid = [int]$_.ProcessId; parent = [int]$_.ParentProcessId; bytes = [double]$_.PrivatePageCount; born = $_.CreationDate.ToString("o"); bornOrder = $_.CreationDate.ToUniversalTime().Ticks.ToString() }' + +export function parseProcStat(stat) { + // comm can itself contain spaces and parentheses; fields start after its LAST ). + const closing = stat.lastIndexOf(')') + const pid = Number(stat.slice(0, stat.indexOf(' '))) + const fields = stat + .slice(closing + 1) + .trim() + .split(/\s+/) + const parent = Number(fields[1]) + const born = fields[19] + if ( + closing < 0 || + !Number.isSafeInteger(pid) || + pid <= 0 || + !Number.isSafeInteger(parent) || + !/^\d+$/.test(born ?? '') + ) + throw new Error('Invalid /proc process identity') + if (fields[0] === 'Z' || fields[0] === 'X') return undefined + return { pid, parent, born, bornOrder: born } +} + +async function windowsSnapshot(filter = '') { + const { stdout } = await execute( + 'powershell.exe', + [ + '-NoProfile', + '-Command', + `Get-CimInstance Win32_Process ${filter} | ForEach-Object { ${windowsFields} } | ConvertTo-Json -Compress`, + ], + { timeout: 5000, maxBuffer: 8 * 1024 ** 2 }, + ) + return stdout.trim() ? [].concat(JSON.parse(stdout)) : [] +} + +async function inspect(pid) { + if (!Number.isSafeInteger(pid) || pid <= 0) + throw new Error('Invalid process PID') + if (process.platform === 'win32') + return (await windowsSnapshot(`-Filter "ProcessId = ${pid}"`))[0] + try { + return parseProcStat(await readFile(`/proc/${pid}/stat`, 'utf8')) + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + ['ENOENT', 'ESRCH'].includes(error.code) + ) + return undefined + throw error + } +} + +async function snapshot() { + if (process.platform === 'win32') return windowsSnapshot() + const pids = (await readdir('/proc')).filter((name) => /^\d+$/.test(name)) + return (await Promise.all(pids.map((pid) => inspect(Number(pid))))).filter( + Boolean, + ) +} + +async function kill(pid) { + process.kill(pid, 'SIGKILL') +} + +export const processRuntime = { + spawn, + inspect, + snapshot, + kill, + privateBytes: (row) => + process.platform === 'win32' ? row.bytes : readPrivateBytes(row.pid), +} 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/e2e/release/process-ownership.mjs b/e2e/release/process-ownership.mjs new file mode 100644 index 000000000..b9770e034 --- /dev/null +++ b/e2e/release/process-ownership.mjs @@ -0,0 +1,137 @@ +import { setTimeout as delay } from 'node:timers/promises' + +export function sameProcess(expected, current) { + return ( + expected !== undefined && + current !== undefined && + expected.pid === current.pid && + expected.born !== undefined && + expected.born === current.born + ) +} + +export class ProcessOwnership { + owned = new Map() + retired = new Set() + closed = false + + constructor(runtime) { + this.runtime = runtime + } + + async establish(pid) { + this.rootPid = pid + const root = await this.runtime.inspect(pid) + if (root) this.owned.set(pid, root) + } + + async validate(row) { + const current = await this.runtime.inspect(row.pid) + if (sameProcess(row, current)) return current + this.owned.delete(row.pid) + this.retired.add(row.pid) + return undefined + } + + async discover(rows) { + for (const row of this.owned.values()) { + if ( + !sameProcess( + row, + rows.find((current) => current.pid === row.pid), + ) + ) { + this.owned.delete(row.pid) + this.retired.add(row.pid) + } + } + let added + do { + added = false + for (const row of rows) { + if (this.closed) return + const parent = this.owned.get(row.parent) + if ( + row.pid === this.rootPid || + this.retired.has(row.pid) || + this.owned.has(row.pid) || + !parent + ) + continue + if (!(await this.validate(parent))) continue + const current = await this.runtime.inspect(row.pid) + if ( + sameProcess(row, current) && + current.parent === parent.pid && + BigInt(current.bornOrder) >= BigInt(parent.bornOrder) && + (await this.validate(parent)) && + !this.closed + ) { + this.owned.set(row.pid, current) + added = true + } + } + } while (added) + } + + async memory() { + let bytes = 0 + for (const row of this.owned.values()) { + if (this.closed) return 0 + const current = await this.validate(row) + if (!current || this.closed) continue + const value = await this.runtime.privateBytes(current) + if ((await this.validate(row)) && !this.closed) bytes += value + } + return bytes + } + + async stop(timeoutMs) { + const errors = [] + for (const row of [...this.owned.values()].reverse()) { + try { + // Generation check is NOT atomic with the subsequent PID signal (no pidfd). + if (!(await this.validate(row))) continue + } catch (error) { + errors.push(error) + continue + } + try { + await this.runtime.kill(row.pid) + } catch (error) { + if ( + error instanceof Error && + error.code === 'ESRCH' && + error.syscall === 'kill' + ) { + try { + if (!(await this.validate(row))) continue + } catch (inspectionError) { + errors.push(inspectionError) + } + } + errors.push(error) + } + } + const deadline = performance.now() + timeoutMs + while (this.owned.size) { + for (const row of [...this.owned.values()]) { + try { + await this.validate(row) + } catch (error) { + errors.push(error) + this.owned.delete(row.pid) + } + } + if (!this.owned.size) break + if (performance.now() >= deadline) { + errors.push( + new Error(`Owned process drain timed out: ${[...this.owned.keys()]}`), + ) + break + } + await delay(20) + } + return errors + } +} diff --git a/e2e/release/process-ownership.test.mjs b/e2e/release/process-ownership.test.mjs new file mode 100644 index 000000000..69299d7f3 --- /dev/null +++ b/e2e/release/process-ownership.test.mjs @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { parseProcStat } from './process-inspection.mjs' +import { ProcessOwnership } from './process-ownership.mjs' + +test('parses Linux start ticks after a command containing spaces and closing parentheses', () => { + const fields = ['S', '11', ...Array(17).fill('0'), '12345678901234', '0'] + assert.deepEqual( + parseProcStat(`42 (a ) tricky (name)) ${fields.join(' ')}`), + { + pid: 42, + parent: 11, + born: '12345678901234', + bornOrder: '12345678901234', + }, + ) + assert.equal( + parseProcStat(`42 (zombie) ${['Z', ...fields.slice(1)].join(' ')}`), + undefined, + ) + assert.throws(() => parseProcStat('42 (bad) S 11')) +}) + +test('discards a memory sample when generation changes during the read', async () => { + const original = { pid: 42, parent: 0, born: '123', bornOrder: '123' } + let current = original + const owner = new ProcessOwnership({ + inspect: async () => current, + privateBytes: async () => { + current = { ...original, born: '456', bornOrder: '456' } + return 999 + }, + }) + await owner.establish(42) + assert.equal(await owner.memory(), 0) + assert.equal(owner.owned.size, 0) +}) + +test('rechecks identity immediately before signalling, independently of global snapshots', async () => { + const original = { pid: 42, parent: 0, born: '123', bornOrder: '123' } + let current = original + const killed = [] + const owner = new ProcessOwnership({ + inspect: async () => current, + kill: async (pid) => killed.push(pid), + }) + await owner.establish(42) + current = { ...original, born: '456', bornOrder: '456' } + assert.deepEqual(await owner.stop(50), []) + assert.deepEqual(killed, []) +}) + +test('does not adopt descendants when a known parent generation disappears', async () => { + const root = { pid: 42, parent: 0, born: '123', bornOrder: '123' } + const old = { pid: 43, parent: 42, born: '124', bornOrder: '124' } + const survivor = { pid: 44, parent: 43, born: '125', bornOrder: '125' } + let rows = [root, old, survivor] + const owner = new ProcessOwnership({ + inspect: async (pid) => rows.find((row) => row.pid === pid), + }) + await owner.establish(42) + await owner.discover(rows) + rows = [ + root, + { ...old, parent: 0, born: '999', bornOrder: '999' }, + survivor, + { pid: 45, parent: 43, born: '1000', bornOrder: '1000' }, + ] + await owner.discover(rows) + assert.deepEqual([...owner.owned.keys()], [42, 44]) +}) + +test('rechecks parent generation after candidate inspection before adopting descendants', async () => { + const root = { pid: 42, parent: 0, born: '123', bornOrder: '123' } + const candidate = { pid: 43, parent: 42, born: '124', bornOrder: '124' } + let parent = root + const owner = new ProcessOwnership({ + inspect: async (pid) => { + if (pid === root.pid) return parent + parent = { ...root, born: '999', bornOrder: '999' } + return candidate + }, + }) + await owner.establish(root.pid) + await owner.discover([root, candidate]) + assert.equal(owner.owned.size, 0) +}) + +for (const ticks of ['9', '90071992547409929']) { + test(`allows same-tick and numerically newer Linux children at tick ${ticks}`, async () => { + // Given: exact ticks straddle a decimal width or exceed Number precision. + const root = { pid: 42, parent: 0, born: ticks, bornOrder: ticks } + const rows = [ + root, + { pid: 43, parent: 42, born: ticks, bornOrder: ticks }, + { + pid: 44, + parent: 42, + born: String(BigInt(ticks) + 1n), + bornOrder: String(BigInt(ticks) + 1n), + }, + { + pid: 45, + parent: 42, + born: String(BigInt(ticks) - 1n), + bornOrder: String(BigInt(ticks) - 1n), + }, + ] + const owner = new ProcessOwnership({ + inspect: async (pid) => rows.find((row) => row.pid === pid), + }) + await owner.establish(root.pid) + // When: ancestry is admitted using the platform's exact creation order. + await owner.discover(rows) + // Then: equality is allowed, numeric newer is allowed, numeric older is not. + assert.deepEqual([...owner.owned.keys()], [42, 43, 44]) + }) +} diff --git a/e2e/release/run.mjs b/e2e/release/run.mjs new file mode 100644 index 000000000..90b6610f9 --- /dev/null +++ b/e2e/release/run.mjs @@ -0,0 +1,201 @@ +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 { CommandExitError, 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 CommandExitError)) throw error + const measurement = measurements.at(-1) + assert.equal(error.result.code, 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/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())), + ), + } +} diff --git a/e2e/release/windows-process.test.mjs b/e2e/release/windows-process.test.mjs new file mode 100644 index 000000000..c76788b19 --- /dev/null +++ b/e2e/release/windows-process.test.mjs @@ -0,0 +1,243 @@ +import assert from 'node:assert/strict' +import { EventEmitter } from 'node:events' +import { test } from 'node:test' + +import { runGuarded } from './guarded-process.mjs' + +function windowsFixture() { + const root = { + pid: 42001, + parent: 0, + born: '2026-10-19T12:00:00.0000002+00:00', + bornOrder: '639280080000000002', + bytes: 1, + } + const child = { + pid: 42002, + parent: root.pid, + born: '2026-10-19T12:00:00.0000003+00:00', + bornOrder: '639280080000000003', + bytes: 2, + } + const childProcess = Object.assign(new EventEmitter(), { + pid: root.pid, + stdout: new EventEmitter(), + stderr: new EventEmitter(), + }) + let rows = [root, child] + const events = [] + const sampled = [] + const results = [] + const runtime = { + spawn: () => childProcess, + snapshot: async () => rows, + inspect: async (pid) => { + events.push(['inspect', pid]) + return rows.find((row) => row.pid === pid) + }, + privateBytes: async (row) => { + sampled.push(row.pid) + if (row.pid === child.pid) + setImmediate(() => childProcess.emit('close', 0, null)) + return row.bytes + }, + kill: async (pid) => { + events.push(['kill', pid]) + rows = rows.filter((row) => row.pid !== pid) + }, + } + return { + root, + child, + runtime, + events, + sampled, + results, + setRows: (next) => { + rows = next + }, + run: () => + runGuarded('windows-controlled', [], { + runtime, + drainTimeoutMs: 50, + onResult: (result) => results.push(result), + }), + } +} + +test('rejects an older stable Windows stale-parent outsider and its descendant', async () => { + // Given: all generations stay stable, but an older outsider retains the root PID. + const f = windowsFixture() + const outsider = { + ...f.child, + pid: 42003, + born: '2026-10-19T12:00:00.0000001+00:00', + bornOrder: '639280080000000001', + bytes: 1000, + } + const descendant = { + ...f.child, + pid: 42004, + parent: outsider.pid, + bytes: 2000, + } + f.setRows([f.root, outsider, descendant, f.child]) + // When: discovery, memory sampling and cleanup run through the supervisor. + const result = await f.run() + // Then: only the root and genuinely newer child are charged and cleaned. + assert.equal(result.peakBytes, 3) + assert.deepEqual(f.sampled, [f.root.pid, f.child.pid]) + assert.deepEqual( + f.events.filter(([event]) => event === 'kill'), + [ + ['kill', f.child.pid], + ['kill', f.root.pid], + ], + ) + assert.deepEqual(result.errors, []) +}) + +for (const outcome of ['absent', 'replacement']) { + test(`drains without error when native kill ESRCH finds the generation ${outcome}`, async () => { + // Given: a matching pre-signal generation and locale-independent native error. + const f = windowsFixture() + const kill = f.runtime.kill + const missing = Object.assign(new Error('임의의 한국어 진단'), { + code: 'ESRCH', + syscall: 'kill', + }) + f.runtime.kill = async (pid) => { + await kill(pid) + if (pid === f.child.pid) { + if (outcome === 'replacement') + f.setRows([f.root, { ...f.child, born: 'replacement' }]) + throw missing + } + } + // When: cleanup encounters that specific missing-process outcome. + const result = await f.run() + // Then: it re-inspects the expected generation and still cleans the root. + const signal = f.events.findIndex(([event]) => event === 'kill') + assert.deepEqual(f.events.slice(signal - 1, signal + 3), [ + ['inspect', f.child.pid], + ['kill', f.child.pid], + ['inspect', f.child.pid], + ['inspect', f.root.pid], + ]) + assert.deepEqual( + f.events.filter(([event]) => event === 'kill'), + [ + ['kill', f.child.pid], + ['kill', f.root.pid], + ], + ) + assert.deepEqual(result.errors, []) + assert.equal(result.code, 0) + }) +} + +for (const [outcome, code, syscall] of [ + ['live', 'ESRCH', 'kill'], + ['permission', 'EPERM', 'kill'], + ['inspection', 'ESRCH', 'kill'], + ['other-native', 'EINVAL', 'kill'], + ['numeric128', 128, 'kill'], + ['numeric255', 255, 'kill'], + ['numeric1', 1, 'kill'], + ['non-kill', 'ESRCH', 'read'], +]) { + test(`preserves native kill failure when the outcome is ${outcome}`, async () => { + // Given: only actual kill ESRCH can trigger immediate generation reinspection. + const f = windowsFixture() + const kill = f.runtime.kill + const inspect = f.runtime.inspect + const inspectionFailure = new Error('reinspection denied') + const failure = Object.assign(new Error('임의의 한국어 진단'), { + code, + syscall, + cmd: `taskkill.exe /PID ${f.child.pid} /F`, + stderr: `ERROR: The process "${f.child.pid}" not found.\r\n`, + }) + let attempted = false + f.runtime.inspect = async (pid) => { + if (attempted && pid === f.child.pid) { + if (outcome === 'inspection') { + f.events.push(['inspection-failure', pid]) + throw inspectionFailure + } + if (outcome === 'live') { + const current = await inspect(pid) + f.setRows([f.root]) + return current + } + } + return inspect(pid) + } + f.runtime.kill = async (pid) => { + if (pid !== f.child.pid) return kill(pid) + f.events.push(['kill', pid]) + attempted = true + if (outcome !== 'live' && outcome !== 'inspection') f.setRows([f.root]) + throw failure + } + // When: cleanup handles the error and continues to other owned processes. + await assert.rejects(f.run(), (error) => { + assert.ok(error instanceof AggregateError) + assert.ok(error.errors.includes(failure)) + if (outcome === 'inspection') + assert.ok(error.errors.includes(inspectionFailure)) + return true + }) + // Then: exact errors survive later absence and remaining root cleanup runs. + if (outcome === 'live' || outcome === 'inspection') { + const signal = f.events.findIndex(([event]) => event === 'kill') + assert.deepEqual(f.events[signal + 1], [ + outcome === 'live' ? 'inspect' : 'inspection-failure', + f.child.pid, + ]) + } + assert.deepEqual( + f.events.filter(([event]) => event === 'kill'), + [ + ['kill', f.child.pid], + ['kill', f.root.pid], + ], + ) + }) +} + +test('retains pre-signal inspection ESRCH without signalling or missing-kill classification', async () => { + // Given: discovery and memory succeed, but the child's pre-signal read fails. + const f = windowsFixture() + const inspect = f.runtime.inspect + const failure = Object.assign(new Error('검사 실패'), { + code: 'ESRCH', + syscall: 'kill', + }) + let childReads = 0 + f.runtime.inspect = async (pid) => { + if (pid === f.child.pid && ++childReads === 4) { + f.events.push(['inspection-failure', pid]) + f.setRows([f.root]) + throw failure + } + return inspect(pid) + } + // When: cleanup validates each identity before attempting its signal. + await assert.rejects(f.run(), (error) => { + assert.ok(error instanceof AggregateError) + assert.deepEqual(error.errors, [failure]) + return true + }) + // Then: no child signal or immediate missing-kill reinspection occurs. + const failed = f.events.findIndex(([event]) => event === 'inspection-failure') + assert.deepEqual(f.events.slice(failed, failed + 3), [ + ['inspection-failure', f.child.pid], + ['inspect', f.root.pid], + ['kill', f.root.pid], + ]) + assert.deepEqual( + f.events.filter(([event]) => event === 'kill'), + [['kill', f.root.pid]], + ) +}) diff --git a/eslint.config.mjs b/eslint.config.mjs index e5314c0b3..83f4b78d7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,72 +1,74 @@ -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, - // the snippets of this page are .css.ts stylesheets, which the build runs as they are - { - files: ['apps/landing/src/app/(detail)/docs/migration/vanilla-extract/**'], - rules: { - '@devup-ui/css-utils-literal-only': 'off', - }, - }, -] +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', + 'release-package-build.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, + // the snippets of this page are .css.ts stylesheets, which the build runs as they are + { + files: ['apps/landing/src/app/(detail)/docs/migration/vanilla-extract/**'], + rules: { + '@devup-ui/css-utils-literal-only': 'off', + }, + }, +] diff --git a/libs/css/src/atom_hoist.rs b/libs/css/src/atom_hoist.rs index ddf039962..940eb6e38 100644 --- a/libs/css/src/atom_hoist.rs +++ b/libs/css/src/atom_hoist.rs @@ -122,6 +122,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 b16a66335..e50b74361 100644 --- a/libs/css/src/file_map.rs +++ b/libs/css/src/file_map.rs @@ -255,6 +255,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()); @@ -268,6 +269,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); @@ -279,6 +281,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"); } @@ -286,6 +289,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); @@ -299,6 +303,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()); @@ -326,6 +331,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 073bb7327..bb16a8154 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])); @@ -159,6 +161,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 328a6646a..5a5bd1737 100644 --- a/libs/css/src/lib.rs +++ b/libs/css/src/lib.rs @@ -17,6 +17,8 @@ mod selector_separator; pub mod slot_reservation; pub mod style_selector; pub mod tailwind_definitions; +#[doc(hidden)] +pub mod test_state; pub mod theme_tokens; pub mod utils; @@ -263,6 +265,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() { @@ -736,6 +743,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); @@ -799,6 +807,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"); @@ -816,6 +825,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), @@ -838,6 +848,7 @@ mod tests { #[test] #[serial] fn test_sheet_to_classname() { + let _state = test_state::TestStateGuard::new(); set_debug(false); reset_class_map(); assert_eq!( @@ -1113,6 +1124,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), @@ -1135,6 +1147,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"), @@ -1158,6 +1171,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) @@ -1249,6 +1263,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()) @@ -1261,6 +1276,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)); @@ -1331,6 +1347,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())); @@ -1349,6 +1366,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())); @@ -1365,6 +1383,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())); @@ -1378,6 +1397,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())); @@ -1391,6 +1411,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(); @@ -1407,6 +1428,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 @@ -1426,6 +1448,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 @@ -1468,6 +1491,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))); @@ -1483,6 +1507,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/slot_reservation.rs b/libs/css/src/slot_reservation.rs index f6a1d0677..f73a8ac8e 100644 --- a/libs/css/src/slot_reservation.rs +++ b/libs/css/src/slot_reservation.rs @@ -14,7 +14,7 @@ enum Phase { } #[derive(Clone, Copy, Default)] -struct State { +pub(crate) struct State { phase: Phase, misses: usize, } @@ -47,6 +47,10 @@ pub fn reset() { with_state(|state| *state = State::default()); } +pub(crate) fn replace_state(replacement: State) -> State { + with_state(|state| std::mem::replace(state, replacement)) +} + pub(crate) struct FileSeedGuard(Phase); impl Default for FileSeedGuard { @@ -120,6 +124,34 @@ mod tests { use super::*; use serial_test::serial; + #[test] + #[serial] + fn complete_phase_and_count_restore_when_nested_or_unwinding() { + let _state = crate::test_state::TestStateGuard::new(); + for phase in [Phase::Legacy, Phase::Scratch, Phase::Live] { + replace_state(State { phase, misses: 7 }); + { + let _inner = crate::test_state::TestStateGuard::new(); + record_insert(); + assert_eq!(unreserved_slot_count(), 0); + } + assert_eq!(unreserved_slot_count(), 7); + let result = std::panic::catch_unwind(|| { + let _inner = crate::test_state::TestStateGuard::new(); + ScratchSlots::default().commit(); + record_insert(); + panic!("restore reservation"); + }); + assert!(result.is_err()); + record_insert(); + let expected = match phase { + Phase::Legacy | Phase::Scratch => 7, + Phase::Live => 8, + }; + assert_eq!(unreserved_slot_count(), expected); + } + } + #[cfg(not(target_arch = "wasm32"))] #[test] fn wasm_state_access_preserves_mutations_and_reset() { diff --git a/libs/css/src/tailwind_definitions.rs b/libs/css/src/tailwind_definitions.rs index b52e26726..bbd1b607f 100644 --- a/libs/css/src/tailwind_definitions.rs +++ b/libs/css/src/tailwind_definitions.rs @@ -26,8 +26,8 @@ pub enum UtilityItem { Apply(Vec), } -#[derive(Debug, Default)] -struct Definitions { +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct Definitions { theme: BTreeMap, /// Namespaces `--ns-*: initial` empties, such as `--color-` resets: BTreeSet, @@ -44,6 +44,20 @@ struct Definitions { static DEFINITIONS: LazyLock> = LazyLock::new(|| RwLock::new(Definitions::default())); +#[cfg(test)] +mod restoration_tests; + +pub(crate) fn replace_definitions(definitions: Definitions) -> Definitions { + let previous = std::mem::replace( + &mut *DEFINITIONS + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner), + definitions, + ); + DEFINITIONS.clear_poison(); + previous +} + /// One statement of a stylesheet #[derive(Debug, PartialEq, Eq)] enum Item { diff --git a/libs/css/src/tailwind_definitions/restoration_tests.rs b/libs/css/src/tailwind_definitions/restoration_tests.rs new file mode 100644 index 000000000..295b37813 --- /dev/null +++ b/libs/css/src/tailwind_definitions/restoration_tests.rs @@ -0,0 +1,74 @@ +use super::*; +use crate::test_state::TestStateGuard; +use serial_test::serial; + +#[test] +#[serial] +fn complete_definitions_restore_when_nested_or_unwinding() { + let _state = TestStateGuard::new(); + for reset in ["--color-*: initial;", "--*: initial;"] { + let source = format!( + "@theme {{{reset} --color-brand: teal; @keyframes bounce {{to {{opacity: 0.5}}}}}} \ + @utility card {{color: red; @apply p-4;}} \ + @utility tab-* {{tab-size: --value(integer);}} \ + @custom-variant night (&:where(.night *));" + ); + let expected = read(&source).unwrap_or_else(|| panic!("invalid definitions fixture")); + assert_eq!(expected.reset_all, reset == "--*: initial;"); + assert_eq!( + expected.resets.contains("--color-"), + reset == "--color-*: initial;" + ); + assert_eq!(expected.theme.len(), 1); + assert_eq!(expected.utilities.len(), 1); + assert_eq!(expected.functional.len(), 1); + assert_eq!(expected.variants.len(), 1); + assert_eq!(expected.keyframes.len(), 1); + set_tailwind_css(&source); + { + let _inner = TestStateGuard::new(); + assert_eq!( + *DEFINITIONS.read().unwrap_or_else(|error| panic!("{error}")), + Definitions::default() + ); + set_tailwind_css("@utility inner {color: blue;}"); + } + assert_eq!( + *DEFINITIONS.read().unwrap_or_else(|error| panic!("{error}")), + expected + ); + let result = std::panic::catch_unwind(|| { + let _inner = TestStateGuard::new(); + set_tailwind_css("@theme {--*: initial;}"); + panic!("restore definitions"); + }); + assert!(result.is_err()); + assert_eq!( + *DEFINITIONS.read().unwrap_or_else(|error| panic!("{error}")), + expected + ); + } +} + +#[test] +#[serial] +fn replacement_recovers_when_registry_is_poisoned() { + let _state = TestStateGuard::new(); + set_tailwind_css("@utility card {color: red;}"); + let result = std::panic::catch_unwind(|| { + let _lock = DEFINITIONS + .write() + .unwrap_or_else(|error| panic!("{error}")); + panic!("poison registry"); + }); + assert!(result.is_err()); + let old = replace_definitions(Definitions::default()); + assert_eq!(old.utilities.len(), 1); + assert!(!DEFINITIONS.is_poisoned()); + assert_eq!(custom_utility("card"), None); + replace_definitions(old); + assert_eq!( + custom_utility("card"), + Some(vec![UtilityItem::Declaration("color".into(), "red".into())]) + ); +} diff --git a/libs/css/src/test_state.rs b/libs/css/src/test_state.rs new file mode 100644 index 000000000..4a0929e5c --- /dev/null +++ b/libs/css/src/test_state.rs @@ -0,0 +1,110 @@ +//! 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, BTreeSet, 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, slot_reservation, tailwind_definitions, + 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); + atom_hoist::restore_atom_plan(None); + slot_reservation::reset(); + 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(); + tailwind_definitions::replace_definitions(Default::default()); +} + +/// 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, + plan: Option>, + reservation: slot_reservation::State, + classes: HashMap>, + files: bimap::BiHashMap, + canonical: HashMap, + routes: HashMap>, + shorthands: (BTreeMap>, bool), + tokens: theme_tokens::ThemeTokenRegistry, + tailwind: tailwind_definitions::Definitions, + 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(), + plan: atom_hoist::atom_plan(), + reservation: slot_reservation::replace_state(Default::default()), + 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()), + tailwind: tailwind_definitions::replace_definitions(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); + atom_hoist::restore_atom_plan(self.plan.take()); + slot_reservation::replace_state(std::mem::take(&mut self.reservation)); + 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)); + tailwind_definitions::replace_definitions(std::mem::take(&mut self.tailwind)); + } +} + +#[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..f786a0ce5 --- /dev/null +++ b/libs/css/src/test_state/tests.rs @@ -0,0 +1,170 @@ +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()]); + theme_tokens::set_theme_token_values( + BTreeMap::from([("space".into(), "11px".into())]), + BTreeMap::from([("card".into(), "0 3px 5px red".into())]), + ); + theme_tokens::set_color_tokens(BTreeSet::from(["accent".into()])); + atom_hoist::restore_atom_plan(Some(BTreeSet::from(["outer-bucket".into()]))); + slot_reservation::ScratchSlots::default().commit(); + slot_reservation::record_insert(); +} + +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"]); + assert_eq!( + theme_tokens::get_first_theme_token_value("width", "$space"), + Some("11px".into()) + ); + assert_eq!( + theme_tokens::get_first_theme_token_value("box-shadow", "$card"), + Some("0 3px 5px red".into()) + ); + assert!(theme_tokens::is_color_token("accent")); + assert!(theme_tokens::is_shadow_token("card")); + assert_eq!( + atom_hoist::atom_plan(), + Some(BTreeSet::from(["outer-bucket".into()])) + ); + assert_eq!(slot_reservation::unreserved_slot_count(), 1); + slot_reservation::record_insert(); + assert_eq!(slot_reservation::unreserved_slot_count(), 2); +} + +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()); + assert_eq!( + theme_tokens::get_first_theme_token_value("width", "$space"), + None + ); + assert_eq!( + theme_tokens::get_first_theme_token_value("box-shadow", "$card"), + None + ); + assert!(!theme_tokens::is_color_token("accent")); + assert!(!theme_tokens::is_shadow_token("card")); + assert_eq!(atom_hoist::atom_plan(), None); + assert_eq!(slot_reservation::unreserved_slot_count(), 0); + slot_reservation::record_insert(); + assert_eq!(slot_reservation::unreserved_slot_count(), 0); +} + +#[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(); +} + +#[test] +#[serial] +fn frozen_plan_retains_none_empty_and_populated_when_scoped() { + let _state = TestStateGuard::new(); + for plan in [ + None, + Some(BTreeSet::new()), + Some(BTreeSet::from(["bucket".into()])), + ] { + atom_hoist::restore_atom_plan(plan.clone()); + { + let _inner = TestStateGuard::new(); + assert_eq!(atom_hoist::atom_plan(), None); + } + assert_eq!(atom_hoist::atom_plan(), plan); + let result = std::panic::catch_unwind(|| { + let _inner = TestStateGuard::new(); + panic!("restore plan"); + }); + assert!(result.is_err()); + assert_eq!(atom_hoist::atom_plan(), plan); + } +} diff --git a/libs/css/src/theme_tokens.rs b/libs/css/src/theme_tokens.rs index 84484eee0..87ce05d85 100644 --- a/libs/css/src/theme_tokens.rs +++ b/libs/css/src/theme_tokens.rs @@ -2,7 +2,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::sync::{LazyLock, RwLock}; #[derive(Default, Debug)] -struct ThemeTokenRegistry { +pub(crate) struct ThemeTokenRegistry { length: BTreeMap>, shadow: BTreeMap>, first_length: BTreeMap, @@ -14,6 +14,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>, @@ -202,6 +218,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(); @@ -217,6 +234,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]); @@ -235,6 +253,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![]); @@ -264,4 +283,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/css_prop_contract_tests.rs b/libs/extractor/src/css_prop_contract_tests.rs index 14a575fdf..51c149662 100644 --- a/libs/extractor/src/css_prop_contract_tests.rs +++ b/libs/extractor/src/css_prop_contract_tests.rs @@ -67,6 +67,7 @@ fn options() -> ExtractOption { #[test] #[serial] fn empty_css_expression_is_rejected_when_mixed_with_valid_forms() { + let _state = crate::test_state::TestStateGuard::new(); // Given: the original mixed fixture, including its invalid final attribute. reset_class_map(); reset_file_map(); @@ -103,6 +104,7 @@ fn repeated_layers_keep_first_use_when_later_atoms_replace_earlier_ones( #[case] rules: &str, #[case] expected: Vec<&str>, ) { + let _state = crate::test_state::TestStateGuard::new(); // Given: the first layer's first atom will lose D1 composition. reset_class_map(); reset_file_map(); @@ -154,6 +156,7 @@ fn custom_css_is_consumed_only_when_emotion_is_enabled( #[case] later: &str, #[case] enabled: bool, ) { + let _state = crate::test_state::TestStateGuard::new(); // Given: a custom component owns css unless an actual Emotion signal opts it in. reset_class_map(); reset_file_map(); @@ -170,6 +173,7 @@ fn custom_css_is_consumed_only_when_emotion_is_enabled( #[test] #[serial] fn layers_follow_authored_order_when_css_array_classes_are_generated() { + let _state = crate::test_state::TestStateGuard::new(); // Given: layers whose first-use order differs from alphabetical order, plus a D1 override. reset_class_map(); reset_file_map(); @@ -210,6 +214,7 @@ fn layers_follow_authored_order_when_css_array_classes_are_generated() { #[case::template("t.colors[`01`]", "var(--colors-01)")] #[serial] fn theme_keys_preserve_exact_text_when_computed(#[case] read: &str, #[case] expected: &str) { + let _state = crate::test_state::TestStateGuard::new(); // Given: a theme key whose spelling is part of the provider's CSS variable name. reset_class_map(); reset_file_map(); @@ -250,6 +255,7 @@ fn emotion_numbers_stay_pixels_when_rule_arrays_are_inlined( #[case] declaration: &str, #[case] rules: &str, ) { + let _state = crate::test_state::TestStateGuard::new(); // Given: equivalent inline and constant arrays, including nested rules and unitless values. reset_class_map(); reset_file_map(); diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 8f6106253..b966f1387 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -47,6 +47,8 @@ mod tailwind_effects; mod tailwind_misc; mod tailwind_motion; mod tailwind_theme; +#[doc(hidden)] +pub mod test_state; mod util_type; mod utils; mod vanilla_extract; @@ -1392,6 +1394,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(); @@ -1425,6 +1428,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(); @@ -12709,6 +12713,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) @@ -12733,6 +12738,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 @@ -12778,6 +12784,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) @@ -12816,6 +12823,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 @@ -12842,6 +12850,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 @@ -12867,6 +12876,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 @@ -12892,6 +12902,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 @@ -12916,6 +12927,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 @@ -12940,6 +12952,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) @@ -12985,6 +12998,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 @@ -13038,6 +13052,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 @@ -13090,6 +13105,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(); @@ -13147,6 +13163,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(); @@ -13224,6 +13241,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( @@ -13255,6 +13273,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( @@ -13284,6 +13303,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(); @@ -13316,6 +13336,7 @@ globalStyle('*', { #[test] #[serial] fn test_vanilla_extract_container() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); // createContainer - container queries @@ -13351,6 +13372,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 @@ -13384,6 +13406,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 @@ -13419,6 +13442,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 @@ -13492,6 +13516,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 @@ -13530,6 +13555,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 @@ -13724,6 +13750,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(); @@ -13750,6 +13777,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(); @@ -13775,6 +13803,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(); @@ -13802,6 +13831,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(); @@ -13836,6 +13866,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(); @@ -13869,6 +13900,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(); @@ -13895,6 +13927,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(); @@ -13928,6 +13961,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(); @@ -13959,6 +13993,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(); @@ -13996,6 +14031,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(); @@ -14032,6 +14068,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(); @@ -14063,6 +14100,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(); @@ -14093,6 +14131,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(); @@ -14119,6 +14158,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(); @@ -14143,6 +14183,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(); @@ -14400,6 +14441,7 @@ globalCss({ #[test] #[serial] fn test_stylesheet_evaluation_errors() { + let _state = test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); let error = extract( @@ -14454,6 +14496,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(); @@ -14479,6 +14522,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(); @@ -14511,6 +14555,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(); @@ -14544,6 +14589,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(); @@ -14571,6 +14617,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(); @@ -14601,6 +14648,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(); @@ -14636,6 +14684,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(); @@ -14662,6 +14711,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(); @@ -14690,6 +14740,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(); @@ -14724,6 +14775,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(); @@ -14750,6 +14802,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(); @@ -14780,6 +14833,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(); @@ -14812,6 +14866,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(); @@ -14841,6 +14896,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(); @@ -14875,6 +14931,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(); @@ -14999,6 +15056,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(); @@ -15029,6 +15087,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(); @@ -15093,6 +15152,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(); @@ -15127,6 +15187,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(); @@ -15375,6 +15436,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); @@ -15389,7 +15451,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(), @@ -15826,6 +15887,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)] = &[( @@ -17592,6 +17654,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( @@ -19412,6 +19475,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(); @@ -19476,6 +19540,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(); @@ -19521,6 +19586,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]); @@ -19611,6 +19677,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]); @@ -23375,6 +23442,7 @@ export const App = () => String { } pub(super) fn execute(filename: &str, source: &str, observation: &str) -> Value { + let _state = crate::test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); let output = extract( diff --git a/libs/extractor/src/test_state.rs b/libs/extractor/src/test_state.rs new file mode 100644 index 000000000..7751d11f2 --- /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::CacheGuard, + _css: css::test_state::TestStateGuard, +} + +impl TestStateGuard { + /// Own snapshots without holding locks across extraction. + pub fn new() -> Self { + Self { + _caches: crate::vanilla_extract::CacheGuard::default(), + _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 bdb34af0d..0004e36b1 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -327,6 +327,7 @@ type ImportedRuns = FxHashMap; pub(crate) struct CacheGuard { imported: ImportedRuns, stripped: FxHashMap, + same_thread: std::marker::PhantomData>, } impl Default for CacheGuard { @@ -334,6 +335,7 @@ impl Default for CacheGuard { Self { imported: IMPORTED_RUNS.with_borrow_mut(std::mem::take), stripped: STRIPPED.with_borrow_mut(std::mem::take), + same_thread: std::marker::PhantomData, } } } @@ -355,6 +357,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, @@ -1560,6 +1564,7 @@ mod tests { #[test] fn test_strip_typescript_once_per_source() { + let _state = CacheGuard::default(); let stripped = strip_typescript("export const a: number = 1;", "strip-cache.ts"); assert_eq!( strip_typescript("export const a: number = 1;", "strip-cache.ts"), @@ -1598,6 +1603,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", @@ -1688,6 +1694,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 }) } @@ -1723,6 +1730,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' }) @@ -1767,6 +1775,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' } }) @@ -1792,6 +1801,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 }) @@ -1810,6 +1820,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' }) @@ -1837,6 +1848,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') @@ -1856,6 +1868,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() @@ -1886,6 +1899,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() @@ -1915,6 +1929,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 }) @@ -1955,6 +1970,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()); @@ -1998,6 +2014,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..5a094875b --- /dev/null +++ b/libs/extractor/src/vanilla_extract/test_state.rs @@ -0,0 +1,55 @@ +use super::{FxHashMap, IMPORTED_RUNS, STRIPPED}; + +pub(crate) fn reset() { + IMPORTED_RUNS.with_borrow_mut(FxHashMap::clear); + STRIPPED.with_borrow_mut(FxHashMap::clear); +} + +#[cfg(test)] +mod tests { + use super::super::{CacheGuard, CollectedStyles}; + use super::*; + + #[test] + fn caches_restore_when_nested_and_unwinding() { + let _state = CacheGuard::default(); + 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::default(); + 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::default(); + drop(nested); + assert_eq!(STRIPPED.with_borrow(Clone::clone), baseline); + } + + #[test] + fn caches_clear_when_populated_and_empty() { + let _state = CacheGuard::default(); + 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 ded36bc7f..e4b0e533b 100644 --- a/libs/sheet/src/lib.rs +++ b/libs/sheet/src/lib.rs @@ -1565,6 +1565,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); @@ -1582,6 +1583,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(); @@ -1684,6 +1686,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 = css::atom_hoist::atom_hoist_threshold(); let previous_plan = atom_plan(); @@ -1729,6 +1732,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(); @@ -1794,6 +1798,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()), @@ -1815,6 +1820,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")); @@ -1828,6 +1834,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", @@ -1862,6 +1869,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); @@ -1881,6 +1889,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", @@ -1904,6 +1913,7 @@ mod tests { #[test] #[serial] fn test_import_css() { + let _state = css::test_state::TestStateGuard::new(); let sheet = StyleSheet::default(); assert_debug_snapshot!( sheet @@ -1917,6 +1927,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()); @@ -2077,6 +2088,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;}"); @@ -2094,6 +2106,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); @@ -2123,6 +2136,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); @@ -2132,6 +2146,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", @@ -2175,6 +2190,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", @@ -2318,6 +2334,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", @@ -2422,6 +2439,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", @@ -2439,6 +2457,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", @@ -2456,6 +2475,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", @@ -2473,6 +2493,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", @@ -2511,6 +2532,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", @@ -2534,6 +2556,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", @@ -2632,6 +2655,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", @@ -2808,6 +2832,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"); @@ -2839,6 +2864,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( @@ -2982,6 +3008,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(); @@ -3020,6 +3047,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()); @@ -3043,6 +3071,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(); @@ -3108,6 +3137,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(); @@ -3121,6 +3151,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( @@ -3174,6 +3205,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( @@ -3200,6 +3232,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( @@ -3226,6 +3259,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( @@ -3252,6 +3286,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(); @@ -3271,6 +3306,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( @@ -3367,6 +3403,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"))); @@ -3394,6 +3431,7 @@ mod tests { #[test] #[serial] fn base_styles_emit_globally_without_atom_promotion() { + 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")); @@ -3412,6 +3450,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) @@ -3440,6 +3479,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(); @@ -3472,6 +3512,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(); @@ -3493,6 +3534,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(); @@ -3524,6 +3566,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( @@ -3548,6 +3591,7 @@ mod tests { } fn pipeline_css_in_mode(theme: Theme, source: &str, single_css: bool) -> String { + let _state = extractor::test_state::TestStateGuard::new(); css::debug::set_debug(false); css::set_prefix(None); css::atom_hoist::set_atom_hoist(None); @@ -4249,6 +4293,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", @@ -4323,6 +4368,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/package.json b/package.json index bef5bf486..390c16948 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", diff --git a/packages/bun-plugin/src/__tests__/captured-hooks.test-support.ts b/packages/bun-plugin/src/__tests__/captured-hooks.test-support.ts new file mode 100644 index 000000000..439d68e48 --- /dev/null +++ b/packages/bun-plugin/src/__tests__/captured-hooks.test-support.ts @@ -0,0 +1,110 @@ +import * as fs from 'node:fs' +import * as fsPromises from 'node:fs/promises' + +import type { BunPlugin, PluginBuilder } from 'bun' +import { spyOn } from 'bun:test' + +import { bunRegistration } from '../../../../bun.setup' + +export function captureBunHooks() { + const realPlugin = Bun.plugin + const loads: { + readonly options: Parameters[0] + readonly callback: Parameters[1] + }[] = [] + const resolves: Parameters[1][] = [] + const ends: Parameters[0][] = [] + const setups: Promise>[] = [] + const expectedErrors = new Set() + const exists = spyOn(fs, 'existsSync').mockReturnValue(false) + const readConfig = spyOn(fsPromises, 'readFile').mockResolvedValue('{}') + const write = spyOn(fsPromises, 'writeFile').mockResolvedValue(undefined) + const writeSync = spyOn(fs, 'writeFileSync').mockReturnValue(undefined) + const 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 + } + builder.onEnd = (callback) => { + ends.push(callback) + return builder + } + const setup = Promise.resolve().then(() => config.setup(builder)) + setups.push( + setup.then( + () => ({ status: 'fulfilled', value: undefined }), + (reason: unknown) => ({ status: 'rejected', reason }), + ), + ) + return setup + }, + }), + { clearAll: realPlugin.clearAll }, + ) + bunRegistration.mockImplementation(capturePlugin) + async function cleanup() { + const errors: unknown[] = [] + try { + const outcomes = await Promise.all(setups.splice(0)) + for (const outcome of outcomes) { + switch (outcome.status) { + case 'fulfilled': + break + case 'rejected': + if (!expectedErrors.has(outcome.reason)) errors.push(outcome.reason) + break + } + } + const terminals = await Promise.allSettled( + ends + .splice(0) + .map((end) => + Promise.resolve().then(() => + end({ success: errors.length === 0, outputs: [], logs: [] }), + ), + ), + ) + for (const terminal of terminals) { + switch (terminal.status) { + case 'fulfilled': + break + case 'rejected': + errors.push(terminal.reason) + break + } + } + } finally { + bunRegistration.mockRestore() + bunRegistration.mockImplementation(realPlugin) + exists.mockRestore() + readConfig.mockRestore() + write.mockRestore() + writeSync.mockRestore() + mkdir.mockRestore() + } + if (errors.length) + throw new AggregateError(errors, 'Captured Bun lifecycle failed') + } + return { + loads, + resolves, + ends, + setups, + expectedErrors, + exists, + readConfig, + write, + writeSync, + mkdir, + cleanup, + } +} diff --git a/packages/bun-plugin/src/__tests__/captured-hooks.test.ts b/packages/bun-plugin/src/__tests__/captured-hooks.test.ts new file mode 100644 index 000000000..e9c149f63 --- /dev/null +++ b/packages/bun-plugin/src/__tests__/captured-hooks.test.ts @@ -0,0 +1,77 @@ +import * as fs from 'node:fs' +import * as fsPromises from 'node:fs/promises' + +import * as wasm from '@devup-ui/wasm' +import { afterEach, beforeEach, expect, it } from 'bun:test' + +import { bunRegistration } from '../../../../bun.setup' +import { register } from '../plugin' +import { captureBunHooks } from './captured-hooks.test-support' + +const realPlugin = Bun.plugin +const realExists = fs.existsSync +const realWrite = fsPromises.writeFile +let hooks: ReturnType + +beforeEach(() => { + hooks = captureBunHooks() +}) +afterEach(async () => { + await hooks.cleanup() +}) + +it('releases a rejected setup and restores spies before subsequent registration', async () => { + // Given: real plugin setup rejects after registering its real session end. + const failure = new Error('initialization write rejected') + hooks.write.mockRejectedValueOnce(failure) + await expect(register()).rejects.toBe(failure) + hooks.expectedErrors.add(failure) + let ended = 0 + const terminals = hooks.ends.splice(0) + for (const end of terminals) + hooks.ends.push(async (result) => { + await end(result) + ended += 1 + }) + // When: cleanup handles the explicitly asserted setup rejection. + await hooks.cleanup() + // Then: the real end ran and spies were restored, and a new registration works. + expect(ended).toBe(1) + expect(fs.existsSync).toBe(realExists) + expect(fsPromises.writeFile).toBe(realWrite) + expect(Bun.plugin).toBe(realPlugin) + expect(bunRegistration.clearAll).toBe(realPlugin.clearAll) + hooks = captureBunHooks() + await register({ debug: false }) + expect(hooks.loads.length).toBe(2) + expect(wasm.isDebug()).toBe(false) +}) + +it('keeps unacknowledged setup failures as teardown errors after releasing sessions', async () => { + const failure = new Error('unexpected setup failure') + hooks.write.mockRejectedValueOnce(failure) + await expect(register()).rejects.toBe(failure) + let ended = false + hooks.ends.push(() => { + ended = true + }) + await expect(hooks.cleanup()).rejects.toMatchObject({ errors: [failure] }) + expect(ended).toBe(true) + expect(fsPromises.writeFile).toBe(realWrite) +}) + +it('attempts every terminal and restores spies when one terminal rejects', async () => { + const failure = new Error('terminal failure') + let ended = false + hooks.ends.push( + async () => { + throw failure + }, + () => { + ended = true + }, + ) + await expect(hooks.cleanup()).rejects.toMatchObject({ errors: [failure] }) + expect(ended).toBe(true) + expect(fs.existsSync).toBe(realExists) +}) diff --git a/packages/bun-plugin/src/__tests__/plugin.test.ts b/packages/bun-plugin/src/__tests__/plugin.test.ts index e93d67418..2dbb83469 100644 --- a/packages/bun-plugin/src/__tests__/plugin.test.ts +++ b/packages/bun-plugin/src/__tests__/plugin.test.ts @@ -1,199 +1,237 @@ import * as fs from 'node:fs' -import * as fsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import * as nodePath from 'node:path' import { join } from 'node:path' import * as wasm from '@devup-ui/wasm' 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 { DevupUI, register } from '../plugin' +import { captureBunHooks } from './captured-hooks.test-support' + +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 realExists = fs.existsSync +const realResolve = nodePath.resolve +let hooks: ReturnType +let loads: typeof hooks.loads +let resolves: typeof hooks.resolves +let exists: typeof hooks.exists +let readConfig: typeof hooks.readConfig +let write: typeof hooks.write +let writeSync: typeof hooks.writeSync +let mkdir: typeof hooks.mkdir 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(), - ) + hooks = captureBunHooks() + ;({ loads, resolves, exists, readConfig, write, writeSync, mkdir } = hooks) }) -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() +afterEach(async () => { + await hooks.cleanup() }) +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') +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 () => {}, }) +} - it('should return empty object when no theme', async () => { - getDefaultThemeSpy.mockReturnValue(undefined) - expect(getDefaultThemeSpy()).toBe(undefined) +describe('registered Bun hooks', () => { + it('seeds readable names from physical inputs before runtime source loads', async () => { + const file = join(root, 'numbered.tsx') + await Bun.write( + file, + "import {Box} from '@devup-ui/react'; export const view=", + ) + exists.mockImplementation(realExists) + const resolve = spyOn(nodePath, 'resolve').mockImplementation((...paths) => + paths.length === 1 && paths[0] === 'src' ? root : realResolve(...paths), + ) + try { + await register() + expect(Object.keys(JSON.parse(wasm.exportFileMap()))).toContain(file) + expect(wasm.isDebug()).toBe(true) + } finally { + resolve.mockRestore() + } }) -}) - -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'] ?? {}) + it('retains native registration and clearAll identity', () => { + expect(Bun.plugin).toBe(realPlugin) + expect(bunRegistration.clearAll).toBe(realPlugin.clearAll) + }) - expect(registerThemeSpy).toHaveBeenCalledWith({ - colors: { primary: '#000' }, - }) + it('reserves short runtime names during awaited setup', async () => { + await register({ debug: false }) + expect(wasm.isDebug()).toBe(false) + expect(loads.length).toBe(2) }) - it('should write theme.d.ts when interfaceCode is returned', async () => { - getThemeInterfaceSpy.mockReturnValue('interface CustomColors {}') + it('leaves invalid JavaScript to the native loader after scanning fails', async () => { + const path = join(root, 'malformed.ts') + const source = 'import {' + await Bun.write(path, source) + await register() + expect(await load(path)).toEqual({ contents: source, loader: 'ts' }) + expect(writeSync).not.toHaveBeenCalled() + }) - const interfaceCode = getThemeInterfaceSpy( - '@devup-ui/react', - 'CustomColors', - 'DevupThemeTypography', - 'CustomLength', - 'CustomShadows', - 'DevupTheme', + it('awaits real build setup and deferred CSS through two native build lifecycles', async () => { + const entry = join(root, 'bundle.tsx') + await Bun.write( + entry, + "import {Box} from '@devup-ui/react'; export const view=;", ) - - if (interfaceCode) { - await writeFileSpy(join('df', 'theme.d.ts'), interfaceCode, 'utf-8') + const outputs: string[][] = [] + for (let round = 0; round < 2; round += 1) { + const result = await Bun.build({ + entrypoints: [entry], + target: 'bun', + external: ['react/jsx-dev-runtime', 'react/jsx-runtime'], + outdir: join(root, 'out'), + plugins: [DevupUI()], + }) + expect(result.success).toBe(true) + expect(result.logs).toEqual([]) + const css = await Promise.all( + result.outputs + .filter((output) => output.path.endsWith('.css')) + .map((output) => output.text()), + ) + expect(css.join('')).toMatch(/color:\s*tomato/) + outputs.push(css) } + expect(outputs[1]).toEqual(outputs[0]) + expect(writeSync).not.toHaveBeenCalled() + }) - expect(writeFileSpy).toHaveBeenCalledWith( + 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, + }) + 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 e7930b94c..7d346a6ba 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 af08e5a64..a9fa200a0 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 1a56f2d1e..2edae3e78 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 { beforeEach, 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 @@ -74,6 +64,7 @@ describe('@devup-ui/wasm exports', () => { 'registerTheme', 'reserveClassSlots', 'resetBuildState', + 'resetStateForTesting', 'seedFileMap', 'setAtomHoist', 'setDebug', @@ -186,6 +177,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('seeds deterministic file numbers when paths are unsorted and duplicated', () => { wasm.resetBuildState() @@ -261,13 +303,6 @@ describe('@devup-ui/wasm reservation', () => { wasm.setDebug(false) }) - afterEach(() => { - // Map/sheet imports do not restore reservation bookkeeping. Leave it reset; - // restore the public state here too, independently of outer hook ordering. - wasm.resetBuildState() - restoreState(saved) - }) - it('reserves class and file slots without publishing scratch styles', () => { wasm.seedFileMap(['abi.tsx']) extract(`import { Box } from '${PACKAGE}'\nexport const a = `) diff --git a/packages/plugin-utils/src/__tests__/wasm-reset.test.ts b/packages/plugin-utils/src/__tests__/wasm-reset.test.ts new file mode 100644 index 000000000..e26fdf87f --- /dev/null +++ b/packages/plugin-utils/src/__tests__/wasm-reset.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it, mock } from 'bun:test' + +const wasm = await import('../../../../bindings/devup-ui-wasm/pkg/index.js') +const pkg = '@devup-ui/react' +const source = `import { Box } from '${pkg}'; import { tone } from './tokens'; export const view = ` + +function extract(code: string, file = 'reset.tsx', single = true) { + return wasm.codeExtract(file, code, pkg, 'df', single, false, false, {}) +} + +describe('normal WASM full reset', () => { + it('removes the resolver on a path that needs it and accepts a different resolver', () => { + const old = mock(() => ({ + path: 'tokens.ts', + code: "export const tone = 'red'", + })) + wasm.setModuleResolver(old) + expect(extract(source).dependencies).toEqual(['tokens.ts']) + expect(wasm.getCss(null, false)).toContain('background:red') + + wasm.resetStateForTesting() + old.mockClear() + const unresolved = extract(source) + expect(old).not.toHaveBeenCalled() + expect(unresolved.dependencies).toEqual([]) + expect(wasm.getCss(null, false)).not.toContain('background:red') + const fresh = mock(() => ({ + path: 'tokens.ts', + code: "export const tone = 'blue'", + })) + wasm.setModuleResolver(fresh) + expect(extract(source).dependencies).toEqual(['tokens.ts']) + expect(fresh).toHaveBeenCalled() + expect(wasm.getCss(null, false)).toContain('background:blue') + }) + + it('evicts imported stylesheet results before a different source is registered', () => { + const entry = `import { style } from '${pkg}'; import { card } from './card.css'; export const view = style([card, {width:'12px'}])` + const supplier = (color: string) => ({ + path: 'card.css.ts', + code: `import { style } from '${pkg}'; export const card = style({color:'${color}'})`, + }) + const old = mock(() => supplier('red')) + wasm.setModuleResolver(old) + extract(supplier('red').code, 'card.css.ts') + expect(extract(entry, 'root.css.ts').dependencies).toContain('card.css.ts') + expect(wasm.getCss(null, false)).toContain('color:red') + wasm.resetStateForTesting() + old.mockClear() + expect(() => extract(entry, 'root.css.ts')).toThrow() + expect(old).not.toHaveBeenCalled() + expect(wasm.getCss(null, false)).not.toContain('color:red') + wasm.setModuleResolver(() => supplier('blue')) + extract(supplier('blue').code, 'card.css.ts') + expect(extract(entry, 'root.css.ts').dependencies).toContain('card.css.ts') + expect(wasm.getCss(null, false)).toContain('color:blue') + expect(wasm.getCss(null, false)).not.toContain('color:red') + }) + + it('returns reservation accounting to Legacy before the next reservation', () => { + const red = `import {Box} from '${pkg}'; export const v=` + const blue = red.replace('red', 'blue') + wasm.setDebug(false) + wasm.reserveClassSlots( + [['reserved.tsx', red, 'df']], + pkg, + true, + false, + false, + {}, + ) + extract(blue, 'reserved.tsx') + expect(wasm.getUnreservedClassSlotCount()).toBe(1) + for (let round = 0; round < 2; round += 1) { + wasm.resetStateForTesting() + wasm.resetStateForTesting() + expect(wasm.getUnreservedClassSlotCount()).toBe(0) + expect(wasm.exportClassMap()).toBe('{}') + extract(blue, 'legacy.tsx') + expect(wasm.getUnreservedClassSlotCount()).toBe(0) + wasm.reserveClassSlots( + [['reserved.tsx', red, 'df']], + pkg, + true, + false, + false, + {}, + ) + extract(red, 'reserved.tsx') + expect(wasm.getUnreservedClassSlotCount()).toBe(0) + const green = red.replace('red', 'green') + extract(green, 'reserved.tsx') + expect(wasm.getUnreservedClassSlotCount()).toBe(1) + extract(green, 'reserved.tsx') + expect(wasm.getUnreservedClassSlotCount()).toBe(1) + const raw = `import {createVar} from '${pkg}'; export const v=createVar()` + extract(raw, 'virtual.css.ts') + expect(wasm.getUnreservedClassSlotCount()).toBe(2) + extract(raw, 'virtual.css.ts') + expect(wasm.getUnreservedClassSlotCount()).toBe(2) + } + }) + + it('freezes only newly configured buckets after full reset', () => { + const code = `import {Box} from '${pkg}'; export const v=` + wasm.resetStateForTesting() + wasm.setAtomHoist(2) + wasm.importCanonicalMap({ 'old.tsx': 'old-bucket.tsx' }) + wasm.importFileRoutes({ 'old-bucket.tsx': [0, 1] }) + extract(code, 'old.tsx', false) + expect(JSON.parse(wasm.exportSheet()).atom_plan).toEqual(['old-bucket.tsx']) + + wasm.resetStateForTesting() + wasm.setAtomHoist(2) + wasm.importCanonicalMap({ 'fresh.tsx': 'fresh-bucket.tsx' }) + wasm.importFileRoutes({ 'fresh-bucket.tsx': [2, 3], 'private.tsx': [2] }) + const shared = extract(code.replace('red', 'blue'), 'fresh.tsx', false) + const local = extract(code.replace('red', 'green'), 'private.tsx', false) + expect(JSON.parse(wasm.exportSheet()).atom_plan).toEqual([ + 'fresh-bucket.tsx', + ]) + expect(wasm.getCss(null, false)).toContain('color:blue') + expect(wasm.getCss(null, false)).not.toContain('color:green') + expect(shared.css).not.toContain('color:blue') + expect(local.css).toContain('color:green') + expect(wasm.getCss(null, false)).not.toContain('color:red') + }) + + it('clears token literals, shorthands and Tailwind definitions before reconfiguration', () => { + wasm.resetStateForTesting() + const code = `import {Box} from '${pkg}'; export const v=` + const baseline = extract(code).code + wasm.registerShorthands({ customInset: ['left', 'right'] }) + wasm.registerTheme({ + colors: { default: { stale: '#abcdef' } }, + length: { default: { space: ['11px', null, '29px'] } }, + shadow: { default: { card: ['0 3px 5px red', null, '0 5px 8px blue'] } }, + typography: { staleHeading: { fontSize: '37px' } }, + tailwindCss: + '@theme {--color-*: initial; --color-brand: teal; @keyframes bounce {to {opacity: 0.5}}} @utility paint {color: red; @apply p-4;} @utility tab-* {tab-size: --value(integer);} @custom-variant night (&:where(.night *));', + }) + const dirty = extract(code) + expect(dirty.code).not.toBe(baseline) + expect(wasm.getCss(null, false)).toContain('color:red') + expect(wasm.getCss(null, false)).toContain('tab-size:4') + expect(wasm.getCss(null, false)).toContain('left:12px') + for (let round = 0; round < 2; round += 1) { + wasm.resetStateForTesting() + expect(extract(code).code).toBe(baseline) + const css = wasm.getCss(null, false) + expect(css).not.toContain('29px') + expect(css).not.toContain('color:red') + expect(css).not.toContain('tab-size:4') + expect(css).not.toContain('left:12px') + const types = wasm.getThemeInterface( + pkg, + 'Colors', + 'Typography', + 'Length', + 'Shadows', + 'Theme', + ) + expect(types).not.toContain('staleHeading') + expect(types).not.toContain('$stale') + } + wasm.registerTheme({ tailwindCss: '@utility paint {color: blue;}' }) + extract(code) + expect(wasm.getCss(null, false)).toContain('color:blue') + expect(wasm.getCss(null, false)).not.toContain('color:red') + }) +})