Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 0 additions & 81 deletions e2e/rust/src/harness/sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -400,87 +400,6 @@ impl SandboxGuard {
})
}

/// Create a detached scratch sandbox with pre-loaded files, then exec a
/// command in it.
///
/// Equivalent to:
/// ```text
/// openshell sandbox create --detach --upload <local>:<dest> [extra_args...]
/// openshell sandbox exec <name> -- <command>
/// ```
///
/// The `--no-git-ignore` flag is passed to avoid needing a git repository.
///
/// # Errors
///
/// Returns an error if the CLI exits with a non-zero status or the sandbox
/// name cannot be parsed.
pub async fn create_with_upload(
upload_local: &str,
upload_dest: &str,
command: &[&str],
) -> Result<Self, String> {
Self::create_with_uploads(&[(upload_local, upload_dest)], command).await
}

/// Create a sandbox with multiple `--upload` specs.
///
/// Each element of `uploads` is a `(local_path, sandbox_dest)` pair.
/// `--no-git-ignore` is applied to all uploads.
pub async fn create_with_uploads(
uploads: &[(&str, &str)],
command: &[&str],
) -> Result<Self, String> {
let mut cmd = openshell_cmd();
cmd.arg("sandbox").arg("create").arg("--detach");
add_unique_name_if_missing(&mut cmd, &[]);
add_test_image_if_missing(&mut cmd, &[]);
for (local, dest) in uploads {
cmd.arg("--upload").arg(format!("{local}:{dest}"));
}
cmd.arg("--no-git-ignore");
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());

let output = timeout(SANDBOX_READY_TIMEOUT, cmd.output())
.await
.map_err(|_| {
format!("sandbox create --upload timed out after {SANDBOX_READY_TIMEOUT:?}")
})?
.map_err(|e| format!("failed to spawn openshell: {e}"))?;

let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let combined = format!("{stdout}{stderr}");

if !output.status.success() {
return Err(format!(
"sandbox create --upload failed (exit {:?}):\n{combined}",
output.status.code()
));
}

let name = extract_sandbox_name(&combined).ok_or_else(|| {
format!("could not parse sandbox name from create output:\n{combined}")
})?;

let mut guard = Self {
name,
create_output: combined,
child: None,
cleaned_up: false,
};

match guard.exec(command).await {
Ok(exec_output) => guard.create_output.push_str(&exec_output),
Err(err) => {
guard.cleanup().await;
return Err(err);
}
}

Ok(guard)
}

/// Upload local files to the sandbox via `openshell sandbox upload`.
///
/// # Arguments
Expand Down
113 changes: 0 additions & 113 deletions e2e/rust/tests/upload_create.rs

This file was deleted.

1 change: 1 addition & 0 deletions tests/suites/conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Each file in `tests/` is one suite.
| `persistent-workspace` | Files in `/sandbox` survive stop and start |
| `policy-advisor` | `policy.local` accepts a proposed rule; a denied address or hostname yields one draft rule |
| `file-transfer` | `sandbox upload` and `download`: round trips, `.gitignore` filtering, workspace-escape and option-looking names |
| `create-upload` | `sandbox create --upload`: directory basename, multiple uploads, single file |

`smoke` is the smallest end-to-end check that the runner can run a real test in a
tmachine environment on CI. The other suites are ports of the old
Expand Down
130 changes: 130 additions & 0 deletions tests/suites/conformance/tests/create_upload.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! `sandbox create --upload`.
//!
//! Migrated from `e2e/rust/tests/upload_create.rs`. The behavior is not
//! driver-specific, so it runs against any prepared gateway.
//!
//! `--upload` cannot be combined with a command, so these tests check the files
//! once `create` returns, not that they arrived before a main process started.

use std::fs;
use std::path::Path;

use testkit::{Context, Error, Sandbox};

/// `--upload <directory>:<destination>` keeps the directory's basename under
/// the destination and preserves nested files.
#[testkit::test]
async fn create_with_upload_directory_preserves_source_basename(
cx: &mut Context,
) -> testkit::Result {
let local = scratch()?;
let source = local.path().join("project");
write(&source.join("marker.txt"), "upload-create-marker")?;
write(&source.join("src/main.py"), "print('hello')")?;

let sandbox = create_with_uploads(cx, "cu1", &[(&source, "/sandbox/data")]).await?;
expect_file(
&sandbox,
"/sandbox/data/project/marker.txt",
"upload-create-marker",
)
.await?;
expect_file(
&sandbox,
"/sandbox/data/project/src/main.py",
"print('hello')",
)
.await?;
sandbox
.sh("test ! -e /sandbox/data/marker.txt")
.await?
.success()?;
sandbox.delete().await
}

/// Several `--upload` flags on one `sandbox create` each place their files at
/// their own destination.
#[testkit::test]
async fn create_with_multiple_uploads(cx: &mut Context) -> testkit::Result {
let local = scratch()?;
let alpha = local.path().join("alpha");
let beta = local.path().join("beta");
write(&alpha.join("a.txt"), "content-alpha")?;
write(&beta.join("b.txt"), "content-beta")?;

let sandbox = create_with_uploads(
cx,
"cu2",
&[(&alpha, "/sandbox/alpha"), (&beta, "/sandbox/beta")],
)
.await?;
expect_file(&sandbox, "/sandbox/alpha/alpha/a.txt", "content-alpha").await?;
expect_file(&sandbox, "/sandbox/beta/beta/b.txt", "content-beta").await?;
sandbox.delete().await
}

/// `--upload <file>:<destination>` places a single file at the destination.
#[testkit::test]
async fn create_with_upload_single_file(cx: &mut Context) -> testkit::Result {
let local = scratch()?;
let source = local.path().join("config.txt");
write(&source, "single-file-upload-test")?;

let sandbox = create_with_uploads(cx, "cu3", &[(&source, "/sandbox")]).await?;
expect_file(&sandbox, "/sandbox/config.txt", "single-file-upload-test").await?;
sandbox.delete().await
}

fn scratch() -> testkit::Result<tempfile::TempDir> {
tempfile::tempdir()
.map_err(|error| Error::msg(format!("create a local scratch directory: {error}")))
}

fn write(path: &Path, content: &str) -> testkit::Result {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| Error::msg(format!("create {}: {error}", parent.display())))?;
}
fs::write(path, content)
.map_err(|error| Error::msg(format!("write {}: {error}", path.display())))
}

/// Create a detached sandbox with one `--upload <source>:<destination>` flag
/// per entry and return a handle to it.
async fn create_with_uploads(
cx: &Context,
suffix: &str,
uploads: &[(&Path, &str)],
) -> testkit::Result<Sandbox> {
let name = cx.name(suffix);
// Track before the CLI runs so cleanup deletes the sandbox even if the
// create succeeds but a later step fails.
cx.track(&name);
let mut args = vec![
"sandbox".to_string(),
"create".to_string(),
"--name".to_string(),
name.clone(),
"--detach".to_string(),
];
for (source, destination) in uploads {
args.push("--upload".to_string());
args.push(format!("{}:{destination}", source.display()));
}
cx.cli(args).await?.success()?;
cx.adopt(&name).await
}

/// Require the sandbox file at `path` to contain exactly `expected`.
async fn expect_file(sandbox: &Sandbox, path: &str, expected: &str) -> testkit::Result {
let output = sandbox.sh(&format!("cat '{path}'")).await?.success()?;
testkit::ensure!(
output.stdout == expected,
"{path} contains {:?}; expected {expected:?}",
output.stdout
);
Ok(())
}
22 changes: 22 additions & 0 deletions tests/testkit/testkit/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -361,11 +361,27 @@ pub struct Sandbox {
/// The buffered output of an exec.
#[derive(Debug)]
pub struct ExecOutput {
/// The command as run, for failure messages.
pub command: String,
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
}

impl ExecOutput {
/// Fail unless the command exited 0.
pub fn success(self) -> Result<Self> {
crate::ensure!(
self.exit_code == 0,
"`{}` exited with {}; stderr: {}",
self.command,
self.exit_code,
self.stderr.trim()
);
Ok(self)
}
}

impl Sandbox {
pub fn name(&self) -> &str {
&self.name
Expand Down Expand Up @@ -526,12 +542,18 @@ impl Sandbox {
.await
.context(format!("exec {command:?} in sandbox {}", self.name))?;
Ok(ExecOutput {
command: command.join(" "),
exit_code: result.exit_code,
stdout: String::from_utf8_lossy(&result.stdout).into_owned(),
stderr: String::from_utf8_lossy(&result.stderr).into_owned(),
})
}

/// Run a shell script with `sh -c` and buffer its output.
pub async fn sh(&self, script: &str) -> Result<ExecOutput> {
self.exec(["sh", "-c", script]).await
}

/// Delete the sandbox and wait until it is gone.
pub async fn delete(&self) -> Result {
self.client
Expand Down
Loading