From caaf0c83b5bb1878dd716863da464f3c95f5a5d0 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Tue, 15 Sep 2026 02:51:29 +0000 Subject: [PATCH 1/2] fix(resolve): rank the bun export condition above node for --platform bun MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bun resolves package `exports` and `imports` with `["bun", "node", …]`. Perry's resolvers used `["perry", "node", "import", "module", "default", "require"]` with no `bun` entry at all, so a package shipping both entries compiled its node build even when the compile targeted bun. `@opentui/core` is the case that surfaced it: its exports are `{ bun: ./index.bun.js, node: ./index.node.js, import: ./index.node.js }`, and its two entries load different renderer backends. OpenCode's terminal interface reached the node backend and threw `TypeError: value is not a function` during the dynamic import of its layer module, while the official bun binary runs the bun entry. A module-body trace over the whole subgraph showed perry initializing `index.node.js` and its node chunks where bun initialized `index.bun.js`. `bun` is ranked directly after `perry`, so an explicit perry entry still wins, and above `node` only when the target is bun. The three resolvers (`resolve_exports`, `resolve_exports_candidates`, `resolve_subpath_import`) now read one shared accessor so they cannot disagree, which the existing comments already required of them. Refs #10107. --- changelog.d/10281-bun-export-condition.md | 10 ++++++ crates/perry/src/commands/compile/resolve.rs | 12 +++---- .../compile/resolve/subpath_imports.rs | 35 +++++++++++++++++-- .../src/commands/compile/run_pipeline.rs | 4 +++ crates/perry/src/commands/deps.rs | 4 +-- 5 files changed, 53 insertions(+), 12 deletions(-) create mode 100644 changelog.d/10281-bun-export-condition.md diff --git a/changelog.d/10281-bun-export-condition.md b/changelog.d/10281-bun-export-condition.md new file mode 100644 index 0000000000..a833e3d429 --- /dev/null +++ b/changelog.d/10281-bun-export-condition.md @@ -0,0 +1,10 @@ +Rank the `bun` condition above `node` when resolving package `exports` and +`imports` under `--platform bun`. Bun resolves with `["bun", "node", …]`, so a +package that ships both entries — `@opentui/core` offers `{ bun: +./index.bun.js, node: ./index.node.js, import: ./index.node.js }` — previously +compiled its node entry and ran a different backend than the bun binary the +program is meant to match. OpenCode's terminal interface died on exactly that: +the node entry's renderer backend is not the one its bun build loads. `bun` +stays below `perry`, so an explicit perry entry still wins, and the order is +unchanged for every other target. The three resolvers now read one shared +list so they cannot disagree. diff --git a/crates/perry/src/commands/compile/resolve.rs b/crates/perry/src/commands/compile/resolve.rs index 190bc1ae87..b77abcb267 100644 --- a/crates/perry/src/commands/compile/resolve.rs +++ b/crates/perry/src/commands/compile/resolve.rs @@ -989,11 +989,7 @@ fn resolve_exports_with_conditions( // documented single-result entry point (mirrors `resolve_subpath_import`). #[allow(dead_code)] pub(super) fn resolve_exports(exports: &serde_json::Value, subpath: &str) -> Option { - resolve_exports_with_conditions( - exports, - subpath, - &["perry", "node", "import", "module", "default", "require"], - ) + resolve_exports_with_conditions(exports, subpath, subpath_imports::default_conditions()) } /// Like [`resolve_exports`], but returns EVERY condition branch's resolution @@ -1012,7 +1008,7 @@ pub(super) fn resolve_exports_candidates( // traversePathUp only in `./node.js`; resolving `./default.js` left them // undefined → npm-run-path → execa LINK failure.) Mirrors `resolve_exports` // / `resolve_subpath_import`. - const CONDITIONS: &[&str] = &["perry", "node", "import", "module", "default", "require"]; + fn collect(value: &serde_json::Value, subpath: &str, out: &mut Vec) { match value { serde_json::Value::String(s) if !out.contains(s) => { @@ -1060,7 +1056,7 @@ pub(super) fn resolve_exports_candidates( } } } - for condition in CONDITIONS { + for condition in subpath_imports::default_conditions() { if let Some(entry) = map.get(*condition) { collect(entry, subpath, out); } @@ -1447,7 +1443,7 @@ pub(super) fn resolve_import_with_bunfs( match subpath_imports::resolve_subpath_import( import_source, importer_path, - subpath_imports::DEFAULT_CONDITIONS, + subpath_imports::default_conditions(), ) { Ok(SubpathImportOutcome::File(canonical)) => Some(canonical), // Bare-package target: re-enter resolution with the mapped diff --git a/crates/perry/src/commands/compile/resolve/subpath_imports.rs b/crates/perry/src/commands/compile/resolve/subpath_imports.rs index 704e7ad6c3..71f1cbc1f1 100644 --- a/crates/perry/src/commands/compile/resolve/subpath_imports.rs +++ b/crates/perry/src/commands/compile/resolve/subpath_imports.rs @@ -53,8 +53,39 @@ use super::{normalize_path_lexically, resolve_with_extensions}; /// (`resolve_exports` / `resolve_exports_candidates`) exactly — including /// ranking `node` above `default`, so a `{ node, default: browser }` /// conditional pair picks the node build for native compilation. -pub(crate) const DEFAULT_CONDITIONS: &[&str] = - &["perry", "node", "import", "module", "default", "require"]; +const NODE_CONDITIONS: &[&str] = &["perry", "node", "import", "module", "default", "require"]; + +/// The same order with `bun` ranked directly after `perry`, used when the +/// compile targets `--platform bun` (#10281). Bun resolves with `["bun", +/// "node", ...]`, so a package that ships both — `@opentui/core` offers +/// `{ bun: ./index.bun.js, node: ./index.node.js, import: ./index.node.js }` — +/// must resolve its bun entry or the program runs a different backend than the +/// bun binary it is meant to match. `bun` stays BELOW `perry` so an explicit +/// perry entry still wins, and above `node` only for this target. +const BUN_CONDITIONS: &[&str] = + &["perry", "bun", "node", "import", "module", "default", "require"]; + +/// Whether this compile targets `--platform bun`. Set once from +/// `CompilationContext::bun_platform` before module collection; a compile is a +/// single process with a single target, so a process-wide flag is the whole +/// state. Read on the already cold resolution path. +static BUN_PLATFORM: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Record the target platform for export/import condition resolution. +pub(crate) fn set_bun_platform(on: bool) { + BUN_PLATFORM.store(on, std::sync::atomic::Ordering::Relaxed); +} + +/// Conditions accepted when matching conditional targets, in priority order. +/// `resolve_exports`, `resolve_exports_candidates` and `resolve_subpath_import` +/// must all use this one source so the three resolvers cannot disagree. +pub(crate) fn default_conditions() -> &'static [&'static str] { + if BUN_PLATFORM.load(std::sync::atomic::Ordering::Relaxed) { + BUN_CONDITIONS + } else { + NODE_CONDITIONS + } +} /// Successful outcome of resolving a `#` subpath-import specifier. #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index f751ace6dc..52de202f58 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -764,6 +764,10 @@ pub fn run_with_parse_cache( let mut ctx = CompilationContext::new(project_root.clone()); ctx.bun_platform = args.platform == JavaScriptPlatform::Bun; + // #10281: the package `exports` / `imports` resolvers rank `bun` above + // `node` for this target, so a package shipping both entries resolves the + // one the bun binary would run. + resolve::subpath_imports::set_bun_platform(ctx.bun_platform); ctx.cache_root = object_cache_project_root(&args.input, &project_root); ctx.bunfs_root = match args.bunfs_root.as_deref() { Some(root) => { diff --git a/crates/perry/src/commands/deps.rs b/crates/perry/src/commands/deps.rs index 18ec7641cf..9e625b2f6c 100644 --- a/crates/perry/src/commands/deps.rs +++ b/crates/perry/src/commands/deps.rs @@ -145,9 +145,9 @@ impl DependencyResolver { // spec resolver the compiler uses (see resolve/subpath_imports.rs). if import_source.starts_with('#') { use super::compile::resolve::subpath_imports::{ - resolve_subpath_import, SubpathImportOutcome, DEFAULT_CONDITIONS, + default_conditions, resolve_subpath_import, SubpathImportOutcome, }; - match resolve_subpath_import(import_source, importing_file, DEFAULT_CONDITIONS) { + match resolve_subpath_import(import_source, importing_file, default_conditions()) { // Maps to a real file inside the package: resolved. Ok(SubpathImportOutcome::File(_)) => return, // Maps to a bare package specifier: verify THAT package the From 24c6c3841fdd269003ba3ef2a85d0279082ff662 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Tue, 15 Sep 2026 05:08:34 +0200 Subject: [PATCH 2/2] test(resolve): pin the bun export condition in both directions (#10281) --- .../compile/resolve/subpath_imports.rs | 77 +++++++++-------- .../src/commands/compile/resolve/tests.rs | 4 + .../tests/bun_export_condition_tests.rs | 83 +++++++++++++++++++ 3 files changed, 129 insertions(+), 35 deletions(-) create mode 100644 crates/perry/src/commands/compile/resolve/tests/bun_export_condition_tests.rs diff --git a/crates/perry/src/commands/compile/resolve/subpath_imports.rs b/crates/perry/src/commands/compile/resolve/subpath_imports.rs index 71f1cbc1f1..5a8aeee7a3 100644 --- a/crates/perry/src/commands/compile/resolve/subpath_imports.rs +++ b/crates/perry/src/commands/compile/resolve/subpath_imports.rs @@ -16,7 +16,7 @@ //! the longer total key (Node's `patternKeyCompare`). //! - Targets may be strings, arrays (first entry that resolves wins), or //! conditional objects. Conditions are matched in the fixed **priority -//! order** of [`DEFAULT_CONDITIONS`] +//! order** of [`default_conditions`] //! (`perry`/`node`/`import`/`module`/`default`/`require`) — the same model //! (and the same `node`-above-`default` ranking) as perry's package-`exports` //! resolver `resolve_exports`; the two resolvers must agree (a `{ node, @@ -62,8 +62,9 @@ const NODE_CONDITIONS: &[&str] = &["perry", "node", "import", "module", "default /// must resolve its bun entry or the program runs a different backend than the /// bun binary it is meant to match. `bun` stays BELOW `perry` so an explicit /// perry entry still wins, and above `node` only for this target. -const BUN_CONDITIONS: &[&str] = - &["perry", "bun", "node", "import", "module", "default", "require"]; +const BUN_CONDITIONS: &[&str] = &[ + "perry", "bun", "node", "import", "module", "default", "require", +]; /// Whether this compile targets `--platform bun`. Set once from /// `CompilationContext::bun_platform` before module collection; a compile is a @@ -153,7 +154,7 @@ impl std::error::Error for SubpathImportError {} /// /// `importer_path` is the file containing the import; the package scope is /// found by walking up from its directory. `conditions` is the active -/// condition set (callers normally pass [`DEFAULT_CONDITIONS`]). +/// condition set (callers normally pass [`default_conditions`]). pub(crate) fn resolve_subpath_import( specifier: &str, importer_path: &Path, @@ -333,7 +334,7 @@ fn resolve_target( } // Conditional object: try the active conditions in priority order // (the same model as `resolve_exports_with_conditions` — see the - // [`DEFAULT_CONDITIONS`] doc for why key order is not used). A branch + // [`default_conditions`] doc for why key order is not used). A branch // that fails to resolve falls through to the next condition. serde_json::Value::Object(map) => { for cond in conditions { @@ -530,8 +531,9 @@ mod tests { r##"{ "#config": "./src/config.ts" }"##, &["src/config.ts"], ); - let resolved = - expect_file(resolve_subpath_import("#config", &importer, DEFAULT_CONDITIONS).unwrap()); + let resolved = expect_file( + resolve_subpath_import("#config", &importer, default_conditions()).unwrap(), + ); assert!(resolved.ends_with("src/config.ts")); } @@ -544,8 +546,9 @@ mod tests { r##"{ "#lib/*": "./src/lib/*" }"##, &["src/lib/foo.ts"], ); - let resolved = - expect_file(resolve_subpath_import("#lib/foo", &importer, DEFAULT_CONDITIONS).unwrap()); + let resolved = expect_file( + resolve_subpath_import("#lib/foo", &importer, default_conditions()).unwrap(), + ); assert!(resolved.ends_with("src/lib/foo.ts")); } @@ -558,7 +561,7 @@ mod tests { &["src/lib/deep/util.ts", "src/deep/util.ts"], ); let resolved = expect_file( - resolve_subpath_import("#lib/deep/util", &importer, DEFAULT_CONDITIONS).unwrap(), + resolve_subpath_import("#lib/deep/util", &importer, default_conditions()).unwrap(), ); // `#lib/deep/*` (longer non-wildcard prefix) must beat `#lib/*`. assert!(resolved.ends_with("src/deep/util.ts"), "{resolved:?}"); @@ -573,7 +576,7 @@ mod tests { &["src/special.ts", "src/lib/special.ts"], ); let resolved = expect_file( - resolve_subpath_import("#lib/special", &importer, DEFAULT_CONDITIONS).unwrap(), + resolve_subpath_import("#lib/special", &importer, default_conditions()).unwrap(), ); assert!(resolved.ends_with("src/special.ts"), "{resolved:?}"); } @@ -588,7 +591,7 @@ mod tests { ); // `*` captures `helper`; the `.js` target probes the `.ts` source. let resolved = expect_file( - resolve_subpath_import("#internal/helper.js", &importer, DEFAULT_CONDITIONS).unwrap(), + resolve_subpath_import("#internal/helper.js", &importer, default_conditions()).unwrap(), ); assert!(resolved.ends_with("src/internal/helper.ts"), "{resolved:?}"); } @@ -602,7 +605,7 @@ mod tests { &["src/env.node.ts", "src/env.default.ts"], ); let resolved = - expect_file(resolve_subpath_import("#env", &importer, DEFAULT_CONDITIONS).unwrap()); + expect_file(resolve_subpath_import("#env", &importer, default_conditions()).unwrap()); assert!(resolved.ends_with("src/env.node.ts"), "{resolved:?}"); } @@ -616,7 +619,7 @@ mod tests { ); // `browser` is not in the active condition set → `default` wins. let resolved = - expect_file(resolve_subpath_import("#env", &importer, DEFAULT_CONDITIONS).unwrap()); + expect_file(resolve_subpath_import("#env", &importer, default_conditions()).unwrap()); assert!(resolved.ends_with("src/env.default.ts"), "{resolved:?}"); } @@ -629,7 +632,7 @@ mod tests { &["src/env.mjs.ts", "src/env.cjs.ts"], ); let resolved = - expect_file(resolve_subpath_import("#env", &importer, DEFAULT_CONDITIONS).unwrap()); + expect_file(resolve_subpath_import("#env", &importer, default_conditions()).unwrap()); assert!(resolved.ends_with("src/env.mjs.ts"), "{resolved:?}"); } @@ -642,7 +645,7 @@ mod tests { &["src/env.ts"], // dist/env.js intentionally absent ); let resolved = - expect_file(resolve_subpath_import("#env", &importer, DEFAULT_CONDITIONS).unwrap()); + expect_file(resolve_subpath_import("#env", &importer, default_conditions()).unwrap()); assert!(resolved.ends_with("src/env.ts"), "{resolved:?}"); } @@ -655,7 +658,7 @@ mod tests { &["src/dep.ts"], ); let resolved = - expect_file(resolve_subpath_import("#dep", &importer, DEFAULT_CONDITIONS).unwrap()); + expect_file(resolve_subpath_import("#dep", &importer, default_conditions()).unwrap()); assert!(resolved.ends_with("src/dep.ts"), "{resolved:?}"); } @@ -663,7 +666,7 @@ mod tests { fn array_of_only_invalid_targets_propagates_error() { let dir = tempfile::tempdir().unwrap(); let importer = fixture(dir.path(), r##"{ "#dep": ["../out.ts", "/abs.ts"] }"##, &[]); - let err = resolve_subpath_import("#dep", &importer, DEFAULT_CONDITIONS).unwrap_err(); + let err = resolve_subpath_import("#dep", &importer, default_conditions()).unwrap_err(); assert!(matches!(err, SubpathImportError::InvalidTarget { .. })); } @@ -671,7 +674,7 @@ mod tests { fn bare_package_target_returns_external() { let dir = tempfile::tempdir().unwrap(); let importer = fixture(dir.path(), r##"{ "#dep": "some-pkg" }"##, &[]); - let outcome = resolve_subpath_import("#dep", &importer, DEFAULT_CONDITIONS).unwrap(); + let outcome = resolve_subpath_import("#dep", &importer, default_conditions()).unwrap(); assert_eq!( outcome, SubpathImportOutcome::External("some-pkg".to_string()) @@ -687,7 +690,7 @@ mod tests { &[], ); let outcome = - resolve_subpath_import("#vendored/util", &importer, DEFAULT_CONDITIONS).unwrap(); + resolve_subpath_import("#vendored/util", &importer, default_conditions()).unwrap(); assert_eq!( outcome, SubpathImportOutcome::External("@scope/vendored/util".to_string()) @@ -702,9 +705,12 @@ mod tests { r##"{ "#vendored/*": "@scope/vendored/*" }"##, &[], ); - let err = - resolve_subpath_import("#vendored/../../etc/passwd", &importer, DEFAULT_CONDITIONS) - .unwrap_err(); + let err = resolve_subpath_import( + "#vendored/../../etc/passwd", + &importer, + default_conditions(), + ) + .unwrap_err(); assert!( matches!(err, SubpathImportError::InvalidSpecifier { .. }), "{err}" @@ -715,7 +721,7 @@ mod tests { fn node_builtin_target_returns_external() { let dir = tempfile::tempdir().unwrap(); let importer = fixture(dir.path(), r##"{ "#fs": "node:fs" }"##, &[]); - let outcome = resolve_subpath_import("#fs", &importer, DEFAULT_CONDITIONS).unwrap(); + let outcome = resolve_subpath_import("#fs", &importer, default_conditions()).unwrap(); assert_eq!( outcome, SubpathImportOutcome::External("node:fs".to_string()) @@ -726,7 +732,7 @@ mod tests { fn parent_dir_target_is_rejected() { let dir = tempfile::tempdir().unwrap(); let importer = fixture(dir.path(), r##"{ "#escape": "../outside.ts" }"##, &[]); - let err = resolve_subpath_import("#escape", &importer, DEFAULT_CONDITIONS).unwrap_err(); + let err = resolve_subpath_import("#escape", &importer, default_conditions()).unwrap_err(); assert!( matches!(err, SubpathImportError::InvalidTarget { .. }), "{err}" @@ -741,7 +747,7 @@ mod tests { r##"{ "#escape": "./src/../../outside.ts" }"##, &[], ); - let err = resolve_subpath_import("#escape", &importer, DEFAULT_CONDITIONS).unwrap_err(); + let err = resolve_subpath_import("#escape", &importer, default_conditions()).unwrap_err(); assert!( matches!(err, SubpathImportError::InvalidTarget { .. }), "{err}" @@ -756,8 +762,9 @@ mod tests { r##"{ "#lib/*": "./src/lib/*" }"##, &["src/lib/foo.ts"], ); - let err = resolve_subpath_import("#lib/../../../etc/passwd", &importer, DEFAULT_CONDITIONS) - .unwrap_err(); + let err = + resolve_subpath_import("#lib/../../../etc/passwd", &importer, default_conditions()) + .unwrap_err(); assert!( matches!(err, SubpathImportError::InvalidSpecifier { .. }), "{err}" @@ -769,7 +776,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let importer = fixture(dir.path(), r##"{ "#lib/*": "./src/lib/*" }"##, &[]); for bad in ["#", "#/", "#/foo", "#lib/"] { - let err = resolve_subpath_import(bad, &importer, DEFAULT_CONDITIONS).unwrap_err(); + let err = resolve_subpath_import(bad, &importer, default_conditions()).unwrap_err(); assert!( matches!(err, SubpathImportError::InvalidSpecifier { .. }), "{bad} should be invalid, got {err}" @@ -792,7 +799,7 @@ mod tests { &["lib/util.ts"], ); let resolved = expect_file( - resolve_subpath_import("#util", &nested_importer, DEFAULT_CONDITIONS).unwrap(), + resolve_subpath_import("#util", &nested_importer, default_conditions()).unwrap(), ); assert!( resolved.ends_with("packages/inner/lib/util.ts"), @@ -812,7 +819,7 @@ mod tests { let importer = sub.join("main.ts"); std::fs::write(&importer, "// importer\n").unwrap(); let resolved = - expect_file(resolve_subpath_import("#util", &importer, DEFAULT_CONDITIONS).unwrap()); + expect_file(resolve_subpath_import("#util", &importer, default_conditions()).unwrap()); assert!(resolved.ends_with("src/util.ts"), "{resolved:?}"); } @@ -825,7 +832,7 @@ mod tests { let importer = root.join("src/main.ts"); std::fs::write(&importer, "// importer\n").unwrap(); assert_eq!( - resolve_subpath_import("#lib/foo", &importer, DEFAULT_CONDITIONS).unwrap(), + resolve_subpath_import("#lib/foo", &importer, default_conditions()).unwrap(), SubpathImportOutcome::NotDefined ); } @@ -839,7 +846,7 @@ mod tests { &["src/lib/foo.ts"], ); assert_eq!( - resolve_subpath_import("#other", &importer, DEFAULT_CONDITIONS).unwrap(), + resolve_subpath_import("#other", &importer, default_conditions()).unwrap(), SubpathImportOutcome::NotDefined ); } @@ -849,7 +856,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let importer = fixture(dir.path(), r##"{ "#blocked": null }"##, &[]); assert_eq!( - resolve_subpath_import("#blocked", &importer, DEFAULT_CONDITIONS).unwrap(), + resolve_subpath_import("#blocked", &importer, default_conditions()).unwrap(), SubpathImportOutcome::NotDefined ); } @@ -867,7 +874,7 @@ mod tests { let importer = dep.join("index.ts"); std::fs::write(&importer, "// importer\n").unwrap(); assert_eq!( - resolve_subpath_import("#util", &importer, DEFAULT_CONDITIONS).unwrap(), + resolve_subpath_import("#util", &importer, default_conditions()).unwrap(), SubpathImportOutcome::NotDefined ); } diff --git a/crates/perry/src/commands/compile/resolve/tests.rs b/crates/perry/src/commands/compile/resolve/tests.rs index 1edbe85023..6de1ea8581 100644 --- a/crates/perry/src/commands/compile/resolve/tests.rs +++ b/crates/perry/src/commands/compile/resolve/tests.rs @@ -1973,3 +1973,7 @@ mod ancestor_node_modules_tests { ); } } + +#[cfg(test)] +#[path = "tests/bun_export_condition_tests.rs"] +mod bun_export_condition_tests; diff --git a/crates/perry/src/commands/compile/resolve/tests/bun_export_condition_tests.rs b/crates/perry/src/commands/compile/resolve/tests/bun_export_condition_tests.rs new file mode 100644 index 0000000000..39f934aa7a --- /dev/null +++ b/crates/perry/src/commands/compile/resolve/tests/bun_export_condition_tests.rs @@ -0,0 +1,83 @@ +//! Issue #10281 regression: `--platform bun` export-condition selection. +//! +//! Split out of `resolve/tests.rs` to keep that file under the 2000-line +//! repository limit. + +/// Issue #10281 — `--platform bun` must select a package's `bun` export +/// condition. Perry's condition list had no `bun` entry and ranked `node` +/// first, so a package shipping both compiled its node build even when the +/// target was bun. `@opentui/core` is the shape that surfaced it: its two +/// entries load different renderer backends, so OpenCode's terminal interface +/// ran the wrong one and threw. +/// +/// The two directions live in ONE test on purpose: the platform is a +/// process-wide flag (a compile is one process with one target), so separate +/// `#[test]` functions would race under the default parallel test harness. +/// The flag is restored before returning. +use super::super::resolve_exports_candidates; +use super::super::subpath_imports::{default_conditions, set_bun_platform}; + +fn opentui_core_exports() -> serde_json::Value { + serde_json::json!({ + ".": { + "types": "./index.d.ts", + "bun": "./index.bun.js", + "node": "./index.node.js", + "import": "./index.node.js" + } + }) +} + +#[test] +fn bun_platform_prefers_the_bun_entry_and_node_target_is_unchanged() { + // Default (node target): the node entry wins, exactly as before. + set_bun_platform(false); + assert_eq!( + default_conditions().first().copied(), + Some("perry"), + "an explicit perry entry must keep winning on either target" + ); + assert!( + !default_conditions().contains(&"bun"), + "the node target must not consider the bun condition" + ); + let node_first = resolve_exports_candidates(&opentui_core_exports(), "."); + assert_eq!( + node_first.first().map(String::as_str), + Some("./index.node.js"), + "node target must resolve the node entry; got {node_first:?}" + ); + + // `--platform bun`: the bun entry wins, and `perry` still outranks it. + set_bun_platform(true); + let conditions = default_conditions(); + assert_eq!( + (conditions.first().copied(), conditions.get(1).copied()), + (Some("perry"), Some("bun")), + "bun must rank directly after perry, above node; got {conditions:?}" + ); + let bun_first = resolve_exports_candidates(&opentui_core_exports(), "."); + assert_eq!( + bun_first.first().map(String::as_str), + Some("./index.bun.js"), + "bun target must resolve the bun entry; got {bun_first:?}" + ); + // The node entry stays available as a fallback for the disk-existence + // walk in `resolve_package_entry`, it is only no longer first. + assert!( + bun_first.iter().any(|c| c == "./index.node.js"), + "the node entry must remain a candidate; got {bun_first:?}" + ); + + // A package with no `bun` entry is unaffected by the target. + let plain = serde_json::json!({ ".": { "node": "./n.js", "default": "./d.js" } }); + assert_eq!( + resolve_exports_candidates(&plain, ".") + .first() + .map(String::as_str), + Some("./n.js"), + "a package without a bun entry must resolve identically on either target" + ); + + set_bun_platform(false); +}