Merge branch 'v3' into v3-t17
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
|
||||
+27
-1
@@ -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/<name>` symlink.
|
||||
/// `/dev` exposes generated null, zero, and random byte devices in every world.
|
||||
/// Missing `/etc/gitconfig` defaults to Sausage <sausage@localhost>. Local repository config overrides it.
|
||||
pub fn open_with_userland(dir: &Path, userland: Option<&Path>) -> anyhow::Result<Self> {
|
||||
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(
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
@@ -13,13 +13,7 @@ fn programs() -> PathBuf {
|
||||
static PATH: OnceLock<PathBuf> = 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)?;
|
||||
@@ -16,14 +16,7 @@ fn components() -> Result<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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),
|
||||
@@ -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<ExitStatus> {
|
||||
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<PathBuf> = 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
|
||||
})
|
||||
}
|
||||
@@ -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<dyn Exec>);
|
||||
#[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");
|
||||
@@ -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<String> {
|
||||
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"),
|
||||
@@ -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 {
|
||||
@@ -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<dyn sausage::exec::Exec>);
|
||||
#[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");
|
||||
@@ -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<dyn Exec>);
|
||||
#[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")
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -14,39 +14,8 @@ use std::{
|
||||
fn guests() -> &'static Path {
|
||||
static BUILT: OnceLock<PathBuf> = 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")
|
||||
})
|
||||
}
|
||||
@@ -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"}}"#,
|
||||
@@ -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?;
|
||||
@@ -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"}}"#,
|
||||
@@ -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()?;
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
@@ -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<()> {
|
||||
@@ -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<std::path::PathBuf> = 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");
|
||||
@@ -12,12 +12,12 @@ fn write(path: PathBuf, text: &str) -> std::io::Result<()> {
|
||||
pub fn component() -> &'static Path {
|
||||
static PATH: OnceLock<PathBuf> = 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};
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
@@ -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},
|
||||
@@ -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!(
|
||||
@@ -166,9 +166,9 @@ impl Browser {
|
||||
}
|
||||
fn components() -> Result<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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] {
|
||||
@@ -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<String> {
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
@@ -22,48 +23,12 @@ fn git(repo: &Path, args: &[&str]) -> anyhow::Result<String> {
|
||||
);
|
||||
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()
|
||||
@@ -26,7 +26,7 @@ fn prepare(dir: &Path) -> anyhow::Result<PathBuf> {
|
||||
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<PathBuf> {
|
||||
"-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<PathBuf> {
|
||||
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"],
|
||||
@@ -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())?;
|
||||
@@ -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(),
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
@@ -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"])
|
||||
@@ -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([
|
||||
@@ -42,14 +42,7 @@ async fn command(runtime: &Arc<Runtime>, 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();
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
@@ -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] <name>...");
|
||||
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));
|
||||
}
|
||||
@@ -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<i32, Box<dyn std::error::Error>> {
|
||||
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::<Result<Vec<_>, _>>()?
|
||||
} 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::<Vec<_>>()
|
||||
} 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<String, Box<dyn std::error::Error>> {
|
||||
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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
+18
-3
@@ -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] <rev> [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`.
|
||||
|
||||
@@ -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<gix::actor::Signature> {
|
||||
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<gix::refs::transaction::RefEdit>,
|
||||
) -> Result<Vec<String>> {
|
||||
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<String> {
|
||||
|
||||
@@ -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<i32> {
|
||||
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::<usize>()
|
||||
.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::<String>();
|
||||
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::*;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+90
-7
@@ -59,6 +59,7 @@ fn identity(name: Option<String>, email: Option<String>) -> Result<Identity> {
|
||||
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<String>,
|
||||
},
|
||||
#[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<String>,
|
||||
},
|
||||
Clone {
|
||||
url: String,
|
||||
path: Option<String>,
|
||||
@@ -207,7 +232,7 @@ enum Command {
|
||||
diff_merges: Option<String>,
|
||||
#[arg(long)]
|
||||
oneline: bool,
|
||||
#[arg(short = 'n')]
|
||||
#[arg(short = 'n', long = "max-count")]
|
||||
limit: Option<usize>,
|
||||
#[arg(short = '1')]
|
||||
one: bool,
|
||||
@@ -224,6 +249,8 @@ enum Command {
|
||||
paths: Vec<String>,
|
||||
},
|
||||
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<String>,
|
||||
@@ -894,6 +923,7 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
}
|
||||
}
|
||||
Command::Diff {
|
||||
check,
|
||||
numstat,
|
||||
no_renames,
|
||||
diff_filter,
|
||||
@@ -921,13 +951,16 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
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<i32> {
|
||||
list,
|
||||
delete,
|
||||
force_delete,
|
||||
force,
|
||||
show_current,
|
||||
all,
|
||||
rename,
|
||||
@@ -1158,6 +1192,14 @@ pub fn run(cli: Cli) -> Result<i32> {
|
||||
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<i32> {
|
||||
)?;
|
||||
}
|
||||
},
|
||||
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<i32> {
|
||||
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<i32> {
|
||||
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 {
|
||||
|
||||
@@ -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",
|
||||
)?;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+84
-1
@@ -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} }
|
||||
|
||||
+71
-8
@@ -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<dyn Fn() -> Result<Vec<u8>> + Send + Sync>;
|
||||
@@ -6,7 +6,7 @@ type Generator = Arc<dyn Fn() -> Result<Vec<u8>> + 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<String, Generator>,
|
||||
files: BTreeMap<String, Source>,
|
||||
}
|
||||
impl Generated {
|
||||
pub fn insert(
|
||||
@@ -14,7 +14,13 @@ impl Generated {
|
||||
path: &str,
|
||||
generate: impl Fn() -> Result<Vec<u8>> + 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<Arc<dyn File>> {
|
||||
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<u8>);
|
||||
fn readonly<T>() -> Result<T> {
|
||||
Err(error(E::ReadOnlyFilesystem))
|
||||
@@ -32,7 +57,7 @@ fn readonly<T>() -> Result<T> {
|
||||
impl Layer for Generated {
|
||||
async fn stat(&self, path: &str, _follow: bool) -> Result<Stat> {
|
||||
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<Arc<dyn File>> {
|
||||
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<Stat> {
|
||||
Snapshot(Vec::new()).stat().await
|
||||
}
|
||||
async fn read(&self, _: u64, length: usize) -> Result<Vec<u8>> {
|
||||
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<u8>) -> Result<usize> {
|
||||
Ok(bytes.len())
|
||||
}
|
||||
async fn append(&self, bytes: Vec<u8>) -> Result<usize> {
|
||||
Ok(bytes.len())
|
||||
}
|
||||
async fn set_size(&self, _: u64) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn sync(&self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn set_times(&self, _: Option<SystemTime>, _: Option<SystemTime>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
-47
@@ -14,8 +14,9 @@ fn engine() -> &'static Engine {
|
||||
static E: OnceLock<Engine> = 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");
|
||||
|
||||
Reference in New Issue
Block a user