diff --git a/AGENTS.md b/AGENTS.md index e4e2089..2f88aa8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,20 @@ nix develop -c cargo build --manifest-path userland/Cargo.toml --release `nix flake check` checks the packaged host, its bundle, and sandbox-compatible tests. +## Tests + +Host integration tests share one executable: + +```sh +cargo test -p sausage --test it +cargo test -p sausage --test it -- chain:: +``` + +Use the required Nix environment and build slot wrapper for these commands. +Add test modules under `host/tests/it/` and declare them in `it/main.rs`. +Use fully qualified child test filters with `--exact`. +See `host/tests/README.md` for fixture and isolation details. + Tests must stay fast: - Only a few tests may wait for a timing (grace periods, deadlines). diff --git a/host/Cargo.toml b/host/Cargo.toml index 6625717..cd5e224 100644 --- a/host/Cargo.toml +++ b/host/Cargo.toml @@ -3,6 +3,11 @@ name = "sausage" version = "0.3.0" edition = "2024" description = "A world of WASI programs and durable agents." +autotests = false + +[[test]] +name = "it" +path = "tests/it/main.rs" [[bin]] name = "sausage" diff --git a/host/src/world.rs b/host/src/world.rs index 1fc5067..a7d4538 100644 --- a/host/src/world.rs +++ b/host/src/world.rs @@ -3,7 +3,7 @@ //! Layout of a world directory: //! - `world/`: the virtual root `/` that every guest sees. Agents change everything in it. //! - `host/`: host state outside the sandbox (harness storage, later the vault). No guest sees it. -use sausage_vfs::{Generated, Host, Layer, Memory, Mounts, Process}; +use sausage_vfs::{Device, Generated, Host, Layer, Memory, Mounts, Process}; use std::{ os::unix::fs::PermissionsExt, path::{Path, PathBuf}, @@ -49,6 +49,9 @@ impl World { } /// Copy an optional bundle into a new world. Existing worlds retain their programs. + /// Each `.wasm` tool keeps its independent file and gains a relative `/bin/` symlink. + /// `/dev` exposes generated null, zero, and random byte devices in every world. + /// Missing `/etc/gitconfig` defaults to Sausage . Local repository config overrides it. pub fn open_with_userland(dir: &Path, userland: Option<&Path>) -> anyhow::Result { let root_dir = dir.join("world"); let host_dir = dir.join("host"); @@ -58,6 +61,24 @@ impl World { for folder in ["bin", "src"] { copy_tree(&bundle.join(folder), &root_dir.join(folder))?; } + // Keep independent .wasm files and expose conventional executable names. + for entry in std::fs::read_dir(root_dir.join("bin"))? { + let path = entry?.path(); + if path.extension().is_some_and(|ext| ext == "wasm") { + let alias = path.with_extension(""); + if std::fs::symlink_metadata(&alias).is_err() { + std::os::unix::fs::symlink(path.file_name().unwrap(), alias)?; + } + } + } + } + let gitconfig = root_dir.join("etc/gitconfig"); + if !gitconfig.try_exists()? { + std::fs::create_dir_all(root_dir.join("etc"))?; + std::fs::write( + gitconfig, + "[user]\n\tname = Sausage\n\temail = sausage@localhost\n", + )?; } std::fs::create_dir_all(&host_dir)?; let mut mounts = Mounts::default(); @@ -90,6 +111,11 @@ impl World { }); mounts.mount("/proc", Arc::new(proc), true); mounts.mount("/run", Arc::new(Memory::default()), false); + let mut devices = Generated::default(); + devices.insert_device("/null", Device::Null); + devices.insert_device("/zero", Device::Zero); + devices.insert_device("/urandom", Device::Random); + mounts.mount("/dev", Arc::new(devices), false); let journal = sausage_vfs::journal::Journal::open(&host_dir.join("undo"), Arc::new(mounts))?; let root = journal.layer(sausage_vfs::journal::Tag::process( diff --git a/host/tests/README.md b/host/tests/README.md new file mode 100644 index 0000000..cd0c7db --- /dev/null +++ b/host/tests/README.md @@ -0,0 +1,37 @@ +# Host integration tests + +All host integration tests share the `it` executable. Each module keeps its former file name. + +```sh +cargo test -p sausage --test it +cargo test -p sausage --test it -- chain:: +cargo test -p sausage --test it -- --test-threads=1 +``` + +Use `nix develop -c` and the required build slot wrapper for these commands. +Set `CARGO_TARGET_DIR` to your executor's target directory. + +The journal timing benchmark keeps its 10% threshold but does not run in the default suite. +Run it by hand on an idle machine: + +```sh +cargo test --release -p sausage --test it -- --ignored undo_cost +``` + +Add new modules under `host/tests/it/` and declare them in `it/main.rs`. +Cargo does not discover separate test executables in `host/tests/`. +Fixture sources remain in `builder-fixture/`. +The bundle-copy regression preserves relative symlink targets, including broken links. + +`common.rs` shares the target lookup and the cached userland Git build. +The goal/questions and harness API fixtures copy the complete extension tree through its shared helper. +A shared mutex protects the cwd-init output and the brush checkout during fixture builds. +The cwd-init build runs at most once per process when its output is absent. +The first parallel run reproduced a collision when two compiler processes wrote `init.o`. +Tests use temporary worlds and ephemeral listener ports. +Child processes receive environment variables through `Command`, not through changes to the test process. +SIGKILL parents use fully qualified test filters with `--exact`. + +The `hook::hook_git` and `hook_git` registrations both existed before this move. +Both remain as wrappers around one shared helper to preserve the test count. +The shared `hook_guest` module now builds its fixture once per process. diff --git a/host/tests/api.rs b/host/tests/it/api.rs similarity index 100% rename from host/tests/api.rs rename to host/tests/it/api.rs diff --git a/host/tests/api_auth.rs b/host/tests/it/api_auth.rs similarity index 100% rename from host/tests/api_auth.rs rename to host/tests/it/api_auth.rs diff --git a/host/tests/api_listener.rs b/host/tests/it/api_listener.rs similarity index 100% rename from host/tests/api_listener.rs rename to host/tests/it/api_listener.rs diff --git a/host/tests/automations.rs b/host/tests/it/automations.rs similarity index 93% rename from host/tests/automations.rs rename to host/tests/it/automations.rs index cce5f4b..c01b96d 100644 --- a/host/tests/automations.rs +++ b/host/tests/it/automations.rs @@ -6,7 +6,7 @@ use std::{ process::Command, time::{Duration, Instant}, }; -const BUNDLE: &str = include_str!("../js/automations-test-bundle.js"); +const BUNDLE: &str = include_str!("../../js/automations-test-bundle.js"); fn copy_tree(source: &Path, destination: &Path) -> Result<()> { std::fs::create_dir_all(destination)?; for entry in std::fs::read_dir(source)? { @@ -24,14 +24,7 @@ fn setup(directory: &Path) -> Result<()> { let userland = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland"); let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap(); if !userland.join("cwd-init/init.o").is_file() { - ensure!( - Command::new("nu") - .args(["--no-config-file", "cwd-init/build.nu"]) - .current_dir(&userland) - .status()? - .success(), - "cwd build failed" - ); + crate::common::cwd_init(); } ensure!( Command::new("cargo") @@ -57,11 +50,7 @@ fn setup(directory: &Path) -> Result<()> { let release = target.join("wasm32-wasip2/release"); if !release.join("brush.wasm").is_file() { ensure!( - Command::new("nu") - .arg("brush/build.nu") - .current_dir(&userland) - .status()? - .success(), + crate::common::build_brush()?.success(), "shell build failed" ); } @@ -251,7 +240,7 @@ async fn fire_after_sigkill() -> Result<()> { let directory = tempfile::tempdir()?; setup(directory.path())?; let mut child = Command::new(std::env::current_exe()?) - .args(["--exact", "automations_child", "--nocapture"]) + .args(["--exact", "automations::automations_child", "--nocapture"]) .env("SAUSAGE_AUTOMATIONS_CHILD", directory.path()) .spawn()?; let deadline = Instant::now() + Duration::from_secs(60); diff --git a/host/tests/builder.rs b/host/tests/it/builder.rs similarity index 93% rename from host/tests/builder.rs rename to host/tests/it/builder.rs index 019823a..35be6ce 100644 --- a/host/tests/builder.rs +++ b/host/tests/it/builder.rs @@ -13,13 +13,7 @@ fn programs() -> PathBuf { static PATH: OnceLock = OnceLock::new(); PATH.get_or_init(|| { let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); - assert!( - std::process::Command::new("nu") - .arg(root.join("userland/cwd-init/build.nu")) - .status() - .unwrap() - .success() - ); + crate::common::cwd_init(); assert!( std::process::Command::new("cargo") .args(["build", "--release", "-p", "world-cargo"]) @@ -28,13 +22,7 @@ fn programs() -> PathBuf { .unwrap() .success() ); - assert!( - std::process::Command::new("nu") - .arg(root.join("userland/brush/build.nu")) - .status() - .unwrap() - .success() - ); + assert!(crate::common::build_brush().unwrap().success()); PathBuf::from(std::env::var_os("CARGO_TARGET_DIR").expect("executor target required")) .join("wasm32-wasip2/release") }) @@ -108,23 +96,23 @@ async fn cargo_build_test_and_source_changes() -> anyhow::Result<()> { for (path, bytes) in [ ( "Cargo.toml", - include_bytes!("builder-fixture/Cargo.toml").as_slice(), + include_bytes!("../builder-fixture/Cargo.toml").as_slice(), ), ( "build.rs", - include_bytes!("builder-fixture/build.rs").as_slice(), + include_bytes!("../builder-fixture/build.rs").as_slice(), ), ( "src/main.rs", - include_bytes!("builder-fixture/src/main.rs").as_slice(), + include_bytes!("../builder-fixture/src/main.rs").as_slice(), ), ( "tests/integration.rs", - include_bytes!("builder-fixture/tests/integration.rs").as_slice(), + include_bytes!("../builder-fixture/tests/integration.rs").as_slice(), ), ( "src/add.c", - include_bytes!("builder-fixture/src/add.c").as_slice(), + include_bytes!("../builder-fixture/src/add.c").as_slice(), ), ] { std::fs::write(source.join(path), bytes)?; diff --git a/host/tests/chain.rs b/host/tests/it/chain.rs similarity index 90% rename from host/tests/chain.rs rename to host/tests/it/chain.rs index 5662f3f..e77ed87 100644 --- a/host/tests/chain.rs +++ b/host/tests/it/chain.rs @@ -16,14 +16,7 @@ fn components() -> Result { let userland = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland"); let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap(); if !userland.join("cwd-init/init.o").is_file() { - ensure!( - std::process::Command::new("nu") - .args(["--no-config-file", "cwd-init/build.nu"]) - .current_dir(&userland) - .status()? - .success(), - "cwd fixture build failed" - ); + crate::common::cwd_init(); } ensure!( std::process::Command::new("cargo") @@ -48,11 +41,7 @@ fn components() -> Result { let release = target.join("wasm32-wasip2/release"); if !release.join("brush.wasm").is_file() { ensure!( - std::process::Command::new("nu") - .arg("brush/build.nu") - .current_dir(&userland) - .status()? - .success(), + crate::common::build_brush()?.success(), "shell fixture build failed" ); } @@ -101,7 +90,7 @@ async fn ancestry_reaches_the_real_hook() -> Result<()> { }, runtime.clone(), cred.clone(), - include_str!("../js/chain-test-bundle.js"), + include_str!("../../js/chain-test-bundle.js"), ) .await?; let records: serde_json::Value = @@ -155,7 +144,7 @@ async fn ancestry_reaches_the_real_hook() -> Result<()> { }); let source = format!( "globalThis.chainReady = true; globalThis.chainPort = {port};\n{}", - include_str!("../js/chain-test-bundle.js") + include_str!("../../js/chain-test-bundle.js") ); let result = tokio::time::timeout( Duration::from_secs(90), diff --git a/host/tests/it/common.rs b/host/tests/it/common.rs new file mode 100644 index 0000000..77c57dc --- /dev/null +++ b/host/tests/it/common.rs @@ -0,0 +1,81 @@ +//! Shared fixture copies and builds. A mutex protects cwd-init and the brush checkout. +use std::{ + path::{Path, PathBuf}, + process::{Command, ExitStatus}, + sync::{Mutex, OnceLock}, +}; +pub static BUILD: Mutex<()> = Mutex::new(()); + +pub fn copy_tree(source: &Path, destination: &Path) -> anyhow::Result<()> { + std::fs::create_dir_all(destination)?; + for entry in std::fs::read_dir(source)? { + let entry = entry?; + let target = destination.join(entry.file_name()); + if entry.file_type()?.is_dir() { + copy_tree(&entry.path(), &target)?; + } else { + std::fs::copy(entry.path(), target)?; + } + } + Ok(()) +} + +pub fn target_dir() -> PathBuf { + std::env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| { + Path::new(env!("CARGO_TARGET_TMPDIR")) + .parent() + .unwrap() + .to_owned() + }) +} + +pub fn cwd_init() { + static INIT: OnceLock<()> = OnceLock::new(); + INIT.get_or_init(|| { + let _guard = BUILD.lock().unwrap(); + let userland = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland"); + if !userland.join("cwd-init/init.o").is_file() { + assert!( + Command::new("nu") + .args(["--no-config-file", "cwd-init/build.nu"]) + .current_dir(userland) + .status() + .unwrap() + .success() + ); + } + }); +} + +pub fn build_brush() -> std::io::Result { + let _guard = BUILD.lock().unwrap(); + Command::new("nu") + .args(["--no-config-file", "brush/build.nu"]) + .env("CARGO_TARGET_DIR", target_dir()) + .current_dir(Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland")) + .status() +} + +pub fn build_git() -> &'static Path { + static GIT: OnceLock = OnceLock::new(); + GIT.get_or_init(|| { + let userland = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland"); + let target = target_dir(); + cwd_init(); + assert!( + Command::new("cargo") + .args(["build", "--release", "-p", "git", "--manifest-path"]) + .arg(userland.join("Cargo.toml")) + .args(["--target", "wasm32-wasip2", "--target-dir"]) + .arg(&target) + .args(["-j", "2", "--locked"]) + .current_dir(&userland) + .status() + .unwrap() + .success() + ); + target + }) +} diff --git a/host/tests/core_ext.rs b/host/tests/it/core_ext.rs similarity index 84% rename from host/tests/core_ext.rs rename to host/tests/it/core_ext.rs index 9fff7d7..7cd3f23 100644 --- a/host/tests/core_ext.rs +++ b/host/tests/it/core_ext.rs @@ -4,7 +4,7 @@ use sausage::{ world::World, }; use std::{path::Path, sync::Arc}; -const BUNDLE: &str = include_str!("../js/core-ext-test-bundle.js"); +const BUNDLE: &str = include_str!("../../js/core-ext-test-bundle.js"); struct Author(Arc); #[async_trait::async_trait] impl Exec for Author { @@ -47,28 +47,7 @@ fn write(root: &Path, path: &str, text: &str) -> anyhow::Result<()> { #[tokio::test(flavor = "current_thread")] async fn core_extensions() -> anyhow::Result<()> { let repo = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); - let userland = repo.join("userland"); - let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap(); - if !userland.join("cwd-init/init.o").exists() { - assert!( - std::process::Command::new("nu") - .args(["--no-config-file", "cwd-init/build.nu"]) - .current_dir(&userland) - .status()? - .success() - ); - } - assert!( - std::process::Command::new("cargo") - .args(["build", "--release", "-p", "git", "--manifest-path"]) - .arg(userland.join("Cargo.toml")) - .args(["--target", "wasm32-wasip2", "--target-dir"]) - .arg(target) - .args(["-j", "2", "--locked"]) - .current_dir(&userland) - .status()? - .success() - ); + let target = crate::common::build_git(); let dir = tempfile::tempdir()?; let world = World::open(dir.path())?; let disk = dir.path().join("world"); diff --git a/host/tests/cred.rs b/host/tests/it/cred.rs similarity index 100% rename from host/tests/cred.rs rename to host/tests/it/cred.rs diff --git a/host/tests/cred_accounting.rs b/host/tests/it/cred_accounting.rs similarity index 100% rename from host/tests/cred_accounting.rs rename to host/tests/it/cred_accounting.rs diff --git a/host/tests/cred_authorization.rs b/host/tests/it/cred_authorization.rs similarity index 100% rename from host/tests/cred_authorization.rs rename to host/tests/it/cred_authorization.rs diff --git a/host/tests/cred_challenges.rs b/host/tests/it/cred_challenges.rs similarity index 100% rename from host/tests/cred_challenges.rs rename to host/tests/it/cred_challenges.rs diff --git a/host/tests/cred_core.rs b/host/tests/it/cred_core.rs similarity index 100% rename from host/tests/cred_core.rs rename to host/tests/it/cred_core.rs diff --git a/host/tests/cred_edits.rs b/host/tests/it/cred_edits.rs similarity index 100% rename from host/tests/cred_edits.rs rename to host/tests/it/cred_edits.rs diff --git a/host/tests/cred_entry.rs b/host/tests/it/cred_entry.rs similarity index 100% rename from host/tests/cred_entry.rs rename to host/tests/it/cred_entry.rs diff --git a/host/tests/cred_grants.rs b/host/tests/it/cred_grants.rs similarity index 100% rename from host/tests/cred_grants.rs rename to host/tests/it/cred_grants.rs diff --git a/host/tests/cred_maintenance.rs b/host/tests/it/cred_maintenance.rs similarity index 100% rename from host/tests/cred_maintenance.rs rename to host/tests/it/cred_maintenance.rs diff --git a/host/tests/cred_oauth2.rs b/host/tests/it/cred_oauth2.rs similarity index 100% rename from host/tests/cred_oauth2.rs rename to host/tests/it/cred_oauth2.rs diff --git a/host/tests/cred_policy.rs b/host/tests/it/cred_policy.rs similarity index 100% rename from host/tests/cred_policy.rs rename to host/tests/it/cred_policy.rs diff --git a/host/tests/cred_presentation.rs b/host/tests/it/cred_presentation.rs similarity index 100% rename from host/tests/cred_presentation.rs rename to host/tests/it/cred_presentation.rs diff --git a/host/tests/cred_proofs.rs b/host/tests/it/cred_proofs.rs similarity index 100% rename from host/tests/cred_proofs.rs rename to host/tests/it/cred_proofs.rs diff --git a/host/tests/cred_quickjs.rs b/host/tests/it/cred_quickjs.rs similarity index 100% rename from host/tests/cred_quickjs.rs rename to host/tests/it/cred_quickjs.rs diff --git a/host/tests/cred_quickjs_security.rs b/host/tests/it/cred_quickjs_security.rs similarity index 100% rename from host/tests/cred_quickjs_security.rs rename to host/tests/it/cred_quickjs_security.rs diff --git a/host/tests/cred_regressions.rs b/host/tests/it/cred_regressions.rs similarity index 100% rename from host/tests/cred_regressions.rs rename to host/tests/it/cred_regressions.rs diff --git a/host/tests/cred_replay.rs b/host/tests/it/cred_replay.rs similarity index 100% rename from host/tests/cred_replay.rs rename to host/tests/it/cred_replay.rs diff --git a/host/tests/cred_security.rs b/host/tests/it/cred_security.rs similarity index 100% rename from host/tests/cred_security.rs rename to host/tests/it/cred_security.rs diff --git a/host/tests/cred_sign.rs b/host/tests/it/cred_sign.rs similarity index 100% rename from host/tests/cred_sign.rs rename to host/tests/it/cred_sign.rs diff --git a/host/tests/cred_storage.rs b/host/tests/it/cred_storage.rs similarity index 100% rename from host/tests/cred_storage.rs rename to host/tests/it/cred_storage.rs diff --git a/host/tests/cred_summary.rs b/host/tests/it/cred_summary.rs similarity index 100% rename from host/tests/cred_summary.rs rename to host/tests/it/cred_summary.rs diff --git a/host/tests/cred_webauthn.rs b/host/tests/it/cred_webauthn.rs similarity index 100% rename from host/tests/cred_webauthn.rs rename to host/tests/it/cred_webauthn.rs diff --git a/host/tests/dream.rs b/host/tests/it/dream.rs similarity index 80% rename from host/tests/dream.rs rename to host/tests/it/dream.rs index d3edc2d..617e998 100644 --- a/host/tests/dream.rs +++ b/host/tests/it/dream.rs @@ -1,7 +1,7 @@ //! World-loaded dream built-ins and messages survive a harness reopen. use sausage::{harness::run_module, world::World}; use std::{path::Path, process::Command}; -const BUNDLE: &str = include_str!("../js/dream-test-bundle.js"); +const BUNDLE: &str = include_str!("../../js/dream-test-bundle.js"); fn git(repo: &Path, args: &[&str]) -> anyhow::Result { let output = Command::new("git") .args(args) @@ -36,30 +36,7 @@ fn prepare(dir: &Path) -> anyhow::Result<()> { let disk = dir.join("world"); let repo = disk.join("src/sausage"); let source = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); - let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap(); - let userland = source.join("userland"); - if !userland.join("cwd-init/init.o").exists() { - anyhow::ensure!( - Command::new("nu") - .args(["--no-config-file", "cwd-init/build.nu"]) - .current_dir(&userland) - .status()? - .success(), - "cwd-init build failed" - ); - } - anyhow::ensure!( - Command::new("cargo") - .args(["build", "--release", "-p", "git", "--manifest-path"]) - .arg(userland.join("Cargo.toml")) - .args(["--target", "wasm32-wasip2", "--target-dir"]) - .arg(target) - .args(["-j", "2", "--locked"]) - .current_dir(&userland) - .status()? - .success(), - "world git build failed" - ); + let target = crate::common::build_git(); std::fs::create_dir_all(disk.join("bin"))?; std::fs::copy( target.join("wasm32-wasip2/release/git.wasm"), diff --git a/host/tests/durable.rs b/host/tests/it/durable.rs similarity index 96% rename from host/tests/durable.rs rename to host/tests/it/durable.rs index 7b718b6..47044c9 100644 --- a/host/tests/durable.rs +++ b/host/tests/it/durable.rs @@ -20,7 +20,7 @@ async fn restart_child() -> anyhow::Result<()> { let dir = std::path::Path::new(&dir); let source = format!( "globalThis.expectedState = 'approved';\n{}", - include_str!("../js/durable-test-bundle.js") + include_str!("../../js/durable-test-bundle.js") ); run_module( World::open(dir)?, @@ -59,7 +59,7 @@ fn sigkill_preserves_request_wait() -> anyhow::Result<()> { cred.unlocked()?, )?; let mut child = Command::new(std::env::current_exe()?) - .args(["--exact", "restart_child", "--nocapture"]) + .args(["--exact", "durable::restart_child", "--nocapture"]) .env("DURABLE_RESTART_DIR", &dir) .spawn()?; let deadline = Instant::now() + Duration::from_secs(30); @@ -117,7 +117,7 @@ fn sigkill_preserves_request_wait() -> anyhow::Result<()> { )?; for _ in 0..2 { let mut resumed = Command::new(std::env::current_exe()?) - .args(["--exact", "restart_child", "--nocapture"]) + .args(["--exact", "durable::restart_child", "--nocapture"]) .env("DURABLE_RESTART_DIR", &dir) .spawn()?; let deadline = Instant::now() + Duration::from_secs(30); @@ -147,7 +147,7 @@ async fn proxy_child() -> anyhow::Result<()> { let dir = std::path::Path::new(&dir); let source = format!( "globalThis.proxyOnly = true; const proxy = Deno.core.ops.op_model_proxy(); if (proxy.base_url !== 'https://proxy.invalid' || proxy.api_key !== 'faux-private-token') throw new Error('Wrong proxy item');\n{}", - include_str!("../js/durable-test-bundle.js") + include_str!("../../js/durable-test-bundle.js") ); run_module( World::open(dir)?, @@ -172,7 +172,7 @@ fn proxy_bootstrap_once() -> anyhow::Result<()> { let dir = std::env::temp_dir().join(format!("sausage-durable-proxy-{}", std::process::id())); for start in 0..2 { let status = std::process::Command::new(std::env::current_exe()?) - .args(["--exact", "proxy_child", "--nocapture"]) + .args(["--exact", "durable::proxy_child", "--nocapture"]) .env("DURABLE_PROXY_DIR", &dir) .env( "PI_PROXY_BASE_URL", @@ -259,7 +259,7 @@ async fn decisions_wake_idle_conversation() -> anyhow::Result<()> { )?; let source = format!( "globalThis.expectedState = {state:?};\n{}", - include_str!("../js/durable-test-bundle.js") + include_str!("../../js/durable-test-bundle.js") ); let decide = async { let conversation = loop { diff --git a/host/tests/events.rs b/host/tests/it/events.rs similarity index 80% rename from host/tests/events.rs rename to host/tests/it/events.rs index 490d41d..7d40ff3 100644 --- a/host/tests/events.rs +++ b/host/tests/it/events.rs @@ -5,7 +5,7 @@ use std::{ process::Command, time::{Duration, Instant}, }; -const BUNDLE: &str = include_str!("../js/events-test-bundle.js"); +const BUNDLE: &str = include_str!("../../js/events-test-bundle.js"); struct Author(std::sync::Arc); #[async_trait::async_trait] impl sausage::exec::Exec for Author { @@ -51,7 +51,7 @@ async fn restart_child() -> anyhow::Result<()> { fn sigkill_between_emit_and_reaction() -> anyhow::Result<()> { let dir = tempfile::tempdir()?; let mut child = Command::new(std::env::current_exe()?) - .args(["--exact", "restart_child", "--nocapture"]) + .args(["--exact", "events::restart_child", "--nocapture"]) .env("EVENTS_RESTART_DIR", dir.path()) .env("EVENTS_MODE", "emit") .spawn()?; @@ -68,7 +68,7 @@ fn sigkill_between_emit_and_reaction() -> anyhow::Result<()> { child.wait()?; for _ in 0..2 { let mut child = Command::new(std::env::current_exe()?) - .args(["--exact", "restart_child", "--nocapture"]) + .args(["--exact", "events::restart_child", "--nocapture"]) .env("EVENTS_RESTART_DIR", dir.path()) .env("EVENTS_MODE", "consume") .spawn()?; @@ -90,28 +90,7 @@ fn sigkill_between_emit_and_reaction() -> anyhow::Result<()> { } #[tokio::test(flavor = "current_thread")] async fn world_extensions() -> anyhow::Result<()> { - let userland = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland"); - let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap(); - if !userland.join("cwd-init/init.o").is_file() { - assert!( - Command::new("nu") - .args(["--no-config-file", "cwd-init/build.nu"]) - .current_dir(&userland) - .status()? - .success() - ); - } - assert!( - Command::new("cargo") - .args(["build", "--release", "-p", "git", "--manifest-path"]) - .arg(userland.join("Cargo.toml")) - .args(["--target", "wasm32-wasip2", "--target-dir"]) - .arg(target) - .args(["-j", "2", "--locked"]) - .current_dir(&userland) - .status()? - .success() - ); + let target = crate::common::build_git(); let dir = tempfile::tempdir()?; let world = World::open(dir.path())?; let disk = dir.path().join("world"); diff --git a/host/tests/extension_names.rs b/host/tests/it/extension_names.rs similarity index 100% rename from host/tests/extension_names.rs rename to host/tests/it/extension_names.rs diff --git a/host/tests/extensions.rs b/host/tests/it/extensions.rs similarity index 92% rename from host/tests/extensions.rs rename to host/tests/it/extensions.rs index d6ae8eb..39d9d05 100644 --- a/host/tests/extensions.rs +++ b/host/tests/it/extensions.rs @@ -4,7 +4,7 @@ use sausage::{ world::World, }; use std::{path::Path, sync::Arc}; -const BUNDLE: &str = include_str!("../js/extensions-test-bundle.js"); +const BUNDLE: &str = include_str!("../../js/extensions-test-bundle.js"); struct Author(Arc); #[async_trait::async_trait] impl Exec for Author { @@ -30,13 +30,7 @@ async fn commits_and_restart() -> anyhow::Result<()> { let userland = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland"); let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap(); if !userland.join("cwd-init/init.o").is_file() { - assert!( - std::process::Command::new("nu") - .args(["--no-config-file", "cwd-init/build.nu"]) - .current_dir(&userland) - .status()? - .success() - ); + crate::common::cwd_init(); } assert!( std::process::Command::new("cargo") diff --git a/host/tests/it/git_compat.rs b/host/tests/it/git_compat.rs new file mode 100644 index 0000000..d5902d6 --- /dev/null +++ b/host/tests/it/git_compat.rs @@ -0,0 +1,94 @@ +//! Fresh worlds provide a Git identity and resolve conventional tool names through which. +use sausage::{ + exec::{Command, Options, Stream}, + world::World, +}; + +#[tokio::test(flavor = "current_thread")] +async fn default_identity_and_which() -> anyhow::Result<()> { + let userland = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland"); + let target = crate::common::build_git(); + assert!( + std::process::Command::new("cargo") + .args(["build", "--release", "-p", "sausage-exec-tools", "--locked"]) + .current_dir(&userland) + .status()? + .success() + ); + let bundle = tempfile::tempdir()?; + std::fs::create_dir_all(bundle.path().join("bin"))?; + std::fs::create_dir_all(bundle.path().join("src"))?; + for name in ["git", "which"] { + std::fs::copy( + target.join(format!("wasm32-wasip2/release/{name}.wasm")), + bundle.path().join(format!("bin/{name}.wasm")), + )?; + } + let dir = tempfile::tempdir()?; + let world = World::open_with_userland(dir.path(), Some(bundle.path()))?; + std::fs::create_dir_all(dir.path().join("world/repo"))?; + std::fs::write(dir.path().join("world/repo/file.txt"), "base\n")?; + let exec = sausage::process::exec(World { + root: world.root.clone(), + host_dir: world.host_dir.clone(), + processes: world.processes.clone(), + })?; + for (argv, expected) in [ + (vec!["git", "-C", "/repo", "init", "-q", "-b", "main"], ""), + ( + vec!["git", "-C", "/repo", "config", "--get", "user.name"], + "Sausage\n", + ), + ( + vec!["git", "-C", "/repo", "config", "--get", "user.email"], + "sausage@localhost\n", + ), + (vec!["git", "-C", "/repo", "add", "."], ""), + (vec!["git", "-C", "/repo", "commit", "-q", "-m", "base"], ""), + (vec!["which", "git"], "/bin/git\n"), + (vec!["which", "-a", "git"], "/bin/git\n/bin/git.wasm\n"), + ] { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let code = exec + .exec( + Command::Argv(argv.iter().map(|s| (*s).into()).collect()), + Options::default(), + &mut |stream, bytes| match stream { + Stream::Stdout => stdout.extend_from_slice(bytes), + Stream::Stderr => stderr.extend_from_slice(bytes), + }, + ) + .await?; + assert_eq!(code, 0, "{argv:?}: {}", String::from_utf8_lossy(&stderr)); + assert_eq!(stdout, expected.as_bytes(), "{argv:?}"); + } + std::fs::copy( + dir.path().join("world/bin/git.wasm"), + dir.path().join("world/usr/local/bin/git.wasm"), + )?; + let mut stdout = Vec::new(); + assert_eq!( + exec.exec( + Command::Argv(vec!["which".into(), "git".into()]), + Options::default(), + &mut |stream, bytes| if matches!(stream, Stream::Stdout) { + stdout.extend_from_slice(bytes); + } + ) + .await?, + 0 + ); + assert_eq!(stdout, b"/usr/local/bin/git.wasm\n"); + let config = dir.path().join("world/etc/gitconfig"); + std::fs::write( + &config, + "[user]\nname = Custom\nemail = custom@example.test\n", + )?; + let _ = World::open(dir.path())?; + assert_eq!( + std::fs::read_to_string(config)?, + "[user]\nname = Custom\nemail = custom@example.test\n" + ); + Ok(()) +} diff --git a/host/tests/git_runtime.rs b/host/tests/it/git_runtime.rs similarity index 83% rename from host/tests/git_runtime.rs rename to host/tests/it/git_runtime.rs index f0178dc..17fae59 100644 --- a/host/tests/git_runtime.rs +++ b/host/tests/it/git_runtime.rs @@ -14,39 +14,8 @@ use std::{ fn guests() -> &'static Path { static BUILT: OnceLock = OnceLock::new(); BUILT.get_or_init(|| { - let repo = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); - let target = PathBuf::from(std::env::var("CARGO_TARGET_DIR").unwrap()); - for script in ["userland/cwd-init/build.nu", "userland/brush/build.nu"] { - if script == "userland/cwd-init/build.nu" - && repo.join("userland/cwd-init/init.o").is_file() - { - continue; - } - assert!( - std::process::Command::new("nu") - .args(["--no-config-file", script]) - .current_dir(repo) - .status() - .unwrap() - .success() - ); - } - assert!( - std::process::Command::new("cargo") - .args([ - "build", - "--release", - "--locked", - "--target", - "wasm32-wasip2", - "-p", - "git" - ]) - .current_dir(repo.join("userland")) - .status() - .unwrap() - .success() - ); + let target = crate::common::build_git(); + assert!(crate::common::build_brush().unwrap().success()); target.join("wasm32-wasip2/release") }) } diff --git a/host/tests/goal_questions.rs b/host/tests/it/goal_questions.rs similarity index 61% rename from host/tests/goal_questions.rs rename to host/tests/it/goal_questions.rs index 2879276..9eff431 100644 --- a/host/tests/goal_questions.rs +++ b/host/tests/it/goal_questions.rs @@ -1,7 +1,7 @@ //! Goal and question-card server cases execute through the world loader. use sausage::{harness::run_module, world::World}; use std::{path::Path, process::Command}; -const BUNDLE: &str = include_str!("../js/goal-questions-test-bundle.js"); +const BUNDLE: &str = include_str!("../../js/goal-questions-test-bundle.js"); static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(()); fn prepare(dir: &Path) -> anyhow::Result<()> { let _guard = PREPARE.lock().unwrap(); @@ -11,71 +11,19 @@ fn prepare(dir: &Path) -> anyhow::Result<()> { return Ok(()); } let source = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); - let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap(); - let userland = source.join("userland"); - if !userland.join("cwd-init/init.o").exists() { - assert!( - Command::new("nu") - .args(["--no-config-file", "cwd-init/build.nu"]) - .current_dir(&userland) - .status()? - .success() - ); - } - assert!( - Command::new("cargo") - .args(["build", "--release", "-p", "git", "--manifest-path"]) - .arg(userland.join("Cargo.toml")) - .args(["--target", "wasm32-wasip2", "--target-dir"]) - .arg(target) - .args(["-j", "2", "--locked"]) - .current_dir(&userland) - .status()? - .success() - ); + let target = crate::common::build_git(); std::fs::create_dir_all(disk.join("bin"))?; std::fs::copy( target.join("wasm32-wasip2/release/git.wasm"), disk.join("bin/git.wasm"), )?; - for directory in [ - "extensions/subagents", - "extensions/messages", - "extensions/lineage", - "extensions/models", - "extensions/goal", - "extensions/questions", - "extensions/automations", - "extensions/core", - "agents", - ] { - std::fs::create_dir_all(repo.join(directory))?; - } - for directory in [ - "extensions/subagents", - "extensions/messages", - "extensions/lineage", - "extensions/models", - "extensions/goal", - "extensions/questions", - "extensions/automations", - ] { - for entry in std::fs::read_dir(source.join(directory))? { - let entry = entry?; - if entry.path().extension().is_some_and(|ext| ext == "ts") { - std::fs::copy(entry.path(), repo.join(directory).join(entry.file_name()))?; - } - } - } + crate::common::copy_tree(&source.join("extensions"), &repo.join("extensions"))?; + std::fs::create_dir_all(repo.join("agents"))?; std::fs::create_dir_all(repo.join("extensions/probe"))?; std::fs::write( repo.join("extensions/probe/server.ts"), "export default host => { globalThis.testHost = host; globalThis.subagentService = host.need({name:'subagents'}); globalThis.messageService = host.need({name:'messages'}); globalThis.lineageService = host.need({name:'lineage'}); host.implement({name:'conversations'}, {summaryModel: async () => globalThis.summaryRef, concludedIn: async () => new Set(), generatedTitle: async () => {}, setStanding: async () => {}, setAutomaticSummaries: async () => {}}); return {name:'probe'}; }", )?; - std::fs::copy( - source.join("extensions/core/agents.ts"), - repo.join("extensions/core/agents.ts"), - )?; std::fs::write( repo.join("agents/models.json"), r#"{"default":{"model":"faux/faux-1"}}"#, diff --git a/host/tests/harness.rs b/host/tests/it/harness.rs similarity index 94% rename from host/tests/harness.rs rename to host/tests/it/harness.rs index d4c08ae..df4ed3d 100644 --- a/host/tests/harness.rs +++ b/host/tests/it/harness.rs @@ -45,7 +45,7 @@ async fn crash_child() -> anyhow::Result<()> { let source = format!( "globalThis.testCrash = {};\n{}", serde_json::to_string(&phase)?, - include_str!("../js/test-bundle.js") + include_str!("../../js/test-bundle.js") ); tokio::time::timeout( std::time::Duration::from_secs(20), @@ -69,7 +69,7 @@ fn sigkill_resumes_model_and_tool() -> anyhow::Result<()> { let dir = std::env::temp_dir().join(format!("sausage-crash-{}-{phase}", std::process::id())); let mut child = Command::new(std::env::current_exe()?) - .args(["--exact", "crash_child", "--nocapture"]) + .args(["--exact", "harness::crash_child", "--nocapture"]) .env("HARNESS_CRASH_DIR", &dir) .env("HARNESS_CRASH_PHASE", phase) .stdout(Stdio::piped()) @@ -92,7 +92,7 @@ fn sigkill_resumes_model_and_tool() -> anyhow::Result<()> { child.wait()?; ready?; let resumed = Command::new(std::env::current_exe()?) - .args(["--exact", "crash_child", "--nocapture"]) + .args(["--exact", "harness::crash_child", "--nocapture"]) .env("HARNESS_CRASH_DIR", &dir) .env("HARNESS_CRASH_PHASE", "resume") .status()?; @@ -110,7 +110,7 @@ async fn file_tools_and_exec() -> anyhow::Result<()> { let result = run_module( World::open(&dir)?, exec.clone(), - include_str!("../js/test-bundle.js"), + include_str!("../../js/test-bundle.js"), ) .await; result?; diff --git a/host/tests/harness_api.rs b/host/tests/it/harness_api.rs similarity index 80% rename from host/tests/harness_api.rs rename to host/tests/it/harness_api.rs index e163e47..55f8d3e 100644 --- a/host/tests/harness_api.rs +++ b/host/tests/it/harness_api.rs @@ -2,7 +2,7 @@ use sausage::{harness::run_module_with_input, world::World}; use std::sync::Arc; use std::{path::Path, process::Command}; -const BUNDLE: &str = include_str!("../js/harness-api-test-bundle.js"); +const BUNDLE: &str = include_str!("../../js/harness-api-test-bundle.js"); static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(()); fn prepare(dir: &Path) -> anyhow::Result<()> { let _guard = PREPARE.lock().unwrap(); @@ -15,13 +15,7 @@ fn prepare(dir: &Path) -> anyhow::Result<()> { let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap(); let userland = source.join("userland"); if !userland.join("cwd-init/init.o").exists() { - assert!( - Command::new("nu") - .args(["--no-config-file", "cwd-init/build.nu"]) - .current_dir(&userland) - .status()? - .success() - ); + crate::common::cwd_init(); } assert!( Command::new("cargo") @@ -71,44 +65,13 @@ fn prepare(dir: &Path) -> anyhow::Result<()> { target.join("wasm32-wasip2/release/curl.wasm"), disk.join("bin/curl.wasm"), )?; - for directory in [ - "extensions/subagents", - "extensions/messages", - "extensions/lineage", - "extensions/models", - "extensions/goal", - "extensions/questions", - "extensions/automations", - "extensions/core", - "agents", - ] { - std::fs::create_dir_all(repo.join(directory))?; - } - for directory in [ - "extensions/subagents", - "extensions/messages", - "extensions/lineage", - "extensions/models", - "extensions/goal", - "extensions/questions", - "extensions/automations", - ] { - for entry in std::fs::read_dir(source.join(directory))? { - let entry = entry?; - if entry.path().extension().is_some_and(|ext| ext == "ts") { - std::fs::copy(entry.path(), repo.join(directory).join(entry.file_name()))?; - } - } - } + crate::common::copy_tree(&source.join("extensions"), &repo.join("extensions"))?; + std::fs::create_dir_all(repo.join("agents"))?; std::fs::create_dir_all(repo.join("extensions/probe"))?; std::fs::write( repo.join("extensions/probe/server.ts"), "export default host => { globalThis.testHost = host; globalThis.subagentService = host.need({name:'subagents'}); globalThis.messageService = host.need({name:'messages'}); globalThis.lineageService = host.need({name:'lineage'}); host.implement({name:'conversations'}, {summaryModel: async () => globalThis.summaryRef, concludedIn: async () => new Set(), generatedTitle: async () => {}, setStanding: async () => {}, setAutomaticSummaries: async () => {}}); return {name:'probe'}; }", )?; - std::fs::copy( - source.join("extensions/core/agents.ts"), - repo.join("extensions/core/agents.ts"), - )?; std::fs::write( repo.join("agents/models.json"), r#"{"default":{"model":"faux/faux-1"}}"#, diff --git a/host/tests/harness_foundation.rs b/host/tests/it/harness_foundation.rs similarity index 93% rename from host/tests/harness_foundation.rs rename to host/tests/it/harness_foundation.rs index 00f95ec..05149b8 100644 --- a/host/tests/harness_foundation.rs +++ b/host/tests/it/harness_foundation.rs @@ -13,16 +13,16 @@ use std::{ time::{Duration, Instant}, }; -const BUNDLE: &str = include_str!("../js/foundation-test-bundle.js"); +const BUNDLE: &str = include_str!("../../js/foundation-test-bundle.js"); #[test] fn bundle_has_patched_hooks() { - for bundle in [BUNDLE, include_str!("../js/bundle.js")] { + for bundle in [BUNDLE, include_str!("../../js/bundle.js")] { assert!(bundle.contains("handlers.beforeInput")); assert!(bundle.contains("input: true")); assert!(bundle.contains("#held")); } - assert!(!include_str!("../js/patches/pi-durable-1.5.0.patch").contains("dist/env/node")); + assert!(!include_str!("../../js/patches/pi-durable-1.5.0.patch").contains("dist/env/node")); } async fn probe(case: &str) -> anyhow::Result<()> { @@ -87,7 +87,11 @@ async fn headless_child() -> anyhow::Result<()> { #[test] fn channel_with_closed_stdin() -> anyhow::Result<()> { let mut child = Command::new(std::env::current_exe()?) - .args(["--exact", "headless_child", "--nocapture"]) + .args([ + "--exact", + "harness_foundation::headless_child", + "--nocapture", + ]) .env("HEADLESS_CHILD", "1") .stdin(Stdio::null()) .spawn()?; diff --git a/host/tests/harness_shutdown.rs b/host/tests/it/harness_shutdown.rs similarity index 96% rename from host/tests/harness_shutdown.rs rename to host/tests/it/harness_shutdown.rs index ab7b662..7796d50 100644 --- a/host/tests/harness_shutdown.rs +++ b/host/tests/it/harness_shutdown.rs @@ -33,7 +33,7 @@ async fn shutdown_child() -> anyhow::Result<()> { receiver, concat!( "globalThis.foundationCase = 'shutdown';\n", - include_str!("../js/foundation-test-bundle.js") + include_str!("../../js/foundation-test-bundle.js") ), ) .await?; @@ -79,7 +79,7 @@ fn sigterm_cancels_tool_and_resumes() -> anyhow::Result<()> { )?; let spawn = || { Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "shutdown_child", "--nocapture"]) + .args(["--exact", "harness_shutdown::shutdown_child", "--nocapture"]) .env("SHUTDOWN_WORLD", dir.path()) .stdin(Stdio::null()) .spawn() diff --git a/host/tests/hook.rs b/host/tests/it/hook.rs similarity index 98% rename from host/tests/hook.rs rename to host/tests/it/hook.rs index 2abe6c0..d3c3e73 100644 --- a/host/tests/hook.rs +++ b/host/tests/it/hook.rs @@ -1,6 +1,12 @@ //! Real guest HTTP requests pass through caller identity, credential injection, and streaming transport. -mod hook_git; -mod hook_guest; +mod hook_git { + //! Preserve the Git push registration that also ran inside the former hook executable. + #[tokio::test] + async fn several_mb_git_push() { + crate::hook_git::push().await; + } +} +use super::hook_guest; use sausage::{ cred::{ self, diff --git a/host/tests/hook_api.rs b/host/tests/it/hook_api.rs similarity index 98% rename from host/tests/hook_api.rs rename to host/tests/it/hook_api.rs index bbec3f1..2da3a51 100644 --- a/host/tests/hook_api.rs +++ b/host/tests/it/hook_api.rs @@ -10,8 +10,8 @@ use std::{ io::{Read, Write}, sync::Arc, }; -#[path = "hook_guest.rs"] -mod hook_guest; + +use super::hook_guest; #[tokio::test] async fn one_api_for_pages_and_guest_requests() -> anyhow::Result<()> { diff --git a/host/tests/hook_git.rs b/host/tests/it/hook_git.rs similarity index 82% rename from host/tests/hook_git.rs rename to host/tests/it/hook_git.rs index 8e4139c..acd9269 100644 --- a/host/tests/hook_git.rs +++ b/host/tests/it/hook_git.rs @@ -4,12 +4,7 @@ use sausage::{ process::Runtime, world::World, }; -use std::{ - io::Write, - path::Path, - process::Stdio, - sync::{Arc, OnceLock}, -}; +use std::{io::Write, path::Path, process::Stdio, sync::Arc}; fn git(cwd: &Path, args: &[&str]) { assert!( std::process::Command::new("git") @@ -22,42 +17,10 @@ fn git(cwd: &Path, args: &[&str]) { } #[tokio::test] async fn several_mb_git_push() { - static BUILT: OnceLock = OnceLock::new(); - let component = BUILT.get_or_init(|| { - let target = std::env::var_os("CARGO_TARGET_DIR") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| { - Path::new(env!("CARGO_TARGET_TMPDIR")) - .parent() - .unwrap() - .to_owned() - }); - let userland = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland"); - if !userland.join("cwd-init/init.o").is_file() { - assert!( - std::process::Command::new("nu") - .args(["--no-config-file", "cwd-init/build.nu"]) - .current_dir(&userland) - .status() - .unwrap() - .success() - ); - } - let manifest = userland.join("Cargo.toml"); - assert!( - std::process::Command::new("cargo") - .args(["build", "--release", "-p", "git", "--manifest-path"]) - .arg(manifest) - .current_dir(&userland) - .args(["--target", "wasm32-wasip2", "--target-dir"]) - .arg(&target) - .args(["-j", "2", "--locked"]) - .status() - .unwrap() - .success() - ); - target.join("wasm32-wasip2/release/git.wasm") - }); + push().await; +} +pub async fn push() { + let component = crate::common::build_git().join("wasm32-wasip2/release/git.wasm"); let temp = tempfile::tempdir().unwrap(); let world = World::open(temp.path()).unwrap(); let root = temp.path().join("world"); diff --git a/host/tests/hook_guest.rs b/host/tests/it/hook_guest.rs similarity index 94% rename from host/tests/hook_guest.rs rename to host/tests/it/hook_guest.rs index ab8b22b..6efc853 100644 --- a/host/tests/hook_guest.rs +++ b/host/tests/it/hook_guest.rs @@ -12,12 +12,12 @@ fn write(path: PathBuf, text: &str) -> std::io::Result<()> { pub fn component() -> &'static Path { static PATH: OnceLock = OnceLock::new(); PATH.get_or_init(|| { - let target = std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from).unwrap_or_else(|| Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap().to_owned()); + let target = crate::common::target_dir(); let directory = Path::new(env!("CARGO_TARGET_TMPDIR")).join("hook-guest"); std::fs::create_dir_all(directory.join("src")).unwrap(); std::fs::create_dir_all(directory.join("wit/deps")).unwrap(); - write(directory.join("wit/process.wit"), include_str!("../wit/process.wit")).unwrap(); - write(directory.join("wit/deps/io.wit"), include_str!("../wit/deps/io.wit")).unwrap(); + write(directory.join("wit/process.wit"), include_str!("../../wit/process.wit")).unwrap(); + write(directory.join("wit/deps/io.wit"), include_str!("../../wit/deps/io.wit")).unwrap(); write(directory.join("Cargo.toml"), "[package]\nname='hook-guest'\nversion='0.1.0'\nedition='2024'\n[workspace]\n[dependencies]\nwstd='=0.6.8'\nanyhow='1'\nfutures-lite='2'\nwit-bindgen='0.46'\n").unwrap(); write(directory.join("src/main.rs"), r#" use wstd::http::{Body, BodyExt, Client, Request}; diff --git a/host/tests/init.rs b/host/tests/it/init.rs similarity index 100% rename from host/tests/init.rs rename to host/tests/it/init.rs diff --git a/host/tests/it/main.rs b/host/tests/it/main.rs new file mode 100644 index 0000000..4dedb6a --- /dev/null +++ b/host/tests/it/main.rs @@ -0,0 +1,65 @@ +//! Host integration tests share one executable. Modules retain their original test registrations. +mod api; +mod api_auth; +mod api_listener; +mod automations; +mod builder; +mod chain; +mod common; +mod core_ext; +mod cred; +mod cred_accounting; +mod cred_authorization; +mod cred_challenges; +mod cred_core; +mod cred_edits; +mod cred_entry; +mod cred_grants; +mod cred_maintenance; +mod cred_oauth2; +mod cred_policy; +mod cred_presentation; +mod cred_proofs; +mod cred_quickjs; +mod cred_quickjs_security; +mod cred_regressions; +mod cred_replay; +mod cred_security; +mod cred_sign; +mod cred_storage; +mod cred_summary; +mod cred_webauthn; +mod dream; +mod durable; +mod events; +mod extension_names; +mod extensions; +mod git_compat; +mod git_runtime; +mod goal_questions; +mod harness; +mod harness_api; +mod harness_foundation; +mod harness_shutdown; +mod hook; +mod hook_api; +mod hook_git; +mod hook_guest; +mod init; +mod messages; +mod net; +mod p1; +mod p2; +mod p3; +mod p4; +mod plan_todos; +mod process; +mod proxy_models; +mod subagents; +mod undo; +mod undo_api; +mod undo_cost; +mod undo_exec; +mod undo_harness; +mod watchdog; +mod workspaces; diff --git a/host/tests/messages.rs b/host/tests/it/messages.rs similarity index 86% rename from host/tests/messages.rs rename to host/tests/it/messages.rs index 11c2eed..a86a2c7 100644 --- a/host/tests/messages.rs +++ b/host/tests/it/messages.rs @@ -5,7 +5,7 @@ use std::{ process::Command, time::{Duration, Instant}, }; -const BUNDLE: &str = include_str!("../js/messages-test-bundle.js"); +const BUNDLE: &str = include_str!("../../js/messages-test-bundle.js"); static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(()); fn prepare(dir: &Path) -> anyhow::Result<()> { let _guard = PREPARE.lock().unwrap(); @@ -15,28 +15,7 @@ fn prepare(dir: &Path) -> anyhow::Result<()> { return Ok(()); } let source = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); - let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap(); - let userland = source.join("userland"); - if !userland.join("cwd-init/init.o").exists() { - assert!( - Command::new("nu") - .args(["--no-config-file", "cwd-init/build.nu"]) - .current_dir(&userland) - .status()? - .success() - ); - } - assert!( - Command::new("cargo") - .args(["build", "--release", "-p", "git", "--manifest-path"]) - .arg(userland.join("Cargo.toml")) - .args(["--target", "wasm32-wasip2", "--target-dir"]) - .arg(target) - .args(["-j", "2", "--locked"]) - .current_dir(&userland) - .status()? - .success() - ); + let target = crate::common::build_git(); std::fs::create_dir_all(disk.join("bin"))?; std::fs::copy( target.join("wasm32-wasip2/release/git.wasm"), @@ -148,7 +127,7 @@ fn sigkill_after_reply_placement() -> anyhow::Result<()> { prepare(dir.path())?; let spawn = |mode: &str| { Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "restart_child", "--nocapture"]) + .args(["--exact", "messages::restart_child", "--nocapture"]) .env("MESSAGES_DIR", dir.path()) .env("MESSAGES_MODE", mode) .spawn() diff --git a/host/tests/net.rs b/host/tests/it/net.rs similarity index 99% rename from host/tests/net.rs rename to host/tests/it/net.rs index 51a6998..8a98a9f 100644 --- a/host/tests/net.rs +++ b/host/tests/it/net.rs @@ -1,5 +1,5 @@ //! Real components can accept TCP connections. Host port registration and host-only config deny access. -mod hook_guest; +use super::hook_guest; use sausage::{ exec::{Command, Exec, Options}, process::{Runtime, net::NetPolicy}, diff --git a/host/tests/p1.rs b/host/tests/it/p1.rs similarity index 79% rename from host/tests/p1.rs rename to host/tests/it/p1.rs index ef4c22d..120801d 100644 --- a/host/tests/p1.rs +++ b/host/tests/it/p1.rs @@ -8,6 +8,7 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> { let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".."); let bundle = PathBuf::from(std::env::var("CARGO_TARGET_DIR").unwrap()).join("bundle"); if !bundle.join("bin/sh.wasm").exists() { + let _guard = crate::common::BUILD.lock().unwrap(); assert!( std::process::Command::new("nu") .arg(repo.join("userland/bundle.nu")) @@ -22,9 +23,15 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> { use std::os::unix::fs::MetadataExt; for entry in std::fs::read_dir(dir.path().join("world/bin"))? { let entry = entry?; - assert_eq!(entry.path().extension().unwrap(), "wasm"); - assert_eq!(entry.metadata()?.nlink(), 1); - assert!(!entry.file_type()?.is_symlink()); + if entry.file_type()?.is_symlink() { + assert_eq!( + std::fs::read_link(entry.path())?, + PathBuf::from(format!("{}.wasm", entry.file_name().to_string_lossy())) + ); + } else { + assert_eq!(entry.path().extension().unwrap(), "wasm"); + assert_eq!(entry.metadata()?.nlink(), 1); + } } assert!(world.root.stat("/usr/local/bin", true).await?.directory); let writable = world @@ -46,7 +53,7 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> { })?; use sausage::exec::{Command, Options, Stream}; for (script, expected) in [ - ("command -v ls", "/bin/ls.wasm\n"), + ("command -v ls", "/bin/ls\n"), ("ls", "bin"), ("git --version", "git version"), ("printf 'a\\n' | xargs echo", "a\n"), @@ -67,6 +74,31 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> { assert_eq!(code, 0, "{script}"); assert!(String::from_utf8(out)?.contains(expected), "{script}"); } + for (script, expected) in [ + ( + "echo x > /dev/null && cat < /dev/null && ls /bin/git && printf 'a\\nb\\n' | xargs -I{} echo [{}]", + "/bin/git\n[a]\n[b]\n", + ), + ("test -x /bin/git && echo executable", "executable\n"), + ("head -c 4 /dev/zero | wc -c", "4\n"), + ("head -c 4 /dev/urandom | wc -c", "4\n"), + ("echo x > /dev/null 2>&1; cat < /dev/null", ""), + ] { + let mut out = Vec::new(); + let code = exec + .exec( + Command::Shell(script.into()), + Options::default(), + &mut |stream, bytes| { + if matches!(stream, Stream::Stdout) { + out.extend_from_slice(bytes); + } + }, + ) + .await?; + assert_eq!(code, 0, "{script}"); + assert_eq!(out, expected.as_bytes(), "{script}"); + } let mut history = Vec::new(); assert_eq!( exec.exec( @@ -101,7 +133,7 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> { assert!(status.is_empty(), "{}", String::from_utf8_lossy(&status)); let source = format!( "globalThis.testP1 = true;\n{}", - include_str!("../js/test-bundle.js") + include_str!("../../js/test-bundle.js") ); run_module(world, exec.clone(), &source).await?; assert_eq!( diff --git a/host/tests/p2.rs b/host/tests/it/p2.rs similarity index 97% rename from host/tests/p2.rs rename to host/tests/it/p2.rs index aac61a5..4897ca1 100644 --- a/host/tests/p2.rs +++ b/host/tests/it/p2.rs @@ -166,9 +166,9 @@ impl Browser { } fn components() -> Result { let userland = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland"); - let target = Path::new(env!("CARGO_TARGET_TMPDIR")).join("p2-userland"); + let target = crate::common::target_dir(); if !userland.join("cwd-init/init.o").is_file() { - command(&userland, &["nu", "--no-config-file", "cwd-init/build.nu"])?; + crate::common::cwd_init(); } let status = Command::new("cargo") .current_dir(&userland) @@ -192,12 +192,7 @@ fn components() -> Result { let release = target.join("wasm32-wasip2/release"); if !release.join("sh.wasm").is_file() { ensure!( - Command::new("nu") - .arg("brush/build.nu") - .env("CARGO_TARGET_DIR", &target) - .current_dir(&userland) - .status()? - .success(), + crate::common::build_brush()?.success(), "shell build failed" ); std::fs::copy(release.join("brush.wasm"), release.join("sh.wasm"))?; @@ -413,7 +408,7 @@ async fn request_approve_fetch_and_ssh_push() -> Result<()> { browser.enter(&ssh_item, &key).await?; let source = format!( "globalThis.p2Http = {http_port};\n{}", - include_str!("../js/p2-test-bundle.js") + include_str!("../../js/p2-test-bundle.js") ); let approve = async { for item in [&http_item, &ssh_item] { diff --git a/host/tests/p3.rs b/host/tests/it/p3.rs similarity index 83% rename from host/tests/p3.rs rename to host/tests/it/p3.rs index 74e441e..c2cc520 100644 --- a/host/tests/p3.rs +++ b/host/tests/it/p3.rs @@ -1,11 +1,12 @@ //! P3 exit scenario across worker, check, and recorded fast-forward SIGKILL boundaries. +use crate::common::copy_tree; use sausage::{harness::run_module, world::World}; use std::{ path::Path, process::Command, time::{Duration, Instant}, }; -const BUNDLE: &str = include_str!("../js/p3-test-bundle.js"); +const BUNDLE: &str = include_str!("../../js/p3-test-bundle.js"); fn git(repo: &Path, args: &[&str]) -> anyhow::Result { let output = Command::new("git") .args(args) @@ -22,48 +23,12 @@ fn git(repo: &Path, args: &[&str]) -> anyhow::Result { ); Ok(String::from_utf8(output.stdout)?.trim().into()) } -fn copy_tree(source: &Path, destination: &Path) -> anyhow::Result<()> { - std::fs::create_dir_all(destination)?; - for entry in std::fs::read_dir(source)? { - let entry = entry?; - let target = destination.join(entry.file_name()); - if entry.file_type()?.is_dir() { - copy_tree(&entry.path(), &target)?; - } else { - std::fs::copy(entry.path(), target)?; - } - } - Ok(()) -} fn prepare(dir: &Path, all_extensions: bool) -> anyhow::Result<()> { World::open(dir)?; let disk = dir.join("world"); let repo = disk.join("src/sausage"); let source = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); - let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap(); - let userland = source.join("userland"); - if !userland.join("cwd-init/init.o").exists() { - anyhow::ensure!( - Command::new("nu") - .args(["--no-config-file", "cwd-init/build.nu"]) - .current_dir(&userland) - .status()? - .success(), - "cwd-init build failed" - ); - } - anyhow::ensure!( - Command::new("cargo") - .args(["build", "--release", "-p", "git", "--manifest-path"]) - .arg(userland.join("Cargo.toml")) - .args(["--target", "wasm32-wasip2", "--target-dir"]) - .arg(target) - .args(["-j", "2", "--locked"]) - .current_dir(&userland) - .status()? - .success(), - "world git build failed" - ); + let target = crate::common::build_git(); std::fs::create_dir_all(disk.join("bin"))?; std::fs::copy( target.join("wasm32-wasip2/release/git.wasm"), @@ -71,12 +36,7 @@ fn prepare(dir: &Path, all_extensions: bool) -> anyhow::Result<()> { )?; if !target.join("wasm32-wasip2/release/brush.wasm").exists() { anyhow::ensure!( - Command::new("nu") - .arg("brush/build.nu") - .env("CARGO_TARGET_DIR", target) - .current_dir(&userland) - .status()? - .success(), + crate::common::build_brush()?.success(), "brush build failed" ); } @@ -185,7 +145,7 @@ fn recovery(point: &str) -> anyhow::Result<()> { } let spawn = |mode: &str| { Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "restart_child", "--nocapture"]) + .args(["--exact", "p3::restart_child", "--nocapture"]) .env("P3_DIR", dir.path()) .env("P3_MODE", mode) .spawn() diff --git a/host/tests/p4.rs b/host/tests/it/p4.rs similarity index 97% rename from host/tests/p4.rs rename to host/tests/it/p4.rs index ef166d4..f0021bb 100644 --- a/host/tests/p4.rs +++ b/host/tests/it/p4.rs @@ -26,7 +26,7 @@ fn prepare(dir: &Path) -> anyhow::Result { ensure!(revision.status.success(), "fixture revision"); if fs::read(bundle.join("revision")).ok().as_deref() != Some(revision.stdout.as_slice()) { if !userland.join("cwd-init/init.o").exists() { - checked(Command::new("nu").arg(userland.join("cwd-init/build.nu")))?; + crate::common::cwd_init(); } checked(Command::new("cargo").current_dir(&userland).args([ "build", @@ -43,7 +43,10 @@ fn prepare(dir: &Path) -> anyhow::Result { "-p", "sausage-coreutils", ]))?; - checked(Command::new("nu").arg(userland.join("brush/build.nu")))?; + ensure!( + crate::common::build_brush()?.success(), + "shell build failed" + ); fs::create_dir_all(bundle.join("bin"))?; fs::create_dir_all(bundle.join("src"))?; for (binary, name) in [ @@ -68,7 +71,7 @@ fn prepare(dir: &Path) -> anyhow::Result { fs::create_dir_all(repo.join("extensions/p4"))?; fs::write( repo.join("extensions/p4/server.ts"), - include_str!("../js/p4-test-bundle.js"), + include_str!("../../js/p4-test-bundle.js"), )?; for args in [ vec!["init", "-b", "main"], diff --git a/host/tests/plan_todos.rs b/host/tests/it/plan_todos.rs similarity index 70% rename from host/tests/plan_todos.rs rename to host/tests/it/plan_todos.rs index 32bdda5..7040859 100644 --- a/host/tests/plan_todos.rs +++ b/host/tests/it/plan_todos.rs @@ -1,32 +1,11 @@ //! The plan and todos ports run through real world extension snapshots and survive restart. use sausage::world::World; use std::path::Path; -const BUNDLE: &str = include_str!("../js/plan-todos-test-bundle.js"); +const BUNDLE: &str = include_str!("../../js/plan-todos-test-bundle.js"); #[tokio::test(flavor = "current_thread")] async fn plan_todos_and_restart() -> anyhow::Result<()> { - let userland = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland"); - let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap(); - if !userland.join("cwd-init/init.o").is_file() { - assert!( - std::process::Command::new("nu") - .args(["--no-config-file", "cwd-init/build.nu"]) - .current_dir(&userland) - .status()? - .success() - ); - } - assert!( - std::process::Command::new("cargo") - .args(["build", "--release", "-p", "git", "--manifest-path"]) - .arg(userland.join("Cargo.toml")) - .args(["--target", "wasm32-wasip2", "--target-dir"]) - .arg(target) - .args(["-j", "2", "--locked"]) - .current_dir(&userland) - .status()? - .success() - ); + let target = crate::common::build_git(); let dir = tempfile::tempdir()?; let disk = dir.path().join("world"); let _world = World::open(dir.path())?; diff --git a/host/tests/process.rs b/host/tests/it/process.rs similarity index 96% rename from host/tests/process.rs rename to host/tests/it/process.rs index 27fc711..23dac35 100644 --- a/host/tests/process.rs +++ b/host/tests/it/process.rs @@ -21,6 +21,12 @@ fn world_copies_programs_and_nested_source_only_once() -> anyhow::Result<()> { b"ref: refs/heads/main\n", )?; std::fs::write(bundle.join("src/sausage/README.md"), b"source")?; + for (name, target) in [ + ("readme-link", "README.md"), + ("broken-link", "missing.wasm"), + ] { + std::os::unix::fs::symlink(target, bundle.join("src/sausage").join(name))?; + } let path = temp.path().join("world"); let _world = World::open_with_userland(&path, Some(&bundle))?; assert_eq!( @@ -32,6 +38,15 @@ fn world_copies_programs_and_nested_source_only_once() -> anyhow::Result<()> { b"source" ); assert!(path.join("world/src/sausage/.git/HEAD").is_file()); + for (name, target) in [ + ("readme-link", "README.md"), + ("broken-link", "missing.wasm"), + ] { + assert_eq!( + std::fs::read_link(path.join("world/src/sausage").join(name))?, + Path::new(target) + ); + } std::fs::write(path.join("world/src/sausage/README.md"), b"agent patch")?; let _world = World::open_with_userland(&path, Some(Path::new("/missing-bundle")))?; assert_eq!( @@ -375,10 +390,7 @@ fn build() { "guest build failed: {args:?}" ); }; - run( - &["nu", "--no-config-file", "userland/cwd-init/build.nu"], - &root, - ); + crate::common::cwd_init(); run( &[ "cargo", @@ -394,10 +406,7 @@ fn build() { ], &root.join("userland"), ); - run( - &["nu", "--no-config-file", "userland/brush/build.nu"], - &root, - ); + assert!(crate::common::build_brush().unwrap().success()); run( &[ "cargo", @@ -560,7 +569,13 @@ async fn corpus_matches_bash() { .args(["-c", &script]) .output() .unwrap(); - assert_eq!(cli.status.code(), result.status.code()); + assert_eq!( + cli.status.code(), + result.status.code(), + "CLI stdout: {}\nCLI stderr: {}", + String::from_utf8_lossy(&cli.stdout), + String::from_utf8_lossy(&cli.stderr) + ); assert_eq!( cli.stdout, expected.as_bytes(), diff --git a/host/tests/proxy_models.rs b/host/tests/it/proxy_models.rs similarity index 98% rename from host/tests/proxy_models.rs rename to host/tests/it/proxy_models.rs index 0e60d64..00ed176 100644 --- a/host/tests/proxy_models.rs +++ b/host/tests/it/proxy_models.rs @@ -98,7 +98,7 @@ async fn provider_transport_and_prices() -> anyhow::Result<()> { let dir = tempfile::tempdir()?; let source = format!( "globalThis.testProxy = {proxy};\n{}", - include_str!("../js/proxy-models-test-bundle.js") + include_str!("../../js/proxy-models-test-bundle.js") ); let result = sausage::harness::run_module(World::open(dir.path())?, Arc::new(NoExec), &source).await; diff --git a/host/tests/subagents.rs b/host/tests/it/subagents.rs similarity index 84% rename from host/tests/subagents.rs rename to host/tests/it/subagents.rs index 12a24f9..1fd08ff 100644 --- a/host/tests/subagents.rs +++ b/host/tests/it/subagents.rs @@ -5,7 +5,7 @@ use std::{ process::Command, time::{Duration, Instant}, }; -const BUNDLE: &str = include_str!("../js/subagents-test-bundle.js"); +const BUNDLE: &str = include_str!("../../js/subagents-test-bundle.js"); fn prepare(dir: &Path) -> anyhow::Result<()> { let disk = dir.join("world"); let repo = disk.join("src/sausage"); @@ -13,28 +13,7 @@ fn prepare(dir: &Path) -> anyhow::Result<()> { return Ok(()); } let source = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); - let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap(); - let userland = source.join("userland"); - if !userland.join("cwd-init/init.o").exists() { - assert!( - Command::new("nu") - .args(["--no-config-file", "cwd-init/build.nu"]) - .current_dir(&userland) - .status()? - .success() - ); - } - assert!( - Command::new("cargo") - .args(["build", "--release", "-p", "git", "--manifest-path"]) - .arg(userland.join("Cargo.toml")) - .args(["--target", "wasm32-wasip2", "--target-dir"]) - .arg(target) - .args(["-j", "2", "--locked"]) - .current_dir(&userland) - .status()? - .success() - ); + let target = crate::common::build_git(); std::fs::create_dir_all(disk.join("bin"))?; std::fs::copy( target.join("wasm32-wasip2/release/git.wasm"), @@ -131,7 +110,7 @@ fn sigkill_between_deliver_and_report() -> anyhow::Result<()> { prepare(dir.path())?; let spawn = |mode: &str| { Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "restart_child", "--nocapture"]) + .args(["--exact", "subagents::restart_child", "--nocapture"]) .env("SUBAGENTS_DIR", dir.path()) .env("SUBAGENTS_MODE", mode) .spawn() diff --git a/host/tests/undo.rs b/host/tests/it/undo.rs similarity index 100% rename from host/tests/undo.rs rename to host/tests/it/undo.rs diff --git a/host/tests/undo_api.rs b/host/tests/it/undo_api.rs similarity index 100% rename from host/tests/undo_api.rs rename to host/tests/it/undo_api.rs diff --git a/host/tests/undo_cost.rs b/host/tests/it/undo_cost.rs similarity index 95% rename from host/tests/undo_cost.rs rename to host/tests/it/undo_cost.rs index 0d077d3..d0a38b5 100644 --- a/host/tests/undo_cost.rs +++ b/host/tests/it/undo_cost.rs @@ -6,19 +6,14 @@ use sausage::{ }; use sausage_vfs::{Host, Mounts, Process}; use std::{path::PathBuf, sync::Arc, time::Instant}; +// This timing benchmark has a 10% threshold and is flaky on a loaded machine. #[tokio::test(flavor = "multi_thread")] +#[ignore = "Run this timing benchmark by hand on an idle machine"] async fn status_cost() { let target = PathBuf::from(std::env::var("CARGO_TARGET_DIR").unwrap()); if !target.join("wasm32-wasip2/release/git.wasm").is_file() { let userland = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../userland"); - assert!( - std::process::Command::new("nu") - .args(["--no-config-file", "cwd-init/build.nu"]) - .current_dir(&userland) - .status() - .unwrap() - .success() - ); + crate::common::cwd_init(); assert!( std::process::Command::new("cargo") .args(["build", "--release", "-p", "git"]) diff --git a/host/tests/undo_exec.rs b/host/tests/it/undo_exec.rs similarity index 94% rename from host/tests/undo_exec.rs rename to host/tests/it/undo_exec.rs index 0b7aaca..7f279f9 100644 --- a/host/tests/undo_exec.rs +++ b/host/tests/it/undo_exec.rs @@ -39,14 +39,7 @@ async fn tool_call_commit_and_independent_change() { || !guest_dir.join("touch.wasm").is_file() || !guest_dir.join("undo.wasm").is_file() { - assert!( - std::process::Command::new("nu") - .args(["--no-config-file", "cwd-init/build.nu"]) - .current_dir(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../userland")) - .status() - .unwrap() - .success() - ); + crate::common::cwd_init(); assert!( std::process::Command::new("cargo") .args([ diff --git a/host/tests/undo_harness.rs b/host/tests/it/undo_harness.rs similarity index 91% rename from host/tests/undo_harness.rs rename to host/tests/it/undo_harness.rs index b7b272f..341fe20 100644 --- a/host/tests/undo_harness.rs +++ b/host/tests/it/undo_harness.rs @@ -42,14 +42,7 @@ async fn command(runtime: &Arc, args: &[&str]) -> String { async fn tools_restore_one_call_or_the_whole_turn() { let target = PathBuf::from(std::env::var("CARGO_TARGET_DIR").unwrap()); let repo = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); - assert!( - std::process::Command::new("nu") - .args(["--no-config-file", "userland/cwd-init/build.nu"]) - .current_dir(repo) - .status() - .unwrap() - .success() - ); + crate::common::cwd_init(); assert!( std::process::Command::new("cargo") .args([ @@ -71,15 +64,7 @@ async fn tools_restore_one_call_or_the_whole_turn() { .unwrap() .success() ); - assert!( - std::process::Command::new("nu") - .args(["--no-config-file", "userland/brush/build.nu"]) - .env("CARGO_TARGET_DIR", &target) - .current_dir(repo) - .status() - .unwrap() - .success() - ); + assert!(crate::common::build_brush().unwrap().success()); for since in [false, true] { let temp = tempfile::tempdir().unwrap(); let world = World::open(temp.path()).unwrap(); @@ -112,7 +97,7 @@ async fn tools_restore_one_call_or_the_whole_turn() { sausage::harness::run_module( world, Arc::new(runtime.clone()), - include_str!("../js/undo-harness-test-bundle.js"), + include_str!("../../js/undo-harness-test-bundle.js"), ) .await .unwrap(); diff --git a/host/tests/watchdog.rs b/host/tests/it/watchdog.rs similarity index 86% rename from host/tests/watchdog.rs rename to host/tests/it/watchdog.rs index eb25878..5cc1a6a 100644 --- a/host/tests/watchdog.rs +++ b/host/tests/it/watchdog.rs @@ -5,7 +5,7 @@ use std::{ process::Command, time::{Duration, Instant}, }; -const BUNDLE: &str = include_str!("../js/watchdog-test-bundle.js"); +const BUNDLE: &str = include_str!("../../js/watchdog-test-bundle.js"); static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(()); fn prepare(dir: &Path) -> anyhow::Result<()> { let _guard = PREPARE.lock().unwrap(); @@ -15,28 +15,7 @@ fn prepare(dir: &Path) -> anyhow::Result<()> { return Ok(()); } let source = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); - let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap(); - let userland = source.join("userland"); - if !userland.join("cwd-init/init.o").exists() { - assert!( - Command::new("nu") - .args(["--no-config-file", "cwd-init/build.nu"]) - .current_dir(&userland) - .status()? - .success() - ); - } - assert!( - Command::new("cargo") - .args(["build", "--release", "-p", "git", "--manifest-path"]) - .arg(userland.join("Cargo.toml")) - .args(["--target", "wasm32-wasip2", "--target-dir"]) - .arg(target) - .args(["-j", "2", "--locked"]) - .current_dir(&userland) - .status()? - .success() - ); + let target = crate::common::build_git(); std::fs::create_dir_all(disk.join("bin"))?; std::fs::copy( target.join("wasm32-wasip2/release/git.wasm"), @@ -150,7 +129,7 @@ fn sigkill_during_durable_wait() -> anyhow::Result<()> { prepare(dir.path())?; let spawn = |mode: &str| { Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "restart_child", "--nocapture"]) + .args(["--exact", "watchdog::restart_child", "--nocapture"]) .env("WATCHDOG_DIR", dir.path()) .env("WATCHDOG_MODE", mode) .spawn() diff --git a/host/tests/workspaces.rs b/host/tests/it/workspaces.rs similarity index 88% rename from host/tests/workspaces.rs rename to host/tests/it/workspaces.rs index f51154c..4f7a801 100644 --- a/host/tests/workspaces.rs +++ b/host/tests/it/workspaces.rs @@ -7,7 +7,7 @@ use std::{ sync::Arc, time::{Duration, Instant}, }; -const BUNDLE: &str = include_str!("../js/workspaces-test-bundle.js"); +const BUNDLE: &str = include_str!("../../js/workspaces-test-bundle.js"); fn git(path: &Path, args: &[&str]) -> anyhow::Result<()> { assert!( Command::new("git") @@ -29,37 +29,9 @@ fn prepare(dir: &Path) -> anyhow::Result<()> { return Ok(()); } let source = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); - let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap(); - let userland = source.join("userland"); - if !userland.join("cwd-init/init.o").exists() { - assert!( - Command::new("nu") - .args(["--no-config-file", "cwd-init/build.nu"]) - .current_dir(&userland) - .status()? - .success() - ); - } - assert!( - Command::new("cargo") - .args(["build", "--release", "-p", "git", "--manifest-path"]) - .arg(userland.join("Cargo.toml")) - .args(["--target", "wasm32-wasip2", "--target-dir"]) - .arg(target) - .args(["-j", "2", "--locked"]) - .current_dir(&userland) - .status()? - .success() - ); + let target = crate::common::build_git(); if !target.join("wasm32-wasip2/release/brush.wasm").exists() { - assert!( - Command::new("nu") - .arg("brush/build.nu") - .env("CARGO_TARGET_DIR", target) - .current_dir(&userland) - .status()? - .success() - ); + assert!(crate::common::build_brush()?.success()); } std::fs::create_dir_all(disk.join("bin"))?; for (binary, name) in [("git", "git"), ("brush", "sh")] { @@ -116,8 +88,6 @@ fn prepare(dir: &Path) -> anyhow::Result<()> { std::fs::write(project.join("base.txt"), "base")?; for path in [&project, &repo] { git(path, &["init", "-b", "main"])?; - git(path, &["config", "user.name", "Test"])?; - git(path, &["config", "user.email", "test@example.com"])?; git(path, &["add", "."])?; git(path, &["commit", "--no-gpg-sign", "-m", "fixture"])?; } @@ -254,7 +224,7 @@ fn sigkill_between_fast_forward_and_push() -> anyhow::Result<()> { let _server = serve(dir.path())?; let spawn = |mode: &str| { Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "restart_child", "--nocapture"]) + .args(["--exact", "workspaces::restart_child", "--nocapture"]) .env("WORKSPACES_DIR", dir.path()) .env("WORKSPACES_MODE", mode) .spawn() diff --git a/userland/exec-tools/Cargo.toml b/userland/exec-tools/Cargo.toml index 9e09c98..4458d8a 100644 --- a/userland/exec-tools/Cargo.toml +++ b/userland/exec-tools/Cargo.toml @@ -7,6 +7,10 @@ edition = "2024" name = "env" path = "src/env.rs" +[[bin]] +name = "which" +path = "src/which.rs" + [[bin]] name = "timeout" path = "src/timeout.rs" diff --git a/userland/exec-tools/src/which.rs b/userland/exec-tools/src/which.rs new file mode 100644 index 0000000..5b6cbc0 --- /dev/null +++ b/userland/exec-tools/src/which.rs @@ -0,0 +1,39 @@ +//! Resolve tool names through PATH, with the same .wasm fallback as the host. +use std::path::Path; +fn main() { + let mut args = std::env::args().skip(1).peekable(); + let all = args.peek().is_some_and(|arg| arg == "-a"); + if all { + args.next(); + } + let names: Vec<_> = args.collect(); + if names.is_empty() || names.iter().any(|name| name.starts_with('-')) { + eprintln!("usage: which [-a] ..."); + std::process::exit(2); + } + let path = std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/bin".into()); + let mut missing = false; + for name in names { + let paths: Vec<_> = if name.contains('/') { + vec![name.clone()] + } else { + path.split(':') + .map(|dir| Path::new(dir).join(&name).to_string_lossy().into_owned()) + .collect() + }; + let mut found = false; + 'search: for path in paths { + for candidate in [path.clone(), format!("{path}.wasm")] { + if std::fs::metadata(&candidate).is_ok_and(|metadata| metadata.is_file()) { + println!("{candidate}"); + found = true; + if !all { + break 'search; + } + } + } + } + missing |= !found; + } + std::process::exit(i32::from(missing)); +} diff --git a/userland/findutils/src/xargs.rs b/userland/findutils/src/xargs.rs index fbc43b2..fc5c2f7 100644 --- a/userland/findutils/src/xargs.rs +++ b/userland/findutils/src/xargs.rs @@ -1,39 +1,122 @@ //! Build sequential child commands from quoted or NUL-delimited input. -use std::io::{self, Read}; use sausage_spawn::{Command, Stdio}; +use std::io::{self, Read}; fn run() -> Result> { let args: Vec<_> = std::env::args().skip(1).collect(); let mut i = 0; let mut count = usize::MAX; let mut null = false; let mut no_empty = false; + let mut replace = None; while i < args.len() { match args[i].as_str() { + "-I" => { + i += 1; + replace = Some(args.get(i).ok_or("missing -I argument")?.clone()); + count = 1; + } + arg if arg.starts_with("-I") => { + replace = Some(arg[2..].to_owned()); + count = 1; + } "-0" => null = true, "-r" => no_empty = true, - "-n" => { i += 1; count = args.get(i).ok_or("missing -n argument")?.parse()?; } - "--" => { i += 1; break; } + "-n" => { + i += 1; + count = args.get(i).ok_or("missing -n argument")?.parse()?; + } + "--" => { + i += 1; + break; + } arg if arg.starts_with("-n") => count = arg[2..].parse()?, arg if arg.starts_with('-') => return Err(format!("unsupported option: {arg}").into()), _ => break, } i += 1; } - if count == 0 { return Err("-n must be positive".into()); } - let argv = if i == args.len() { vec!["echo".into()] } else { args[i..].to_vec() }; - let mut input = String::new(); io::stdin().read_to_string(&mut input)?; - let values = if null { input.split_terminator('\0').map(str::to_owned).collect() } - else { shlex::split(&input).ok_or("unmatched quote in input")? }; - if values.is_empty() && no_empty { return Ok(0); } + if count == 0 { + return Err("-n must be positive".into()); + } + let argv = if i == args.len() { + vec!["echo".into()] + } else { + args[i..].to_vec() + }; + let mut input = String::new(); + io::stdin().read_to_string(&mut input)?; + let values = if null { + input.split_terminator('\0').map(str::to_owned).collect() + } else if replace.is_some() { + input + .split_terminator('\n') + .map(|line| line.trim_start_matches([' ', '\t'])) + .filter(|line| !line.is_empty()) + .map(replacement) + .collect::, _>>()? + } else { + shlex::split(&input).ok_or("unmatched quote in input")? + }; + if values.is_empty() && (no_empty || replace.is_some()) { + return Ok(0); + } let mut result = 0; - let batches = if values.is_empty() { vec![&[][..]] } else { values.chunks(count).collect() }; + let batches = if values.is_empty() { + vec![&[][..]] + } else { + values.chunks(count).collect() + }; for batch in batches { - match Command::new(&argv[0]).args(&argv[1..]).args(batch).stdin(Stdio::Null).status() { - Ok(0) => (), Ok(255) => return Ok(124), Ok(_) => result = 123, + let command = if let Some(ref token) = replace { + argv.iter() + .map(|arg| arg.replace(token, &batch.join(" "))) + .collect::>() + } else { + argv.iter().cloned().chain(batch.iter().cloned()).collect() + }; + match Command::new(&command[0]) + .args(&command[1..]) + .stdin(Stdio::Null) + .status() + { + Ok(0) => (), + Ok(255) => return Ok(124), + Ok(_) => result = 123, Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(127), Err(_) => return Ok(126), } } Ok(result) } -fn main() { std::process::exit(match run() { Ok(code) => code, Err(e) => { eprintln!("xargs: {e}"); 1 } }); } +fn replacement(line: &str) -> Result> { + let mut result = String::new(); + let mut quote = None; + let mut escaped = false; + for ch in line.chars() { + if escaped { + result.push(ch); + escaped = false; + } else if ch == '\\' && quote != Some('\'') { + escaped = true; + } else if quote == Some(ch) { + quote = None; + } else if quote.is_none() && (ch == '\'' || ch == '"') { + quote = Some(ch); + } else { + result.push(ch); + } + } + if quote.is_some() || escaped { + return Err("unmatched quote or escape in input".into()); + } + Ok(result) +} +fn main() { + std::process::exit(match run() { + Ok(code) => code, + Err(e) => { + eprintln!("xargs: {e}"); + 1 + } + }); +} diff --git a/userland/findutils/test.nu b/userland/findutils/test.nu new file mode 100644 index 0000000..9bb0c8c --- /dev/null +++ b/userland/findutils/test.nu @@ -0,0 +1,31 @@ +#!/usr/bin/env nu +# Compare xargs stdout and exit codes with GNU xargs in a real world. +def main [host: path, bundle: path] { + let world = (mktemp -d) + mkdir ($world | path join world) + cp -r ($bundle | path join bin) ($world | path join world bin) + let nul = (char nul) + let cases = [ + {input: "a\nb\n", args: [-I{} printf '<%s>\n' '{}']} + {input: "a b\nc d\n", args: [-I REPL printf '<%s>\n' preREPLpost REPL]} + {input: " 'a b'\nc\\ d\n\n", args: [-I{} printf '<%s>\n' '{}']} + {input: $"'a b'($nul)two\nlines($nul)", args: ['-0' -I{} printf '<%s>\n' '{}']} + {input: $"a b($nul)c($nul)d($nul)", args: ['-0' -n 2 printf '<%s>\n']} + {input: "a b c\n", args: [-n 2 echo]} + {input: "'a b' c\n", args: [-n1 printf '<%s>\n']} + {input: '', args: [echo]} + {input: '', args: [-r echo]} + {input: '', args: [-I{} echo '{}']} + {input: "a\n", args: [sh -c 'exit 7']} + ] + for case in $cases { + $case.input | save -f ($world | path join world input) + let native = ($case.input | ^xargs ...$case.args | complete) + let command = ($case.args | each {|arg| $arg | into string | to json -r} | str join ' ') + let guest = (^$host sh $world -c $"xargs ($command) < /input" | complete) + if $native.stdout != $guest.stdout or $native.exit_code != $guest.exit_code { + error make {msg: $"xargs mismatch ($case): native=($native) guest=($guest). World: ($world)"} + } + print $"PASS xargs ($case.args)" + } +} diff --git a/userland/git/README.md b/userland/git/README.md index 0512101..365a211 100644 --- a/userland/git/README.md +++ b/userland/git/README.md @@ -49,6 +49,18 @@ The component uses WASI's `exit-with-code`, not a custom host import. The world host must enable that optional WASI function. The tests pass explicit `-C` paths. +## Identity + +The host creates `/etc/gitconfig` when it is absent. +It sets `user.name = Sausage` and `user.email = sausage@localhost`. +Git reads this system configuration unless `GIT_CONFIG_NOSYSTEM` disables it. +Local repository configuration overrides these defaults. The host preserves an existing system configuration. +The `Sausage-Conversation` trailer retains conversation attribution. + +Reflog-only operations use a fallback committer when no identity exists. +They include reset, checkout, branch changes, and reference updates. +New commits and annotated tags still require an identity. + ## Agent command forms The comparison suite covers per-command `-c key=value` overrides and quiet local commands. @@ -56,13 +68,16 @@ It checks multiple revisions, detached status, branch deletion messages, and mer It also checks these forms: - `status -sb` and `status --porcelain=v1`. -- `log -1`, `-n`, `--oneline`, `-p`, `--stat`, and one literal path after `--`. +- `log -N`, `log -n N`, `--max-count=N`, `--oneline`, `-p`, `--stat`, and one literal path after `--`. - `log --format` and `--pretty=format:` with `%H %h %s %an %ae %ad %d`. - `log A..B`, `--name-only`, `--no-renames`, and `--diff-merges=first-parent`. - `%x00`, `%x1e`, and `%(trailers:key=Sausage-Conversation,valueonly)` in log formats. -- `show --stat`, `show HEAD:path`, and merge commits without a combined diff. +- `show --stat`, `--oneline`, `--name-only`, `show HEAD:path`, and merge commits without a combined diff. +- `cat-file -t`, `-p`, `-s`, and `-e` for object inspection. +- `ls-tree [-r] [--name-only] [path]` for literal paths. - `diff HEAD~1`, `diff A..B`, `--name-only`, `--name-status`, and one literal path after `--`. -- `branch -a`, `-d`, `-D`, `-m`, and `--show-current`. +- `diff --check` and `diff --cached --check` for added whitespace errors and conflict markers. +- `branch -a`, `-d`, `-D`, `-f`, `-m`, and `--show-current`. - `checkout -b`, `switch -c`, and `merge --no-ff`. - `rev-parse --short HEAD`, `ls-files`, and `config --get`. - Quiet `init`, `commit`, branch deletion, `checkout`, `switch`, `merge`, and `rebase`. diff --git a/userland/git/src/branches.rs b/userland/git/src/branches.rs index a8f3c4c..c50d385 100644 --- a/userland/git/src/branches.rs +++ b/userland/git/src/branches.rs @@ -57,30 +57,54 @@ pub(crate) struct BranchDeleted { pub reference: String, } -/// Reflogs require an identity, but switching branches must not require configuring one. -/// With no repository identity, leave reflogs untouched and report that explicitly; never invent an author. +/// Reflog-only writes use a fallback identity when the repository has none. +pub(crate) fn reflog_signature(repo: &gix::Repository) -> Result { + if let Some(signature) = repo + .committer() + .transpose() + .map_err(|e| error("reflog identity", e))? + { + return signature + .to_owned() + .map_err(|e| error("reflog identity", e)); + } + let config = repo.config_snapshot(); + let name = std::env::var("GIT_COMMITTER_NAME") + .ok() + .or_else(|| config.string("user.name").map(|s| s.to_string())) + .or_else(|| std::env::var("USER").ok()) + .unwrap_or_else(|| "Sausage".into()); + let email = std::env::var("GIT_COMMITTER_EMAIL") + .ok() + .or_else(|| config.string("user.email").map(|s| s.to_string())) + .unwrap_or_else(|| { + format!( + "{}@{}", + std::env::var("USER").unwrap_or_else(|_| "sausage".into()), + std::env::var("HOSTNAME").unwrap_or_else(|_| "localhost".into()) + ) + }); + let seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + Ok(gix::actor::Signature { + name: name.into(), + email: email.into(), + time: gix::date::Time::new(seconds, 0), + }) +} + +/// Publish references and their reflog entries without requiring a commit identity. pub(crate) fn edit_refs( repo: &gix::Repository, edits: Vec, ) -> Result> { - let committer = repo - .committer() - .transpose() - .map_err(|e| error("reflog identity", e))?; - if let Some(committer) = committer { - repo.edit_references_as(edits, Some(committer)) - .map_err(|e| error("checkout refs", e))?; - Ok(Vec::new()) - } else { - let mut local = repo.clone(); - local.refs.write_reflog = gix::refs::store::WriteReflog::Disable; - local - .edit_references_as(edits, None) - .map_err(|e| error("checkout refs", e))?; - Ok(vec![ - "reflog not written: no repository committer identity configured".into(), - ]) - } + let signature = reflog_signature(repo)?; + let mut time = Default::default(); + repo.edit_references_as(edits, Some(signature.to_ref(&mut time))) + .map_err(|e| error("checkout refs", e))?; + Ok(Vec::new()) } pub(crate) fn branch_name(name: &str) -> Result { diff --git a/userland/git/src/changes.rs b/userland/git/src/changes.rs index 6b2f91f..a3d6002 100644 --- a/userland/git/src/changes.rs +++ b/userland/git/src/changes.rs @@ -618,6 +618,113 @@ fn unified(a: &str, b: &str, context: usize) -> String { out } +/// Git's default whitespace checks apply only to added lines. +pub fn check( + repo: &gix::Repository, + diffs: &[FileDiff], + revision: Option<&str>, + cached: bool, +) -> Result { + let snapshot = if let Some(rev) = revision { + Some(tree(repo, rev)?) + } else if cached { + Some(index(repo)?.0) + } else { + None + }; + let mut failed = false; + for diff in diffs.iter().filter(|diff| !diff.binary) { + let mut number = 0; + let mut added = BTreeSet::new(); + for line in diff.patch.split_terminator('\n') { + if line.starts_with("@@ ") { + number = line + .split_whitespace() + .nth(2) + .unwrap() + .trim_start_matches('+') + .split(',') + .next() + .unwrap() + .parse::() + .unwrap(); + } else if number > 0 && line.starts_with('+') { + let text = &line[1..]; + if text.trim_ascii().is_empty() { + added.insert(number); + } + let indent = text + .chars() + .take_while(|ch| *ch == ' ' || *ch == '\t') + .collect::(); + let mut errors = Vec::new(); + if text.ends_with(|ch: char| ch.is_ascii_whitespace()) { + errors.push("trailing whitespace"); + } + if !text.trim_ascii().is_empty() && indent.contains(" \t") { + errors.push("space before tab in indent"); + } + if text.starts_with("<<<<<<< ") || text == "=======" || text.starts_with(">>>>>>> ") + { + println!("{}:{number}: leftover conflict marker", diff.path); + failed = true; + } + if !errors.is_empty() { + println!("{}:{number}: {}.", diff.path, errors.join(", ")); + println!("{line}"); + failed = true; + } + number += 1; + } else if number > 0 && line.starts_with(' ') { + number += 1; + } + } + if added.is_empty() { + continue; + } + let bytes = if let Some(ref snapshot) = snapshot { + blob(repo, snapshot.get(diff.path.as_bytes()))? + } else if diff.status == Kind::Deleted { + Vec::new() + } else { + let file = repo.workdir().unwrap().join(&diff.path); + if std::fs::symlink_metadata(&file) + .map_err(|e| error("diff check", e))? + .file_type() + .is_symlink() + { + gix::path::into_bstr(std::fs::read_link(&file).map_err(|e| error("diff check", e))?) + .map_err(|e| error("diff check", e))? + .into_owned() + .to_vec() + } else { + std::fs::read(file).map_err(|e| error("diff check", e))? + } + }; + let text = String::from_utf8_lossy(&bytes); + let mut lines: Vec<_> = text.lines().collect(); + if !text.ends_with('\n') + && lines + .last() + .is_some_and(|line| line.trim_ascii().is_empty()) + { + lines.pop(); + } + let first = lines + .iter() + .enumerate() + .rev() + .take_while(|(_, line)| line.trim_ascii().is_empty()) + .filter_map(|(i, _)| added.contains(&(i + 1)).then_some(i + 1)) + .min(); + if let Some(line) = first { + println!("{}:{line}: new blank line at EOF.", diff.path); + failed = true; + } + } + Ok(if failed { 2 } else { 0 }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/userland/git/src/inspect.rs b/userland/git/src/inspect.rs new file mode 100644 index 0000000..d878128 --- /dev/null +++ b/userland/git/src/inspect.rs @@ -0,0 +1,92 @@ +//! Read Git object types, sizes, contents, and tree entries without changing repository state. +use crate::{Result, repo::error}; +use std::io::Write; + +pub(crate) fn cat_file( + repo: &gix::Repository, + revision: &str, + kind: bool, + size: bool, + exists: bool, +) -> Result<()> { + let object = repo + .rev_parse_single(revision) + .map_err(|e| error("cat-file", e))? + .object() + .map_err(|e| error("cat-file", e))?; + if exists { + return Ok(()); + } + if kind { + println!("{}", object.kind); + } else if size { + println!("{}", object.data.len()); + } else if object.kind == gix::objs::Kind::Tree { + ls_tree(repo, revision, false, false, None)?; + } else { + std::io::stdout() + .write_all(&object.data) + .map_err(|e| error("cat-file", e))?; + } + Ok(()) +} + +pub(crate) fn ls_tree( + repo: &gix::Repository, + revision: &str, + recursive: bool, + names: bool, + path: Option<&str>, +) -> Result<()> { + let id = crate::local::tree(repo, revision)?; + fn walk( + repo: &gix::Repository, + id: gix::ObjectId, + prefix: &str, + recursive: bool, + names: bool, + filter: Option<&str>, + ) -> Result<()> { + let tree = repo.find_tree(id).map_err(|e| error("ls-tree", e))?; + for entry in tree.iter() { + let entry = entry.map_err(|e| error("ls-tree", e))?; + let filename = format!("{prefix}{}", entry.filename()); + let directory = entry.mode().is_tree(); + let descend = directory + && (recursive + || filter.is_some_and(|path| path.starts_with(&format!("{filename}/")))); + if descend { + walk( + repo, + entry.oid().to_owned(), + &format!("{filename}/"), + recursive, + names, + filter, + )?; + } else if filter.is_none_or(|path| { + filename == path + || filename.starts_with(&format!("{}/", path.trim_end_matches('/'))) + }) { + if names { + println!("{filename}"); + } else { + let kind = if directory { + "tree" + } else if entry.mode().is_commit() { + "commit" + } else { + "blob" + }; + println!( + "{:06o} {kind} {}\t{filename}", + entry.mode().kind() as u32, + entry.oid() + ); + } + } + } + Ok(()) + } + walk(repo, id, "", recursive, names, path) +} diff --git a/userland/git/src/lib.rs b/userland/git/src/lib.rs index 6689eec..da2d7cf 100644 --- a/userland/git/src/lib.rs +++ b/userland/git/src/lib.rs @@ -59,6 +59,7 @@ fn identity(name: Option, email: Option) -> Result { mod cherry_pick; mod history; mod local; +mod inspect; mod plumbing; mod stash; use clap::{Parser, Subcommand}; @@ -79,8 +80,32 @@ enum Command { Show { #[arg(long)] stat: bool, + #[arg(long)] + oneline: bool, + #[arg(long)] + name_only: bool, revision: Option, }, + #[command(group(clap::ArgGroup::new("mode").required(true).multiple(false)))] + CatFile { + #[arg(short = 't', group = "mode")] + kind: bool, + #[arg(short = 'p', group = "mode")] + pretty: bool, + #[arg(short = 's', group = "mode")] + size: bool, + #[arg(short = 'e', group = "mode")] + exists: bool, + revision: String, + }, + LsTree { + #[arg(short = 'r')] + recursive: bool, + #[arg(long)] + name_only: bool, + revision: String, + path: Option, + }, Clone { url: String, path: Option, @@ -207,7 +232,7 @@ enum Command { diff_merges: Option, #[arg(long)] oneline: bool, - #[arg(short = 'n')] + #[arg(short = 'n', long = "max-count")] limit: Option, #[arg(short = '1')] one: bool, @@ -224,6 +249,8 @@ enum Command { paths: Vec, }, Diff { + #[arg(long)] + check: bool, #[arg(long)] numstat: bool, #[arg(long)] @@ -255,6 +282,8 @@ enum Command { delete: bool, #[arg(short = 'D')] force_delete: bool, + #[arg(short = 'f', long)] + force: bool, #[arg(short = 'm')] rename: bool, name: Option, @@ -894,6 +923,7 @@ pub fn run(cli: Cli) -> Result { } } Command::Diff { + check, numstat, no_renames, diff_filter, @@ -921,13 +951,16 @@ pub fn run(cli: Cli) -> Result { changes::DiffParams { repo: path, from, - to, + to: to.clone(), staged: Some(cached), path: paths.first().cloned(), context: None, }, !no_renames, )?; + if check { + return changes::check(&repository, &diffs, to.as_deref(), cached); + } if name_only || name_status { for diff in diffs { if name_status { @@ -1118,6 +1151,7 @@ pub fn run(cli: Cli) -> Result { list, delete, force_delete, + force, show_current, all, rename, @@ -1158,6 +1192,14 @@ pub fn run(cli: Cli) -> Result { if !quiet { println!("Deleted branch {name} (was {}).", &id[..7]); } + } else if force { + let full = branches::branch_name(&name)?; + worktrees::ensure_available(&repository, &full)?; + if head_info(&repository)?.branch.as_deref() == Some(&full) { + return Err(error("branch", "cannot force update current branch")); + } + let id = branches::resolve(&repository, start.as_deref().unwrap_or("HEAD"))?; + plumbing::update_ref(&repository, &full, &id.to_string(), None)?; } else { branches::create( &repository, @@ -1482,7 +1524,35 @@ pub fn run(cli: Cli) -> Result { )?; } }, - Command::Show { revision, stat } => { + Command::CatFile { + kind, + pretty: _, + size, + exists, + revision, + } => { + inspect::cat_file(&repository, &revision, kind, size, exists)?; + } + Command::LsTree { + recursive, + name_only, + revision, + path, + } => { + inspect::ls_tree( + &repository, + &revision, + recursive, + name_only, + path.as_deref(), + )?; + } + Command::Show { + revision, + stat, + oneline, + name_only, + } => { let object = repository .rev_parse_single(revision.as_deref().unwrap_or("HEAD")) .map_err(|e| error("show", e))? @@ -1522,9 +1592,18 @@ pub fn run(cli: Cli) -> Result { return Ok(129); } } - output::commit(&commit)?; - println!(); - if merge_commit && !stat { + if oneline { + let decoded = commit.decode().map_err(|e| error("show", e))?; + println!( + "{} {}", + &commit.id.to_string()[..7], + decoded.message.to_str_lossy().lines().next().unwrap_or("") + ); + } else { + output::commit(&commit)?; + println!(); + } + if merge_commit && !stat && !name_only { return Ok(0); } let old = match commit.parent_ids().next() { @@ -1542,7 +1621,11 @@ pub fn run(cli: Cli) -> Result { path: None, context: None, })?; - if stat { + if name_only { + for diff in diffs { + println!("{}", diff.path); + } + } else if stat { output::stat(&diffs, false); } else { for diff in diffs { diff --git a/userland/git/src/local.rs b/userland/git/src/local.rs index 7cb205d..1c56cfe 100644 --- a/userland/git/src/local.rs +++ b/userland/git/src/local.rs @@ -118,7 +118,7 @@ pub(crate) fn reset( repo, Some(gix::ObjectId::from_hex(old.as_bytes()).map_err(|e| error("reset HEAD", e))?), id, - &staging::signature(None, None)?, + &branches::reflog_signature(repo)?, "reset", )?; } diff --git a/userland/git/src/main.rs b/userland/git/src/main.rs index b520446..67d5b7a 100644 --- a/userland/git/src/main.rs +++ b/userland/git/src/main.rs @@ -1,11 +1,38 @@ //! Parse Git CLI arguments and return Git-compatible usage and operation exit codes. use clap::{Parser, error::ErrorKind}; fn main() { - if matches!(std::env::args().nth(1).as_deref(), Some("--version" | "version")) { + if matches!( + std::env::args().nth(1).as_deref(), + Some("--version" | "version") + ) { println!("git version {}.sausage", env!("CARGO_PKG_VERSION")); exit(0); } - let cli = match git::Cli::try_parse() { + let mut args: Vec<_> = std::env::args_os().collect(); + let mut command = 1; + while command < args.len() && args[command].to_string_lossy().starts_with('-') { + command += if args[command] == "-C" || args[command] == "-c" { + 2 + } else { + 1 + }; + } + if args.get(command).is_some_and(|arg| arg == "log") { + let mut i = command + 1; + while i < args.len() && args[i] != "--" { + let arg = args[i].to_string_lossy(); + if let Some(count) = arg + .strip_prefix('-') + .filter(|s| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())) + { + let count = count.to_owned(); + args.splice(i..=i, ["-n".into(), count.into()]); + i += 1; + } + i += 1; + } + } + let cli = match git::Cli::try_parse_from(args) { Ok(cli) => cli, Err(error) => { let code = if !error.use_stderr() { diff --git a/userland/git/test.nu b/userland/git/test.nu index b1ce75d..b90fe1c 100644 --- a/userland/git/test.nu +++ b/userland/git/test.nu @@ -16,7 +16,7 @@ def main [component: path, server: path = '/tmp/p1-git/server-target/debug/git-t let ready = ($root | path join port) let server_job = (job spawn { ^$server $server_root $ready }) let deadline = ((date now) + 10sec) - while not ($ready | path exists) { if (date now) > $deadline { job kill $server_job; error make {msg: 'HTTP fixture did not start'} } } + while (not ($ready | path exists)) or (open --raw $ready | str trim | is-empty) { if (date now) > $deadline { job kill $server_job; error make {msg: 'HTTP fixture did not start'} } } let url = $'http://127.0.0.1:(open --raw $ready | str trim)/coord.git' try { let envs = {GIT_AUTHOR_NAME: 'Agent', GIT_AUTHOR_EMAIL: 'agent@example.test', GIT_COMMITTER_NAME: 'Agent', GIT_COMMITTER_EMAIL: 'agent@example.test', GIT_AUTHOR_DATE: '1700000000 +0000', GIT_COMMITTER_DATE: '1700000000 +0000', GIT_CONFIG_NOSYSTEM: '1', GIT_EDITOR: 'true', HOME: '/tmp/p1-git/empty-home', LC_ALL: 'C', TZ: 'UTC'} @@ -64,6 +64,22 @@ def main [component: path, server: path = '/tmp/p1-git/server-target/debug/git-t {args: [log -n 1 --stat], compare: true} {args: [log --oneline -- file.txt], compare: true} {args: [show --stat], compare: true} + {args: [show --oneline], compare: true} + {args: [show --name-only], compare: true} + {args: [show --oneline --stat], compare: true} + {args: [cat-file -t HEAD], compare: true} + {args: [cat-file -p HEAD], compare: true} + {args: [cat-file -s HEAD], compare: true} + {args: [cat-file -e HEAD], compare: true} + {args: [cat-file -e absent], compare: false, exit: 128} + {args: [cat-file -t 'HEAD:file.txt'], compare: true} + {args: [cat-file -p 'HEAD:file.txt'], compare: true} + {args: [cat-file -s 'HEAD:file.txt'], compare: true} + {args: [cat-file -p 'HEAD^{tree}'], compare: true} + {args: [ls-tree HEAD], compare: true} + {args: [ls-tree -r HEAD], compare: true} + {args: [ls-tree --name-only HEAD], compare: true} + {args: [ls-tree -r --name-only HEAD file.txt], compare: true} {args: [rev-parse --short HEAD], compare: true} {args: [show HEAD], compare: true} {args: [show HEAD:file.txt], compare: true} @@ -73,6 +89,11 @@ def main [component: path, server: path = '/tmp/p1-git/server-target/debug/git-t {args: [add .], compare: true} {args: [commit -m topic], compare: true} {args: [rev-parse HEAD HEAD~1], compare: true} + {args: [log -1], compare: true} + {args: [log -2], compare: true} + {args: [log -n 2], compare: true} + {args: [log --oneline -5], compare: true} + {args: [log --max-count=1 --oneline], compare: true} {args: [diff HEAD~1], compare: true} {args: [diff HEAD~1..HEAD], compare: true} {args: [merge-base HEAD~1 HEAD], compare: true} @@ -313,6 +334,18 @@ def main [component: path, server: path = '/tmp/p1-git/server-target/debug/git-t {args: [log -n 1 --name-only --no-renames --diff-merges=first-parent '--format=%x1e%H%x00%s%x00%(trailers:key=Sausage-Conversation,valueonly)%x00'], compare: true} {args: [commit --allow-empty -m no-trailer], compare: true, state: true} {args: [log --name-only --no-renames --diff-merges=first-parent '--format=%x1e%H%x00%s%x00%(trailers:key=Sausage-Conversation,valueonly)%x00' HEAD~3..HEAD], compare: true} + {args: [diff --check], compare: true} + {args: [diff --cached --check], compare: true} + {write: 'clean\nbad \n indent\n\n', file: 'file.txt'} + {args: [diff --check], compare: true, exit: 2} + {args: [add file.txt], compare: true} + {args: [diff --cached --check], compare: true, exit: 2} + {write: 'clean\n', file: 'file.txt'} + {args: [diff --check], compare: true} + {args: [add file.txt], compare: true} + {args: [diff --cached --check], compare: true} + {write: 'clean\n indent \n<<<<<<< HEAD\n=======\n>>>>>>> other\n', file: 'file.txt'} + {args: [diff --check], compare: true, exit: 2} {args: [status --bad-option], compare: true, exit: 129} {args: [not-a-command], compare: true, exit: 1} ] @@ -421,6 +454,56 @@ def main [component: path, server: path = '/tmp/p1-git/server-target/debug/git-t let equal = ($native_result.exit_code == 0 and $result.exit_code == 129 and $result.stdout == '' and ($result.stderr | str contains 'usage')) print $'show combined merge: (if $equal {"PASS documented usage"} else {"FAIL documented usage"}) native=($native_result.exit_code) wasi=($result.exit_code)' if not $equal { $failures += 1; print ($result | to json) } + # Whitespace checks read symlink text, not the target of a dangling symlink. + ^ln -sfn missing ($native | path join check-link) + ^git -C $native add check-link + for target in ['missing ' ' '] { + ^ln -sfn $target ($native | path join check-link) + for args in [[diff --check] [add check-link] [diff --cached --check]] { + let expected = (^git -C $native ...$args | complete) + let actual = (^wasmtime run -S http=y -S cli-exit-with-code=y --dir $'($native)::/links' $component -C /links ...$args | complete) + let equal = ($expected.stdout == $actual.stdout and $expected.exit_code == $actual.exit_code) + print $'symlink ($args): (if $equal {"PASS"} else {"FAIL"})' + if not $equal { $failures += 1; print ($expected | to json); print ($actual | to json) } + } + } + # Reflog-only writes do not require the strict identity that new commits require. + let no_identity = ($root | path join no-identity) + mkdir $no_identity + ^git -C $no_identity init -q -b main + 'base' | save ($no_identity | path join file.txt) + ^git -C $no_identity add file.txt + ^git -C $no_identity commit --no-gpg-sign -q -m base + mkdir ($no_identity | path join nested/sub) + 'nested' | save ($no_identity | path join nested/sub/file.txt) + ^git -C $no_identity add . + let inspection_tree = (^git -C $no_identity write-tree | str trim) + for args in [[ls-tree $inspection_tree] [ls-tree -r $inspection_tree] [ls-tree --name-only $inspection_tree nested] [ls-tree -r --name-only $inspection_tree nested] [ls-tree $inspection_tree nested/sub/file.txt] [cat-file -p $inspection_tree]] { + let expected = (^git -C $no_identity ...$args | complete) + let actual = (^wasmtime run -S http=y -S cli-exit-with-code=y --dir $'($no_identity)::/repo' $component -C /repo ...$args | complete) + let equal = ($expected.exit_code == 0 and $actual.exit_code == 0 and $expected.stdout == $actual.stdout) + print $'nested tree ($args): (if $equal {"PASS"} else {"FAIL"})' + if not $equal { $failures += 1; print ($expected | to json); print ($actual | to json) } + } + ^git -C $no_identity reset --hard -q HEAD + ^git -C $no_identity config user.useConfigOnly true + let identity_failures = (do { + mut failed = 0 + hide-env -i GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL GIT_COMMITTER_NAME GIT_COMMITTER_EMAIL + for args in [[reset --hard HEAD] [checkout -q --detach HEAD] [checkout -q main] [branch -f other HEAD] [update-ref refs/heads/other HEAD]] { + let expected = (^git -C $no_identity ...$args | complete) + let actual = (^wasmtime run -S http=y -S cli-exit-with-code=y --dir $'($no_identity)::/repo' $component -C /repo ...$args | complete) + let equal = ($expected.exit_code == 0 and $actual.exit_code == 0 and $expected.stdout == $actual.stdout) + print $'no identity ($args): (if $equal {"PASS"} else {"FAIL"})' + if not $equal { $failed += 1; print ($expected | to json); print ($actual | to json) } + } + let expected = (^git -C $no_identity commit --allow-empty -m missing | complete) + let actual = (^wasmtime run -S http=y -S cli-exit-with-code=y --dir $'($no_identity)::/repo' $component -C /repo commit --allow-empty -m missing | complete) + if $expected.exit_code != 128 or $actual.exit_code != 128 { $failed += 1; print ($expected | to json); print ($actual | to json) } + print 'no identity commit requires identity: checked' + $failed + }) + $failures += $identity_failures if $failures > 0 {error make {msg: $'($failures) workflow comparisons failed'}} } } catch {|err| job kill $server_job; error make {msg: $err.msg} } diff --git a/vfs/src/generated.rs b/vfs/src/generated.rs index 4fc569d..7baddbc 100644 --- a/vfs/src/generated.rs +++ b/vfs/src/generated.rs @@ -1,4 +1,4 @@ -//! Read-only generated files. Each open captures an independent finite snapshot. +//! Generated snapshots and standard byte devices. Device writes have no persistent effect. use crate::*; use std::{collections::BTreeMap, io::ErrorKind as E}; type Generator = Arc Result> + Send + Sync>; @@ -6,7 +6,7 @@ type Generator = Arc Result> + Send + Sync>; /// Generators must be cheap and must not block. Stat can invoke them as well as open. #[derive(Default)] pub struct Generated { - files: BTreeMap, + files: BTreeMap, } impl Generated { pub fn insert( @@ -14,7 +14,13 @@ impl Generated { path: &str, generate: impl Fn() -> Result> + Send + Sync + 'static, ) { - self.files.insert(resolve("/", path), Arc::new(generate)); + self.files + .insert(resolve("/", path), Source::Snapshot(Arc::new(generate))); + } + /// Insert an endless byte device (or the empty null device). Writes are discarded. + pub fn insert_device(&mut self, path: &str, device: Device) { + self.files + .insert(resolve("/", path), Source::Device(device)); } fn directory(&self, path: &str) -> bool { path == "/" @@ -24,6 +30,25 @@ impl Generated { .any(|p| p.starts_with(&format!("{path}/"))) } } +enum Source { + Snapshot(Generator), + Device(Device), +} +impl Source { + fn file(&self) -> Result> { + match self { + Self::Snapshot(generate) => Ok(Arc::new(Snapshot(generate()?))), + Self::Device(device) => Ok(Arc::new(*device)), + } + } +} +/// Standard byte sources. All three discard writes and report zero size. +#[derive(Clone, Copy)] +pub enum Device { + Null, + Zero, + Random, +} struct Snapshot(Vec); fn readonly() -> Result { Err(error(E::ReadOnlyFilesystem)) @@ -32,7 +57,7 @@ fn readonly() -> Result { impl Layer for Generated { async fn stat(&self, path: &str, _follow: bool) -> Result { if let Some(f) = self.files.get(path) { - return Snapshot(f()?).stat().await; + return f.file()?.stat().await; } if !self.directory(path) { return Err(error(E::NotFound)); @@ -77,14 +102,17 @@ impl Layer for Generated { .collect()) } async fn open(&self, path: &str, o: Open) -> Result> { - if o.write || o.create || o.truncate { - return readonly(); - } if let Some(f) = self.files.get(path) { + if matches!(f, Source::Snapshot(_)) && (o.write || o.create || o.truncate) { + return readonly(); + } if o.directory { return Err(error(E::NotADirectory)); } - return Ok(Arc::new(Snapshot(f()?))); + return f.file(); + } + if o.write || o.create || o.truncate { + return readonly(); } if self.directory(path) { return Memory::default().open("/", o).await; @@ -137,3 +165,38 @@ impl File for Snapshot { readonly() } } +#[async_trait] +impl File for Device { + async fn stat(&self) -> Result { + Snapshot(Vec::new()).stat().await + } + async fn read(&self, _: u64, length: usize) -> Result> { + let mut bytes = vec![ + 0; + if matches!(self, Self::Null) { + 0 + } else { + length + } + ]; + if matches!(self, Self::Random) { + getrandom::fill(&mut bytes).map_err(|e| std::io::Error::other(e.to_string()))?; + } + Ok(bytes) + } + async fn write(&self, _: u64, bytes: Vec) -> Result { + Ok(bytes.len()) + } + async fn append(&self, bytes: Vec) -> Result { + Ok(bytes.len()) + } + async fn set_size(&self, _: u64) -> Result<()> { + Ok(()) + } + async fn sync(&self) -> Result<()> { + Ok(()) + } + async fn set_times(&self, _: Option, _: Option) -> Result<()> { + Ok(()) + } +} diff --git a/vfs/src/lib.rs b/vfs/src/lib.rs index 9245c6a..004c24d 100644 --- a/vfs/src/lib.rs +++ b/vfs/src/lib.rs @@ -13,7 +13,7 @@ mod readonly; mod streams; pub use async_trait::async_trait; -pub use generated::Generated; +pub use generated::{Device, Generated}; pub use host::Host; pub use memory::Memory; pub use mounts::Mounts; diff --git a/vfs/src/preview1/README.md b/vfs/src/preview1/README.md index 4c86968..b6ec7cd 100644 --- a/vfs/src/preview1/README.md +++ b/vfs/src/preview1/README.md @@ -27,17 +27,15 @@ Directory descriptors retain their original paths after a rename. Hard links, symlink creation, file preallocation, signals, and sockets return `NOTSUP`. Preview1 does not expose the platform's WASI 0.2 sockets. -The integration test uses the existing S6 and lab guests: - -- esbuild v0.27.7: `/tmp/s6-esbuild/esbuild.wasm` and `/tmp/s6-esbuild/esbuild-native`. -- uutils 0.12.0: `/tmp/wasi-userland-lab/uu/coreutils-0.12.0-wasm32-wasip1/coreutils.wasm`. -- QuickJS-ng v0.18.0: `/tmp/wasi-userland-lab/qjs-wasi.wasm`. - -The QuickJS-ng input matches its recorded release SHA-256. -The S6 fixture also requires `/tmp/s6-esbuild/app/node_modules`. +The integration test needs no prepared files under `/tmp`. +It uses the bundled `userland/qjs/qjs.wasm` for file operations and JavaScript execution. +It builds native and WASI esbuild from the pinned, vendored source under `userland/esbuild/source`. +Go reuses its persistent build cache. The test stores the executables under `CARGO_TARGET_TMPDIR/preview1-fixtures`. +It installs the S6 app dependencies with `npm ci` and the committed lockfile. It stores local Wasmtime compilation artifacts in a persistent test cache. -Run it from the repository root: +The development shell supplies Go, Node.js, and npm. +Run it from the repository root with your executor's target directory and build slot: ```sh -CARGO_TARGET_DIR=target/preview1 nix develop -c cargo test -p sausage-vfs --test preview1 -- --nocapture +CARGO_BUILD_JOBS=2 CARGO_TARGET_DIR=/path/to/shared/target/executor nu /path/to/.board/slot.nu nix develop -c cargo test -p sausage-vfs --test preview1 -- --nocapture ``` diff --git a/vfs/tests/devices.rs b/vfs/tests/devices.rs new file mode 100644 index 0000000..31c7b84 --- /dev/null +++ b/vfs/tests/devices.rs @@ -0,0 +1,47 @@ +//! Standard generated devices discard writes and retain their read behavior across opens. +use sausage_vfs::{Device, Generated, Layer, Open}; + +#[tokio::test] +async fn generated_devices() { + let mut layer = Generated::default(); + for (name, device) in [ + ("null", Device::Null), + ("zero", Device::Zero), + ("urandom", Device::Random), + ] { + layer.insert_device(&format!("/{name}"), device); + } + assert_eq!(layer.list("/").await.unwrap().len(), 3); + for name in ["null", "zero", "urandom"] { + let file = layer + .open( + &format!("/{name}"), + Open { + write: true, + create: true, + truncate: true, + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!(file.write(100, b"discard".to_vec()).await.unwrap(), 7); + assert_eq!(file.append(b"discard".to_vec()).await.unwrap(), 7); + file.set_size(100).await.unwrap(); + file.sync().await.unwrap(); + assert_eq!(file.stat().await.unwrap().size, 0); + let reopened = layer + .open(&format!("/{name}"), Open::default()) + .await + .unwrap(); + let bytes = reopened.read(100, 64).await.unwrap(); + match name { + "null" => assert!(bytes.is_empty()), + "zero" => assert_eq!(bytes, vec![0; 64]), + _ => { + assert_eq!(bytes.len(), 64); + assert_ne!(bytes, reopened.read(0, 64).await.unwrap()); + } + } + } +} diff --git a/vfs/tests/preview1.rs b/vfs/tests/preview1.rs index 906e154..b1082a7 100644 --- a/vfs/tests/preview1.rs +++ b/vfs/tests/preview1.rs @@ -14,8 +14,9 @@ fn engine() -> &'static Engine { static E: OnceLock = OnceLock::new(); E.get_or_init(Engine::default) } -fn module(path: &str) -> Module { - let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("required guest {path}: {e}")); +fn module(path: &Path) -> Module { + let bytes = + std::fs::read(path).unwrap_or_else(|e| panic!("required guest {}: {e}", path.display())); let mut hash = std::collections::hash_map::DefaultHasher::new(); bytes.hash(&mut hash); engine().precompile_compatibility_hash().hash(&mut hash); @@ -230,51 +231,30 @@ async fn poll_preserves_input_and_returns_clock() { #[tokio::test(flavor = "multi_thread")] async fn userland_files() { - let guest = module("/tmp/wasi-userland-lab/uu/coreutils-0.12.0-wasm32-wasip1/coreutils.wasm"); + let qjs = module(&Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland/qjs/qjs.wasm")); let root = Arc::new(Memory::default()); - run(&guest, root.clone(), "/", &["coreutils", "mkdir", "/work"]).await; - run( - &guest, - root.clone(), - "/work", - &["coreutils", "touch", "input"], - ) - .await; - let input = root - .open( - "/work/input", - Open { - write: true, - ..Open::default() - }, - ) - .await - .unwrap(); - input.write(0, b"hello preview1\n".to_vec()).await.unwrap(); - run( - &guest, - root.clone(), - "/work", - &["coreutils", "cp", "input", "copy"], - ) - .await; - let (cat, _) = run(&guest, root.clone(), "/work", &["coreutils", "cat", "copy"]).await; - assert_eq!(cat, "hello preview1\n"); - let (ls, _) = run( - &guest, - root.clone(), - "/work", - &["coreutils", "ls", "-la", "."], - ) - .await; - assert!(ls.contains("copy") && ls.contains("input"), "{ls}"); - run(&guest, root.clone(), "/work", &["coreutils", "rm", "copy"]).await; + let script = r#" + if (os.mkdir('/work') !== 0) throw Error('mkdir'); + let f = std.open('/work/input', 'w'); f.puts('hello preview1\n'); f.close(); + f = std.open('/work/input', 'r'); let text = f.readAsString(); f.close(); + let out = std.open('/work/copy', 'w'); out.puts(text); out.close(); + f = std.open('/work/copy', 'r'); console.log(f.readAsString().trim()); f.close(); + let [entries, error] = os.readdir('/work'); + if (error) throw Error('readdir'); + console.log(entries.sort().join(',')); + if (os.stat('/work/copy')[0].size !== 15) throw Error('stat'); + if (os.remove('/work/copy') !== 0) throw Error('remove'); + "#; + let (out, _) = run(&qjs, root.clone(), "/", &["qjs", "--std", "-e", script]).await; + assert!( + out.starts_with("hello preview1\n") && out.contains("copy") && out.contains("input"), + "{out}" + ); assert_eq!( root.stat("/work/copy", true).await.unwrap_err().kind(), std::io::ErrorKind::NotFound ); - println!("PASS uutils mkdir touch cp cat ls -la rm"); - let qjs = module("/tmp/wasi-userland-lab/qjs-wasi.wasm"); + println!("PASS QuickJS mkdir create copy read stat readdir remove"); let script = b"let f = std.open('input', 'r'); let text = f.readAsString(); f.close(); let out = std.open('js-output', 'w'); out.puts(text.toUpperCase()); out.close(); console.log('QuickJS files ok');"; let file = root .open( @@ -308,13 +288,39 @@ async fn esbuild_bundle() { std::fs::copy(fixture.join(name), dir.path().join(name)).unwrap(); } assert!( - std::process::Command::new("cp") - .args(["-r", "/tmp/s6-esbuild/app/node_modules"]) - .arg(dir.path()) + std::process::Command::new("npm") + .args(["ci", "--ignore-scripts", "--no-audit", "--no-fund"]) + .current_dir(dir.path()) .status() .unwrap() .success() ); + let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland/esbuild/source"); + let programs = Path::new(env!("CARGO_TARGET_TMPDIR")).join("preview1-fixtures"); + std::fs::create_dir_all(&programs).unwrap(); + for (name, wasm) in [("esbuild-native", false), ("esbuild.wasm", true)] { + let mut build = std::process::Command::new("go"); + build + .current_dir(&source) + .env("GOPROXY", "off") + .env("GOSUMDB", "off") + .env("GOMAXPROCS", "2") + .args([ + "build", + "-p", + "2", + "-mod=vendor", + "-trimpath", + "-ldflags=-s -w", + "-o", + ]) + .arg(programs.join(name)) + .arg("./cmd/esbuild"); + if wasm { + build.env("GOOS", "wasip1").env("GOARCH", "wasm"); + } + assert!(build.status().unwrap().success(), "build {name}"); + } let flags = [ "app.tsx", "--bundle", @@ -327,7 +333,7 @@ async fn esbuild_bundle() { "--log-level=error", ]; assert!( - std::process::Command::new("/tmp/s6-esbuild/esbuild-native") + std::process::Command::new(programs.join("esbuild-native")) .current_dir(dir.path()) .args(flags) .arg("--outfile=native.js") @@ -336,7 +342,7 @@ async fn esbuild_bundle() { .success() ); let root = Arc::new(Host::new(dir.path(), Process::default()).unwrap()); - let guest = module("/tmp/s6-esbuild/esbuild.wasm"); + let guest = module(&programs.join("esbuild.wasm")); let mut args = vec!["esbuild"]; args.extend(flags); args.push("--outfile=host.js");