fabro/lib/components/fabro-sandbox/tests/daytona_streaming_live.rs
2026-08-26 11:16:37 -04:00

588 lines
19 KiB
Rust

#[cfg(feature = "daytona")]
mod daytona_streaming_live {
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result, ensure};
use fabro_sandbox::daytona::{DaytonaConfig, DaytonaSandbox};
use fabro_sandbox::{CommandOutputCallback, ExecStreamingResult, Sandbox};
use fabro_static::EnvVars;
use fabro_types::{CommandOutputStream, CommandTermination};
use tokio::sync::Mutex;
use tokio::time::{Instant, sleep};
use tokio_util::sync::CancellationToken;
#[derive(Debug, Clone)]
struct CapturedChunk {
stream: CommandOutputStream,
text: String,
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "requires live Daytona credentials and provisions a sandbox"]
async fn daytona_streaming_live_smoke() -> Result<()> {
ensure!(
daytona_api_key_present(),
"DAYTONA_API_KEY must be set to run this live smoke test"
);
let sandbox = Arc::new(
DaytonaSandbox::new(
DaytonaConfig {
skip_clone: true,
..Default::default()
},
None,
None,
None,
None,
None,
None,
None,
)
.await?,
);
sandbox.initialize().await?;
let smoke_result = run_smoke(Arc::clone(&sandbox)).await;
let cleanup_result = sandbox.cleanup().await.context("clean up Daytona sandbox");
smoke_result?;
cleanup_result?;
Ok(())
}
/// Both command paths must reach the same interpreter, so Bash-only syntax
/// that `sh` rejects has to behave identically through them. The two paths
/// build different requests — a direct process exec and a toolbox session —
/// so neither is evidence for the other.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "requires live Daytona credentials and provisions a sandbox"]
async fn daytona_runs_bash_only_syntax_through_both_command_paths() -> Result<()> {
ensure!(
daytona_api_key_present(),
"DAYTONA_API_KEY must be set to run this live smoke test"
);
let sandbox = DaytonaSandbox::new(
DaytonaConfig {
skip_clone: true,
..Default::default()
},
None,
None,
None,
None,
None,
None,
None,
)
.await?;
sandbox.initialize().await?;
// Arrays, `[[ ]]`, and `${arr[@]}` are Bash-only; `shopt -q
// login_shell` proves neither path ran under a login shell.
let command = "arr=(one two three); [[ ${#arr[@]} -eq 3 ]] || exit 1; \
shopt -q login_shell && exit 2; echo ${arr[1]}";
let checks: Result<()> = async {
let non_streaming = sandbox
.exec_command(command, 30_000, None, None, None)
.await?;
ensure_eq(
&non_streaming.exit_code,
&Some(0),
&format!("exec_command should run Bash-only syntax: {non_streaming:?}"),
)?;
ensure_contains(
&non_streaming.stdout,
"two",
"exec_command should report the Bash-only result",
)?;
let (streaming, _) = run_captured(&sandbox, command, 30_000, None).await?;
ensure_eq(
&streaming.result.exit_code,
&Some(0),
&format!("exec_command_streaming should run Bash-only syntax: {streaming:?}"),
)?;
ensure_contains(
&streaming.result.stdout,
"two",
"exec_command_streaming should report the Bash-only result",
)?;
let stdin = "first line\n$(touch /tmp/must-not-run)\nlast line";
let (stdin_result, _) = run_captured_with_stdin(
&sandbox,
"cat",
30_000,
None,
Some(stdin.as_bytes().to_vec()),
)
.await?;
ensure_eq(
&stdin_result.result.exit_code,
&Some(0),
"exec_command_streaming should close stdin with a successful EOF",
)?;
ensure_eq(
&stdin_result.result.stdout,
&stdin.to_string(),
"exec_command_streaming should preserve exact stdin bytes",
)?;
let stdin_cleanup = sandbox
.exec_command(
"test ! -e /tmp/must-not-run && \
! compgen -G '/tmp/fabro-command-stdin-*' >/dev/null",
30_000,
None,
None,
None,
)
.await?;
ensure!(
stdin_cleanup.is_success(),
"Daytona stdin data must stay inert and its temporary file must be deleted: {stdin_cleanup:?}"
);
Ok(())
}
.await;
let cleanup_result = sandbox.cleanup().await.context("clean up Daytona sandbox");
checks?;
cleanup_result?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "requires live Daytona credentials and provisions a sandbox"]
async fn daytona_managed_labels_live_smoke() -> Result<()> {
ensure!(
daytona_api_key_present(),
"DAYTONA_API_KEY must be set to run this live smoke test"
);
let run_id: fabro_types::RunId = "01HY0000000000000000000000".parse().unwrap();
let sandbox = DaytonaSandbox::new(
DaytonaConfig {
skip_clone: true,
labels: Some(std::collections::HashMap::from([(
"team".to_string(),
"platform".to_string(),
)])),
..Default::default()
},
None,
Some(run_id),
None,
None,
None,
None,
None,
)
.await?;
sandbox.initialize().await?;
let labels = sandbox
.sandbox_handle()
.context("sandbox handle should be initialized")?
.labels
.clone();
let cleanup_result = sandbox.cleanup().await.context("clean up Daytona sandbox");
ensure_eq(
&labels.get("sh.fabro.managed").map(String::as_str),
&Some("true"),
"Daytona should accept and return the managed label",
)?;
ensure_eq(
&labels.get("sh.fabro.run_id").map(String::as_str),
&Some("01HY0000000000000000000000"),
"Daytona should accept and return the run id label",
)?;
ensure_eq(
&labels.get("team").map(String::as_str),
&Some("platform"),
"Daytona should preserve user labels",
)?;
cleanup_result?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "requires live Daytona credentials and provisions a sandbox"]
async fn daytona_clone_layout_live_smoke() -> Result<()> {
ensure!(
daytona_api_key_present(),
"DAYTONA_API_KEY must be set to run this live smoke test"
);
let sandbox = DaytonaSandbox::new(
DaytonaConfig {
skip_clone: false,
..Default::default()
},
None,
None,
Some("https://github.com/brynary/rack-test".to_string()),
None,
None,
None,
None,
)
.await?;
sandbox.initialize().await?;
ensure_eq(
&sandbox.working_directory(),
&"/home/daytona/workspace/rack-test",
"working directory should be the workspace symlink",
)?;
let result = sandbox
.exec_command(
"test -d /home/daytona/repos/brynary/rack-test/.git && \
test -L /home/daytona/workspace/rack-test && \
test \"$(readlink /home/daytona/workspace/rack-test)\" = /home/daytona/repos/brynary/rack-test && \
test \"$(git -C /home/daytona/repos/brynary/rack-test rev-parse HEAD)\" = \
\"$(git -C /home/daytona/workspace/rack-test rev-parse HEAD)\" && \
git rev-parse --is-inside-work-tree",
30_000,
None,
None,
None,
)
.await?;
let cleanup_result = sandbox.cleanup().await.context("clean up Daytona sandbox");
ensure!(
result.is_success(),
"layout verification failed: stdout={} stderr={}",
result.stdout,
result.stderr
);
ensure_contains(
&result.stdout,
"true",
"default cwd should be inside the work tree",
)?;
cleanup_result?;
Ok(())
}
// Regression test for glob patterns that contain a path separator. Before
// the glob fix, Daytona ran `find <base> -name <pattern>`, and `find -name`
// matches only the basename and rejects patterns containing `/`. So
// `*/SKILL.md` and `**/SKILL.md` silently returned an empty list even though
// the files existed. Both `glob` calls below fail against that old
// implementation and pass once traversal (the Daytona filesystem API) and
// matching (host-side) are split.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "requires live Daytona credentials and provisions a sandbox"]
async fn daytona_glob_matches_patterns_containing_a_path_separator() -> Result<()> {
ensure!(
daytona_api_key_present(),
"DAYTONA_API_KEY must be set to run this live glob test"
);
let sandbox = DaytonaSandbox::new(
DaytonaConfig {
skip_clone: true,
..Default::default()
},
None,
None,
None,
None,
None,
None,
None,
)
.await?;
sandbox.initialize().await?;
let glob_result = run_glob_checks(&sandbox).await;
let cleanup_result = sandbox.cleanup().await.context("clean up Daytona sandbox");
glob_result?;
cleanup_result?;
Ok(())
}
async fn run_glob_checks(sandbox: &DaytonaSandbox) -> Result<()> {
// Build a skills tree with a SKILL.md at the search root, one level
// below it, and two levels below it.
let seed = sandbox
.exec_command(
"mkdir -p skills/patch skills/nested/deeper && \
touch skills/SKILL.md skills/patch/SKILL.md skills/nested/deeper/SKILL.md",
30_000,
None,
None,
None,
)
.await?;
ensure!(
seed.is_success(),
"seeding the skills tree failed: stdout={} stderr={}",
seed.stdout,
seed.stderr
);
// `*/SKILL.md` matches exactly one path segment: only the file one level
// below the search directory, not the root file or the deeper one.
let one_level = sandbox.glob("*/SKILL.md", Some("skills")).await?;
ensure_eq(
&one_level.len(),
&1,
"`*/SKILL.md` should match exactly one level below the search dir",
)?;
ensure!(
one_level[0].ends_with("skills/patch/SKILL.md"),
"`*/SKILL.md` should match the one-level-deep file, got {one_level:?}"
);
// `**/SKILL.md` matches at any depth, including several levels down.
let recursive = sandbox.glob("**/SKILL.md", Some("skills")).await?;
ensure!(
recursive
.iter()
.any(|path| path.ends_with("skills/nested/deeper/SKILL.md")),
"`**/SKILL.md` should match files nested several levels deep, got {recursive:?}"
);
Ok(())
}
async fn run_smoke(sandbox: Arc<DaytonaSandbox>) -> Result<()> {
let chunks = Arc::new(Mutex::new(Vec::new()));
let cancel_token = CancellationToken::new();
let callback = capture_callback(Arc::clone(&chunks));
let sandbox_for_exec = Arc::clone(&sandbox);
let cancel_for_exec = cancel_token.clone();
let live_exec = tokio::spawn(async move {
sandbox_for_exec
.exec_command_streaming(fabro_sandbox::ExecStreamingRequest {
timeout_ms: Some(60_000),
cancel_token: Some(cancel_for_exec),
output_callback: Some(callback),
..fabro_sandbox::ExecStreamingRequest::new(
"printf 'live-out\\n'; printf 'live-err\\n' >&2; sleep 30",
)
})
.await
});
let saw_live_stdout_and_stderr =
wait_for_chunks(&chunks, Duration::from_secs(20), |chunks| {
contains_chunk(chunks, CommandOutputStream::Stdout, "live-out")
&& contains_chunk(chunks, CommandOutputStream::Stderr, "live-err")
})
.await;
cancel_token.cancel();
let live_result = live_exec
.await
.context("join live cancel command task")?
.context("run live cancel command")?;
ensure!(
saw_live_stdout_and_stderr,
"expected live stdout and stderr chunks before cancellation, got {chunks:?}",
chunks = chunks.lock().await
);
ensure!(
live_result.live_streaming,
"expected Daytona command logs to stream before completion"
);
ensure!(
live_result.streams_separated,
"expected Daytona command logs to separate stdout and stderr"
);
ensure_eq(
&live_result.result.termination,
&CommandTermination::Cancelled,
"cancelled command should preserve cancellation termination",
)?;
ensure_contains(
&live_result.result.stdout,
"live-out",
"cancelled command stdout should preserve partial logs",
)?;
ensure_contains(
&live_result.result.stderr,
"live-err",
"cancelled command stderr should preserve partial logs",
)?;
let (nonzero, nonzero_chunks) = run_captured(
sandbox.as_ref(),
"printf 'exit-out\\n'; printf 'exit-err\\n' >&2; exit 7",
30_000,
None,
)
.await?;
ensure_eq(
&nonzero.result.exit_code,
&Some(7),
"nonzero command should preserve the Daytona exit code",
)?;
ensure_eq(
&nonzero.result.termination,
&CommandTermination::Exited,
"nonzero command should be represented as a completed process",
)?;
ensure_contains(
&nonzero.result.stdout,
"exit-out",
"nonzero command stdout should be captured",
)?;
ensure_contains(
&nonzero.result.stderr,
"exit-err",
"nonzero command stderr should be captured",
)?;
ensure!(
contains_chunk(&nonzero_chunks, CommandOutputStream::Stdout, "exit-out"),
"nonzero command should stream stdout chunks"
);
ensure!(
contains_chunk(&nonzero_chunks, CommandOutputStream::Stderr, "exit-err"),
"nonzero command should stream stderr chunks"
);
let (timed_out, _) = run_captured(
sandbox.as_ref(),
"printf 'timeout-out\\n'; printf 'timeout-err\\n' >&2; sleep 30",
1_500,
None,
)
.await?;
ensure_eq(
&timed_out.result.termination,
&CommandTermination::TimedOut,
"timed-out command should preserve timeout termination",
)?;
ensure_contains(
&timed_out.result.stdout,
"timeout-out",
"timed-out command stdout should preserve partial logs",
)?;
ensure_contains(
&timed_out.result.stderr,
"timeout-err",
"timed-out command stderr should preserve partial logs",
)?;
Ok(())
}
async fn run_captured(
sandbox: &DaytonaSandbox,
command: &str,
timeout_ms: u64,
cancel_token: Option<CancellationToken>,
) -> Result<(ExecStreamingResult, Vec<CapturedChunk>)> {
run_captured_with_stdin(sandbox, command, timeout_ms, cancel_token, None).await
}
async fn run_captured_with_stdin(
sandbox: &DaytonaSandbox,
command: &str,
timeout_ms: u64,
cancel_token: Option<CancellationToken>,
stdin: Option<Vec<u8>>,
) -> Result<(ExecStreamingResult, Vec<CapturedChunk>)> {
let chunks = Arc::new(Mutex::new(Vec::new()));
let callback = capture_callback(Arc::clone(&chunks));
let result = sandbox
.exec_command_streaming(fabro_sandbox::ExecStreamingRequest {
timeout_ms: Some(timeout_ms),
cancel_token,
stdin,
output_callback: Some(callback),
..fabro_sandbox::ExecStreamingRequest::new(command)
})
.await?;
let chunks = chunks.lock().await.clone();
Ok((result, chunks))
}
fn capture_callback(chunks: Arc<Mutex<Vec<CapturedChunk>>>) -> CommandOutputCallback {
Arc::new(move |stream, bytes| {
let chunks = Arc::clone(&chunks);
Box::pin(async move {
chunks.lock().await.push(CapturedChunk {
stream,
text: String::from_utf8_lossy(&bytes).into_owned(),
});
Ok(())
})
})
}
#[expect(
clippy::disallowed_methods,
reason = "live smoke tests need a direct process-env preflight before provisioning Daytona"
)]
fn daytona_api_key_present() -> bool {
std::env::var_os(EnvVars::DAYTONA_API_KEY).is_some()
}
async fn wait_for_chunks(
chunks: &Arc<Mutex<Vec<CapturedChunk>>>,
timeout_after: Duration,
predicate: impl Fn(&[CapturedChunk]) -> bool,
) -> bool {
let deadline = Instant::now() + timeout_after;
loop {
if predicate(&chunks.lock().await) {
return true;
}
if Instant::now() >= deadline {
return false;
}
sleep(Duration::from_millis(100)).await;
}
}
fn contains_chunk(chunks: &[CapturedChunk], stream: CommandOutputStream, text: &str) -> bool {
chunks
.iter()
.any(|chunk| chunk.stream == stream && chunk.text.contains(text))
}
fn ensure_contains(value: &str, needle: &str, message: &str) -> Result<()> {
ensure!(
value.contains(needle),
"{message}: expected to find {needle:?} in {value:?}"
);
Ok(())
}
fn ensure_eq<T>(actual: &T, expected: &T, message: &str) -> Result<()>
where
T: std::fmt::Debug + PartialEq,
{
ensure!(
actual == expected,
"{message}: expected {expected:?}, got {actual:?}"
);
Ok(())
}
}