diff --git a/.github/workflows/CLAUDE.md b/.github/workflows/CLAUDE.md index b4eb9eb94472..748c055cb10a 100644 --- a/.github/workflows/CLAUDE.md +++ b/.github/workflows/CLAUDE.md @@ -116,16 +116,18 @@ export LLVM_VERSION_MAJOR=19 ## rust-lints.yml Workflow -Four independent jobs that each run one cargo command over the Rust workspace. They share `.github/actions/rust-lint-setup`, a composite action that installs LLVM from apt.llvm.org (configure resolves a clang even though nothing here compiles C++), Bun, optionally a pinned Rust toolchain plus components, runs `bun install`, then `bun scripts/build.ts --configure-only` and the ninja targets a job asks for: `clone-lolhtml` (cargo cannot resolve the workspace until the vendored `lol_html` path dependency exists) and, for jobs that check `bun_runtime`/`bun_jsc`/`bun_core`, `codegen` (their `include!()`d sources under `build/debug/codegen`). +Five independent jobs that each run one cargo command over the Rust workspace. They share `.github/actions/rust-lint-setup`, a composite action that installs LLVM from apt.llvm.org (configure resolves a clang even though nothing here compiles C++), Bun, optionally a pinned Rust toolchain plus components, runs `bun install`, then `bun scripts/build.ts --configure-only` and the ninja targets a job asks for: `clone-lolhtml` (cargo cannot resolve the workspace until the vendored `lol_html` path dependency exists) and, for jobs that check `bun_runtime`/`bun_jsc`/`bun_core`, `codegen` (their `include!()`d sources under `build/debug/codegen`). | Job | Check name | Runs | Blocking | | --------- | --------------------- | -------------------------------------------- | ------------------------------ | | `clippy` | `cargo clippy` | `bun run rust:clippy` | yes | | `miri` | `cargo miri test` | `bun run rust:miri` (`scripts/rust-miri.ts`) | yes | +| `test` | `cargo test` | `bun run rust:test` (`scripts/rust-test.ts`) | yes | | `lolhtml` | `lol-html cargo test` | `cargo test` in `vendor/lolhtml` | yes | | `mordant` | `mordant` | `cargo dylint --all --workspace` | advisory (`continue-on-error`) | -- `clippy`, `miri` and `lolhtml` pin `RUSTUP_TOOLCHAIN` at the workflow level (kept in sync with `channel` in `rust-toolchain.toml`) so rustup does not install that file's cross-target list; the action installs the toolchain with `--profile minimal` plus the components the job names (`clippy`, `miri rust-src`, none). +- `clippy`, `miri`, `test` and `lolhtml` pin `RUSTUP_TOOLCHAIN` at the workflow level (kept in sync with `channel` in `rust-toolchain.toml`) so rustup does not install that file's cross-target list; the action installs the toolchain with `--profile minimal` plus the components the job names (`clippy`, `miri rust-src`, none, none). +- `test` runs a native `cargo test --locked -p ` for each crate listed in `scripts/rust-test.ts`. A test binary links only the crate's Rust dependencies, so this is what catches a test reaching one of the externs that exist only in the full bun link (Miri never links). `scripts/rust-workspace.ts` holds the configure prerequisite check `rust-miri.ts` and `rust-test.ts` share; `test/internal/rust-native-cargo-test.test.ts` runs the same script under `bun bd test`. - `lolhtml` exists because the vendored lol-html is a fork (oven-sh/lol-html, `bun` branch) whose own test suite is the only thing guarding the fork's invariants. It used to trigger only on `scripts/build/deps/lolhtml.ts`; it now shares the workflow's wider path filter. - `mordant` runs the [mordant](https://github.com/scarletindustries/mordant) dylint pack. It sets `RUSTUP_TOOLCHAIN: stable` instead: mordant is built with, and lints us using, the nightly named in its own rust-toolchain file, which dylint fetches on demand, so the outer cargo only needs to exist. Because that nightly is older than ours, the job passes `-A unknown_lints` through `DYLINT_RUSTFLAGS`. Two caches cover the slow parts: `~/.cargo/bin/{cargo-dylint,dylint-link}` keyed on `DYLINT_VERSION`, and `~/.dylint_drivers` + `target/dylint/libraries` keyed on `DYLINT_VERSION` plus the pinned mordant rev read out of `Cargo.toml`. It is skipped on `merge_group`. diff --git a/.github/workflows/rust-lints.yml b/.github/workflows/rust-lints.yml index 5643bf8d12f5..c55a3d79597b 100644 --- a/.github/workflows/rust-lints.yml +++ b/.github/workflows/rust-lints.yml @@ -14,6 +14,8 @@ on: - "scripts/build/**" - "scripts/build.ts" - "scripts/rust-miri.ts" + - "scripts/rust-test.ts" + - "scripts/rust-workspace.ts" - "package.json" - "Cargo.toml" - "Cargo.lock" @@ -85,6 +87,31 @@ jobs: BUN_CODEGEN_DIR: ${{ github.workspace }}/build/debug/codegen run: bun run rust:miri + test: + # The thing Miri cannot check: a crate's test binary links only its Rust + # dependencies, so a test that reaches one of the externs only the full bun + # link provides fails here (scripts/rust-test.ts). + name: cargo test + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup + uses: ./.github/actions/rust-lint-setup + with: + bun-version: ${{ env.BUN_VERSION }} + llvm-version: ${{ env.LLVM_VERSION_MAJOR }} + toolchain: ${{ env.RUSTUP_TOOLCHAIN }} + + - name: cargo test + env: + BUN_CODEGEN_DIR: ${{ github.workspace }}/build/debug/codegen + run: bun run rust:test + lolhtml: # The vendored lol-html is a fork (oven-sh/lol-html, `bun` branch) carrying # content-handler suspension, and its own test suite is the only thing that diff --git a/Cargo.lock b/Cargo.lock index 7b5654882f96..438c105ee3a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -449,6 +449,7 @@ dependencies = [ "bstr", "bun_clap_macros", "bun_core", + "bun_highway", "const_format", "enum-map", "enumset", diff --git a/package.json b/package.json index e39896d89b99..2afb96834dda 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "rust:mordant": "DYLINT_RUSTFLAGS='-A unknown_lints' cargo dylint --all --workspace -- --keep-going", "rust:mordant:baseline": "DYLINT_RUSTFLAGS='-A unknown_lints' MORDANT_BASELINE_WRITE=1 cargo dylint --all --workspace", "rust:miri": "bun scripts/rust-miri.ts", + "rust:test": "bun scripts/rust-test.ts", "rust:timings": "bun scripts/rust-timings.ts", "codegen:string-maps": "for f in src/**/*.string-map.ts; do bun src/codegen/generate-string-map.ts \"$f\" \"${f%.string-map.ts}.generated.rs\"; done", "codegen:verify": "bun run codegen:string-maps && git diff --exit-code 'src/**/*.generated.rs' || (echo '\\n*.generated.rs is stale — run `bun run codegen:string-maps` and commit the result.' >&2; exit 1)", diff --git a/scripts/rust-miri.ts b/scripts/rust-miri.ts index 09d5892297b3..3d970094f4a9 100644 --- a/scripts/rust-miri.ts +++ b/scripts/rust-miri.ts @@ -16,17 +16,17 @@ * is the candidate replacement spec, allows that pattern, and still catches * the bugs we care about. * + * Miri never links, so it says nothing about whether these crates' test + * binaries link natively; `rust-test.ts` covers that. + * * Usage: * bun run rust:miri # default crate set, crates run concurrently * bun run rust:miri -p bun_foo # extra args go straight to one `cargo miri test` */ -import { spawn, spawnSync } from "node:child_process"; -import { existsSync } from "node:fs"; +import { spawn } from "node:child_process"; import { availableParallelism } from "node:os"; -import { resolve } from "node:path"; - -const repo = resolve(import.meta.dirname, ".."); +import { ensureCargoWorkspace, repo, run } from "./rust-workspace.ts"; // Crates that pass `cargo miri test` under Tree Borrows. To add one it must // (a) have at least one `#[test]`, (b) compile under `--cfg test`, (c) at test @@ -54,34 +54,7 @@ const MIRI_CRATES = [ "bun_wyhash", ]; -function run(cmd: string, args: string[], opts: Parameters[2] = {}) { - return spawnSync(cmd, args, { stdio: "inherit", cwd: repo, ...opts }); -} - -// `bun_core/build.rs` needs `build_options.rs`; cargo can't resolve the -// workspace until `vendor/lolhtml/` (a path dep) exists. Both come from the -// configure step, which is a no-op when already done. -const buildOptionsRs = resolve(repo, "build/debug/codegen/build_options.rs"); -const lolhtmlCargo = resolve(repo, "vendor/lolhtml/Cargo.toml"); -if (!existsSync(buildOptionsRs) || !existsSync(lolhtmlCargo)) { - console.log("\x1b[36m[setup]\x1b[0m bun run build --configure-only"); - if (run("bun", ["run", "build", "--configure-only"]).status !== 0) process.exit(1); - if (!existsSync(lolhtmlCargo) && run("ninja", ["-C", "build/debug", "clone-lolhtml"]).status !== 0) { - process.exit(1); - } - // Re-check: configure can succeed without producing these (e.g. partial - // checkout, ninja target rename) — fail fast instead of letting cargo - // produce a confusing workspace-resolution error. - for (const [path, hint] of [ - [buildOptionsRs, "bun run build --configure-only"], - [lolhtmlCargo, "ninja -C build/debug clone-lolhtml"], - ] as const) { - if (!existsSync(path)) { - console.error(`\x1b[31m[error]\x1b[0m ${path} still missing after setup — try: ${hint}`); - process.exit(1); - } - } -} +ensureCargoWorkspace(); const env = { ...process.env, diff --git a/scripts/rust-test.ts b/scripts/rust-test.ts new file mode 100644 index 000000000000..dc56ae41b3c7 --- /dev/null +++ b/scripts/rust-test.ts @@ -0,0 +1,78 @@ +#!/usr/bin/env bun +/** + * Native `cargo test` for the crates whose unit tests are meant to run on + * their own, outside the bun build. + * + * A crate's test binary links its Rust dependencies and nothing else; the C/C++ + * behind the workspace's externs (highway kernels, simdutf, mimalloc, the + * OutputSink interface implemented in bun_sys, ...) exists only in the full bun + * link. A test that reaches one of them fails to link with an `undefined + * symbol`, and nothing else notices: the Miri lane (`rust-miri.ts`) never links + * and runs bun_highway's scalar paths, and `cargo check --tests` stops before + * the linker. A crate whose tests need the byte-search kernels enables + * bun_highway's `scalar` feature from its `[dev-dependencies]` (see + * src/clap/Cargo.toml); anything else has to be stubbed in the crate, or stays + * a link error naming the symbol. + * + * One cargo invocation per crate on purpose: cargo unifies features within an + * invocation, so `cargo test -p a -p b` would build bun_highway with `scalar` + * for both as soon as either asks for it, and a crate missing its own + * `[dev-dependencies]` entry would pass here yet fail for whoever runs + * `cargo test -p ` alone. Shared dependencies are still built once per + * feature set, not once per crate. + * + * Usage: + * bun run rust:test # every crate below + * bun run rust:test -p bun_foo # extra args go straight to cargo test + */ + +import { ensureCargoWorkspace, run } from "./rust-workspace.ts"; + +// Crates whose `cargo test --locked -p ` links and passes on a +// configured checkout. Of the Miri set, bun_ast (its tests reach mimalloc) and +// bun_ptr (highway kernels and the OutputSink interface) do not link yet, and +// bun_collections' pool tests count drops that its thread-local pools perform +// at thread exit, so they race once libtest actually runs them in parallel. +const NATIVE_TEST_CRATES = [ + "bun_base64", + "bun_clap", + "bun_dispatch", + "bun_errno", + "bun_hash", + "bun_http_types", + "bun_md", + "bun_paths", + "bun_resolve_builtins", + "bun_shell_parser", + "bun_threading", + "bun_wyhash", +]; + +ensureCargoWorkspace(); + +// `scalar` makes every byte search scalar, so it must only ever reach test +// binaries. Cargo does not activate [dev-dependencies] when building bun_bin; +// a `[dependencies]` entry asking for the feature would, silently. +const treeArgs = ["tree", "--locked", "-p", "bun_bin", "-i", "bun_highway", "-e", "features"]; +console.log(`\x1b[36m[test]\x1b[0m cargo ${treeArgs.join(" ")}`); +const tree = run("cargo", treeArgs, { stdio: ["ignore", "pipe", "inherit"] }); +if (tree.status !== 0) process.exit(tree.status ?? 1); +if (tree.stdout.toString().includes('feature "scalar"')) { + console.error(tree.stdout.toString()); + console.error("\x1b[31m[error]\x1b[0m bun_highway's `scalar` feature is enabled in bun_bin's dependency graph"); + process.exit(1); +} + +const extraArgs = process.argv.slice(2); +const invocations = extraArgs.length > 0 ? [extraArgs] : NATIVE_TEST_CRATES.map(crate => ["-p", crate]); + +const failed: string[] = []; +for (const args of invocations) { + console.log(`\x1b[36m[test]\x1b[0m cargo test --locked ${args.join(" ")}`); + if (run("cargo", ["test", "--locked", ...args]).status !== 0) failed.push(args.join(" ")); +} + +if (failed.length > 0) { + console.error(`\x1b[31m[error]\x1b[0m cargo test failed for: ${failed.join(", ")}`); + process.exit(1); +} diff --git a/scripts/rust-workspace.ts b/scripts/rust-workspace.ts new file mode 100644 index 000000000000..e4abaa897cc1 --- /dev/null +++ b/scripts/rust-workspace.ts @@ -0,0 +1,48 @@ +/** + * Shared setup for the scripts that run cargo on individual crates + * (`rust-miri.ts`, `rust-test.ts`). + * + * `bun_core/build.rs` needs `build_options.rs`, and cargo can't resolve the + * workspace at all until `vendor/lolhtml/` (a path dep) exists. Both come from + * the configure step, which is a no-op when already done. + */ + +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; + +export const repo = resolve(import.meta.dirname, ".."); + +const buildOptionsRs = resolve(repo, "build/debug/codegen/build_options.rs"); +const lolhtmlCargo = resolve(repo, "vendor/lolhtml/Cargo.toml"); + +export function cargoWorkspaceConfigured(): boolean { + return existsSync(buildOptionsRs) && existsSync(lolhtmlCargo); +} + +export function run(cmd: string, args: string[], opts: Parameters[2] = {}) { + return spawnSync(cmd, args, { stdio: "inherit", cwd: repo, ...opts }); +} + +/** Configures the tree if needed; exits the process when that does not produce what cargo needs. */ +export function ensureCargoWorkspace(): void { + if (cargoWorkspaceConfigured()) return; + + console.log("\x1b[36m[setup]\x1b[0m bun run build --configure-only"); + if (run("bun", ["run", "build", "--configure-only"]).status !== 0) process.exit(1); + if (!existsSync(lolhtmlCargo) && run("ninja", ["-C", "build/debug", "clone-lolhtml"]).status !== 0) { + process.exit(1); + } + // Re-check: configure can succeed without producing these (e.g. partial + // checkout, ninja target rename). Fail fast instead of letting cargo + // produce a confusing workspace-resolution error. + for (const [path, hint] of [ + [buildOptionsRs, "bun run build --configure-only"], + [lolhtmlCargo, "ninja -C build/debug clone-lolhtml"], + ] as const) { + if (!existsSync(path)) { + console.error(`\x1b[31m[error]\x1b[0m ${path} still missing after setup; try: ${hint}`); + process.exit(1); + } + } +} diff --git a/src/clap/Cargo.toml b/src/clap/Cargo.toml index 4673e2bc7e00..5999b61d2143 100644 --- a/src/clap/Cargo.toml +++ b/src/clap/Cargo.toml @@ -21,3 +21,9 @@ bitflags.workspace = true thiserror.workspace = true bun_clap_macros.workspace = true bun_core.workspace = true + +[dev-dependencies] +# The streaming tests reach `bun_core::strings` (`StreamingClap::normal`, +# `test_err`); without this the test binary fails to link against the highway +# kernels (`bun run rust:test`). +bun_highway = { workspace = true, features = ["scalar"] } diff --git a/src/clap/streaming.rs b/src/clap/streaming.rs index d7f59518e819..ecf38d165c91 100644 --- a/src/clap/streaming.rs +++ b/src/clap/streaming.rs @@ -432,10 +432,8 @@ mod tests { &diag.arg }; let quoted = [b"'".as_slice(), captured, b"'"].concat(); - // Naive search: `cargo test -p bun_clap` does not link the - // highway kernels behind `bun_core::strings::contains`. assert!( - (0..expected.len()).any(|i| expected[i..].starts_with("ed)), + strings::contains(expected, "ed), "expected message {:?} does not name captured arg {:?}", bstr::BStr::new(expected), bstr::BStr::new(captured), diff --git a/src/highway/Cargo.toml b/src/highway/Cargo.toml index 79f44fb36d5b..5da887e5ced9 100644 --- a/src/highway/Cargo.toml +++ b/src/highway/Cargo.toml @@ -6,6 +6,14 @@ edition.workspace = true [lib] path = "lib.rs" +[features] +# Take the scalar paths the search wrappers use under Miri at every length, so +# nothing references the highway_* C++ kernels, which only exist in the full bun +# link. For a crate whose standalone `cargo test` binary reaches a search +# wrapper: enable this from that crate's [dev-dependencies]. Never enabled in +# the bun build itself. +scalar = [] + [lints] workspace = true diff --git a/src/highway/lib.rs b/src/highway/lib.rs index 95eb419fd7f6..5000e6547d70 100644 --- a/src/highway/lib.rs +++ b/src/highway/lib.rs @@ -10,7 +10,7 @@ unsafe extern "C" { fn highway_count_char(haystack: *const u8, haystack_len: usize, needle: u8) -> usize; - #[cfg(not(miri))] + #[cfg(not(any(miri, feature = "scalar")))] fn highway_memmem( haystack: *const u8, haystack_len: usize, @@ -20,7 +20,7 @@ unsafe extern "C" { // These three return `usize::MAX` for not-found (the empty needle matches at // 0 / `haystack_len` respectively). - #[cfg(not(miri))] + #[cfg(not(any(miri, feature = "scalar")))] fn highway_memrmem( haystack: *const u8, haystack_len: usize, @@ -28,7 +28,7 @@ unsafe extern "C" { needle_len: usize, ) -> usize; - #[cfg(not(miri))] + #[cfg(not(any(miri, feature = "scalar")))] fn highway_memmem16( haystack: *const u16, haystack_len: usize, @@ -36,7 +36,7 @@ unsafe extern "C" { needle_len: usize, ) -> usize; - #[cfg(not(miri))] + #[cfg(not(any(miri, feature = "scalar")))] fn highway_memrmem16( haystack: *const u16, haystack_len: usize, @@ -177,25 +177,24 @@ unsafe extern "C" { /// call into a caller's hot loop (see `pop_last_segment_t` in node/path.rs). const SCALAR_CUTOFF: usize = 16; -/// Miri cannot call foreign functions, and the workspace denies std's search -/// methods everywhere else, so under Miri (`bun run rust:miri`) the search -/// wrappers below take their scalar path at every length. Kernels with no -/// scalar form here (hashing, hex, sourcemaps, lexer scans) stay FFI-only: -/// reaching one from a Miri-tested crate is a loud, immediate error. +/// Miri cannot call foreign functions, and the `scalar` feature (see Cargo.toml) +/// is for binaries that have nothing to link the kernels from; in both, the +/// search wrappers below take their scalar path at every length. Kernels with +/// no scalar form here (hashing, hex, sourcemaps, lexer scans) stay FFI-only. #[inline(always)] fn scalar_only(len: usize) -> bool { - cfg!(miri) || len < SCALAR_CUTOFF + cfg!(any(miri, feature = "scalar")) || len < SCALAR_CUTOFF } -/// Scalar substring search for Miri. Callers have already handled the empty -/// needle and `haystack.len() < needle.len()`. -#[cfg(miri)] +/// Scalar substring search for `scalar_only`'s two configurations. Callers +/// have already handled the empty needle and `haystack.len() < needle.len()`. +#[cfg(any(miri, feature = "scalar"))] fn scalar_memmem(haystack: &[T], needle: &[T]) -> Option { (0..=haystack.len() - needle.len()).find(|&i| haystack[i..i + needle.len()] == *needle) } /// Reverse [`scalar_memmem`]: start index of the last occurrence. -#[cfg(miri)] +#[cfg(any(miri, feature = "scalar"))] fn scalar_memrmem(haystack: &[T], needle: &[T]) -> Option { (0..=haystack.len() - needle.len()) .rev() @@ -213,7 +212,7 @@ fn found_at(result: usize, haystack_len: usize) -> Option { } /// The `mem*mem*` kernels return `usize::MAX` for "not found". -#[cfg(not(miri))] +#[cfg(not(any(miri, feature = "scalar")))] #[inline(always)] fn match_at(result: usize) -> Option { if result == usize::MAX { @@ -278,11 +277,11 @@ pub fn memmem(haystack: &[u8], needle: &[u8]) -> Option { if haystack.len() < needle.len() { return None; } - #[cfg(miri)] + #[cfg(any(miri, feature = "scalar"))] { scalar_memmem(haystack, needle) } - #[cfg(not(miri))] + #[cfg(not(any(miri, feature = "scalar")))] { // SAFETY: both (ptr,len) pairs are valid readable ranges. let p = unsafe { @@ -312,11 +311,11 @@ pub fn memrmem(haystack: &[u8], needle: &[u8]) -> Option { if haystack.len() < needle.len() { return None; } - #[cfg(miri)] + #[cfg(any(miri, feature = "scalar"))] { scalar_memrmem(haystack, needle) } - #[cfg(not(miri))] + #[cfg(not(any(miri, feature = "scalar")))] { // SAFETY: both (ptr,len) pairs are valid readable ranges. let result = unsafe { @@ -341,11 +340,11 @@ pub fn memmem16(haystack: &[u16], needle: &[u16]) -> Option { if haystack.len() < needle.len() { return None; } - #[cfg(miri)] + #[cfg(any(miri, feature = "scalar"))] { scalar_memmem(haystack, needle) } - #[cfg(not(miri))] + #[cfg(not(any(miri, feature = "scalar")))] { // SAFETY: both (ptr,len) pairs are valid readable ranges (`&[u16]` is 2-byte aligned). let result = unsafe { @@ -372,11 +371,11 @@ pub fn memrmem16(haystack: &[u16], needle: &[u16]) -> Option { if haystack.len() < needle.len() { return None; } - #[cfg(miri)] + #[cfg(any(miri, feature = "scalar"))] { scalar_memrmem(haystack, needle) } - #[cfg(not(miri))] + #[cfg(not(any(miri, feature = "scalar")))] { // SAFETY: both (ptr,len) pairs are valid readable ranges (`&[u16]` is 2-byte aligned). let result = unsafe { diff --git a/test/internal/rust-native-cargo-test.test.ts b/test/internal/rust-native-cargo-test.test.ts new file mode 100644 index 000000000000..49425d310d78 --- /dev/null +++ b/test/internal/rust-native-cargo-test.test.ts @@ -0,0 +1,40 @@ +// scripts/rust-test.ts runs `cargo test -p ` natively for the crates +// listed there. A crate's test binary links only its Rust dependencies, so a +// test reaching one of the externs that exist solely in the full bun link +// (bun_clap's streaming tests reach the highway byte-search kernels through +// bun_core::strings, which is what bun_highway's `scalar` feature in +// src/clap/Cargo.toml is for) fails with +// ld.lld: error: undefined symbol: highway_index_of_char +// The `cargo test` job in .github/workflows/rust-lints.yml runs the same +// script in CI; this is the local and debug-build counterpart, so that a +// broken crate shows up in `bun bd test` as well. +// +// Needs cargo and a configured checkout (cargo cannot resolve the workspace +// without vendor/lolhtml, and bun_core/build.rs needs build_options.rs); the +// test-only CI lanes have neither and skip. Skipped on Windows too: link.exe +// also rejects references from code the tests never run, so every bun_core +// dependent's test binary fails to link there regardless of what this script +// checks (#37575 covers that side). Cold, the script first compiles bun_core +// and the other shared dependencies, hence the explicit ceiling. +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, isWindows } from "harness"; +import { join } from "node:path"; +import { cargoWorkspaceConfigured, repo } from "../../scripts/rust-workspace.ts"; + +test.skipIf(isWindows || !Bun.which("cargo") || !cargoWorkspaceConfigured())( + "the crates in scripts/rust-test.ts link and pass under a native cargo test", + async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), join(repo, "scripts", "rust-test.ts")], + cwd: repo, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).not.toContain("undefined symbol"); + expect({ stdout, stderr, exitCode }).toMatchObject({ exitCode: 0 }); + }, + 600_000, +);