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
48 changes: 44 additions & 4 deletions crates/openshell-conformance/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,14 @@ use tokio::time::sleep;
use self::executor::{CliExecutionError, CliExecutor, ProcessCli};

pub use scenarios::{
FILE_TRANSFER_GIT_FILTERING_SCENARIO, FILE_TRANSFER_PATH_SAFETY_SCENARIO,
FILE_TRANSFER_ROUND_TRIP_SCENARIO, MECHANISTIC_PROPOSAL_SCENARIO,
NEW_HOSTNAME_PROPOSAL_SCENARIO, POLICY_LOCAL_SCENARIO, SANDBOX_LIFECYCLE_SCENARIO,
SMOKE_SCENARIO,
FILE_TRANSFER_CREATE_UPLOAD_DIRECTORY_SCENARIO, FILE_TRANSFER_CREATE_UPLOAD_FILE_SCENARIO,
FILE_TRANSFER_CREATE_UPLOAD_MULTIPLE_SCENARIO, FILE_TRANSFER_GIT_FILTERING_SCENARIO,
FILE_TRANSFER_PATH_SAFETY_SCENARIO, FILE_TRANSFER_ROUND_TRIP_SCENARIO,
MECHANISTIC_PROPOSAL_SCENARIO, NEW_HOSTNAME_PROPOSAL_SCENARIO, POLICY_LOCAL_SCENARIO,
SANDBOX_LIFECYCLE_SCENARIO, SANDBOX_TEMPLATE_DUPLICATE_NAME_SCENARIO,
SANDBOX_TEMPLATE_GET_AFTER_DELETE_SCENARIO, SANDBOX_TEMPLATE_LIFECYCLE_SCENARIO,
SANDBOX_TEMPLATE_MISSING_TEMPLATE_SCENARIO, SANDBOX_TEMPLATES_SCENARIO, SMOKE_SCENARIO,
WORKSPACE_LIFECYCLE_SCENARIO, WORKSPACE_TERMINATING_SCENARIO,
};

/// A portable conformance scenario exercised by the Cargo test suite.
Expand Down Expand Up @@ -94,6 +98,28 @@ impl CommandResult {
self.elapsed
}

/// Check combined stdout+stderr for `needle`, normalizing miette's
/// line-wrapped, `│`-continued diagnostic text first so phrase matches
/// don't depend on the terminal width the CLI detected when rendering.
pub fn output_contains(&self, needle: &str) -> bool {
self.normalized_output().contains(needle)
}

/// Case-insensitive variant of [`Self::output_contains`].
pub fn output_contains_ignore_case(&self, needle: &str) -> bool {
self.normalized_output()
.to_lowercase()
.contains(&needle.to_lowercase())
}

fn normalized_output(&self) -> String {
format!("{}{}", self.stdout, self.stderr)
.replace(['\n', '│'], " ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}

pub fn json<T: DeserializeOwned>(&self) -> Result<T, RunnerError> {
serde_json::from_str(&self.stdout).map_err(|source| RunnerError::InvalidJson {
context: self.context(),
Expand All @@ -110,6 +136,20 @@ impl CommandResult {
Err(self.failure_diagnostic(&self.expectation))
}

/// Assert the command's success matches `expect_success` (`false` asserts
/// the command fails, e.g. a rejected duplicate-name or blocked-while-managed case).
pub fn require_outcome(&self, expect_success: bool) -> Result<(), String> {
if self.success() == expect_success {
Ok(())
} else {
Err(self.failure_diagnostic(if expect_success {
"command succeeds"
} else {
"command fails"
}))
}
}

pub fn failure_diagnostic(&self, expectation: &str) -> String {
format!(
"{}\nexpected: {expectation}\nactual: exit {} after {:.1?}\ncommand: {}\nstdout:\n{}\nstderr:\n{}",
Expand Down
206 changes: 206 additions & 0 deletions crates/openshell-conformance/src/scenarios/file_transfer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,40 @@ pub const FILE_TRANSFER_PATH_SAFETY_SCENARIO: Scenario = Scenario {
run: run_path_safety,
};

/// Certify that `sandbox create --upload <dir>:<dest>` preserves the source
/// directory's basename under the destination.
///
/// - **Behavior:** a directory passed to `sandbox create --upload <dir>:<dest>` is available in the sandbox under `<dest>/<directory name>`, keeping the source directory's basename. Verified by reading a file from inside the sandbox.
/// - **Preconditions:** a reachable gateway whose compute driver can create sandboxes from the default image (supplied by target preparation). The test writes its upload sources to a local temporary directory.
/// - **Permissions:** an identity allowed to create, execute in, and delete sandboxes.
/// - **Side effects:** creates one sandbox named from the run id, which the runner removes on success and on failure (a cleanup failure fails the scenario), and a local temporary directory.
pub const FILE_TRANSFER_CREATE_UPLOAD_DIRECTORY_SCENARIO: Scenario = Scenario {
name: "file-transfer/create-upload-directory",
run: run_create_upload_directory,
};

/// Certify that `sandbox create` accepts several `--upload` flags at once.
///
/// - **Behavior:** several `--upload` flags on one `sandbox create` are all pre-loaded to their own destinations. Verified by reading a file from each destination inside the sandbox.
/// - **Preconditions:** a reachable gateway whose compute driver can create sandboxes from the default image (supplied by target preparation). The test writes its upload sources to a local temporary directory.
/// - **Permissions:** an identity allowed to create, execute in, and delete sandboxes.
/// - **Side effects:** creates one sandbox named from the run id, which the runner removes on success and on failure (a cleanup failure fails the scenario), and a local temporary directory.
pub const FILE_TRANSFER_CREATE_UPLOAD_MULTIPLE_SCENARIO: Scenario = Scenario {
name: "file-transfer/create-upload-multiple",
run: run_create_upload_multiple,
};

/// Certify that `sandbox create --upload <file>` pre-loads a single file.
///
/// - **Behavior:** a single file passed to `sandbox create --upload` is available in the sandbox at the requested destination. Verified by reading it from inside the sandbox.
/// - **Preconditions:** a reachable gateway whose compute driver can create sandboxes from the default image (supplied by target preparation). The test writes its upload sources to a local temporary directory.
/// - **Permissions:** an identity allowed to create, execute in, and delete sandboxes.
/// - **Side effects:** creates one sandbox named from the run id, which the runner removes on success and on failure (a cleanup failure fails the scenario), and a local temporary directory.
pub const FILE_TRANSFER_CREATE_UPLOAD_FILE_SCENARIO: Scenario = Scenario {
name: "file-transfer/create-upload-file",
run: run_create_upload_file,
};

fn run_round_trip(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> {
Box::pin(async move {
let (sandbox_name, remote_root, local) = prepare_sandbox(runner, "round-trip").await?;
Expand Down Expand Up @@ -66,6 +100,178 @@ fn run_path_safety(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> {
})
}

fn run_create_upload_directory(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> {
Box::pin(async move { create_upload_directory_preserves_source_basename(runner).await })
}

fn run_create_upload_multiple(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> {
Box::pin(async move { create_with_multiple_uploads(runner).await })
}

fn run_create_upload_file(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> {
Box::pin(async move { create_upload_single_file(runner).await })
}

/// A sandbox created with `--upload <dir>:<dest>` preserves the source
/// directory's basename under `<dest>`.
async fn create_upload_directory_preserves_source_basename(
runner: &mut OpenShellRunner,
) -> Result<(), String> {
let local =
tempfile::tempdir().map_err(fs_error("create create-upload/directory temporary dir"))?;
let source = local.path().join("project");
fs::create_dir_all(source.join("src")).map_err(fs_error("create project/src"))?;
fs::write(source.join("marker.txt"), "upload-create-marker")
.map_err(fs_error("write marker.txt"))?;
fs::write(source.join("src/main.py"), "print('hello')").map_err(fs_error("write main.py"))?;

let sandbox_name = format!("ct-{}-cu1", runner.id());
create_with_uploads(
runner,
"create-upload/directory",
&sandbox_name,
&[(&source, "/sandbox/data")],
)
.await?;

let output = exec_capture(
runner,
&sandbox_name,
"create-upload/directory/verify",
"cat /sandbox/data/project/marker.txt",
)
.await?;
if !output.contains("upload-create-marker") {
return Err(format!(
"expected uploaded marker content in sandbox output:\n{output}"
));
}

delete_sandbox(runner, &sandbox_name).await
}

/// Two `--upload` specs in a single `sandbox create` call both land in the
/// sandbox before the command runs.
async fn create_with_multiple_uploads(runner: &mut OpenShellRunner) -> Result<(), String> {
let local = tempfile::tempdir().map_err(fs_error("create multiple-uploads temporary dir"))?;
let dir_a = local.path().join("alpha");
let dir_b = local.path().join("beta");
fs::create_dir_all(&dir_a).map_err(fs_error("create alpha"))?;
fs::create_dir_all(&dir_b).map_err(fs_error("create beta"))?;
fs::write(dir_a.join("a.txt"), "content-alpha").map_err(fs_error("write a.txt"))?;
fs::write(dir_b.join("b.txt"), "content-beta").map_err(fs_error("write b.txt"))?;

let sandbox_name = format!("ct-{}-cu2", runner.id());
create_with_uploads(
runner,
"create-upload/multiple",
&sandbox_name,
&[(&dir_a, "/sandbox/alpha"), (&dir_b, "/sandbox/beta")],
)
.await?;

let output = exec_capture(
runner,
&sandbox_name,
"create-upload/multiple/verify",
"cat /sandbox/alpha/alpha/a.txt /sandbox/beta/beta/b.txt",
)
.await?;
if !output.contains("content-alpha") || !output.contains("content-beta") {
return Err(format!(
"expected both uploaded contents in sandbox output:\n{output}"
));
}

delete_sandbox(runner, &sandbox_name).await
}

/// `--upload` with a single file (not a directory) works.
async fn create_upload_single_file(runner: &mut OpenShellRunner) -> Result<(), String> {
let local = tempfile::tempdir().map_err(fs_error("create single-file temporary dir"))?;
let file_path = local.path().join("config.txt");
fs::write(&file_path, "single-file-upload-test").map_err(fs_error("write config.txt"))?;

let sandbox_name = format!("ct-{}-cu3", runner.id());
create_with_uploads(
runner,
"create-upload/single-file",
&sandbox_name,
&[(&file_path, "/sandbox")],
)
.await?;

let output = exec_capture(
runner,
&sandbox_name,
"create-upload/single-file/verify",
"cat /sandbox/config.txt",
)
.await?;
if !output.contains("single-file-upload-test") {
return Err(format!(
"expected single-file content in sandbox output:\n{output}"
));
}

delete_sandbox(runner, &sandbox_name).await
}

/// Run `sandbox create --detach --upload <local>:<dest>... --no-git-ignore`.
async fn create_with_uploads(
runner: &mut OpenShellRunner,
step: &str,
sandbox_name: &str,
uploads: &[(&Path, &str)],
) -> Result<(), String> {
runner.track_sandbox(sandbox_name);
let mut args = vec![
"sandbox".to_string(),
"create".to_string(),
"--name".to_string(),
sandbox_name.to_string(),
"--detach".to_string(),
];
for (local, dest) in uploads {
let local = local
.to_str()
.ok_or_else(|| format!("local upload path is not UTF-8: {}", local.display()))?;
args.push("--upload".to_string());
args.push(format!("{local}:{dest}"));
}
args.push("--no-git-ignore".to_string());
let args: Vec<&str> = args.iter().map(String::as_str).collect();

let result = runner
.step(step)
.description(format!("sandbox '{sandbox_name}' is created with uploads"))
.with_timeout(CREATE_TIMEOUT)
.run(&args)
.await
.map_err(|error| error.to_string())?;
result.require_success()
}

/// Exec a command in the sandbox and return its combined stdout+stderr.
async fn exec_capture(
runner: &OpenShellRunner,
sandbox: &str,
step: &str,
script: &str,
) -> Result<String, String> {
let result = runner
.step(step)
.description(format!("sandbox '{sandbox}' exec for {step} succeeds"))
.with_timeout(COMMAND_TIMEOUT)
.run(&[
"sandbox", "exec", "--name", sandbox, "--no-tty", "--", "sh", "-c", script,
])
.await
.map_err(|error| error.to_string())?;
result.require_success()?;
Ok(format!("{}{}", result.stdout(), result.stderr()))
}

async fn prepare_sandbox(
runner: &mut OpenShellRunner,
group: &str,
Expand Down
13 changes: 11 additions & 2 deletions crates/openshell-conformance/src/scenarios/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,23 @@
mod file_transfer;
mod policy_behavior;
mod sandbox_lifecycle;
mod sandbox_templates;
mod smoke;
mod workspace_lifecycle;

pub use file_transfer::{
FILE_TRANSFER_GIT_FILTERING_SCENARIO, FILE_TRANSFER_PATH_SAFETY_SCENARIO,
FILE_TRANSFER_ROUND_TRIP_SCENARIO,
FILE_TRANSFER_CREATE_UPLOAD_DIRECTORY_SCENARIO, FILE_TRANSFER_CREATE_UPLOAD_FILE_SCENARIO,
FILE_TRANSFER_CREATE_UPLOAD_MULTIPLE_SCENARIO, FILE_TRANSFER_GIT_FILTERING_SCENARIO,
FILE_TRANSFER_PATH_SAFETY_SCENARIO, FILE_TRANSFER_ROUND_TRIP_SCENARIO,
};
pub use policy_behavior::{
MECHANISTIC_PROPOSAL_SCENARIO, NEW_HOSTNAME_PROPOSAL_SCENARIO, POLICY_LOCAL_SCENARIO,
};
pub use sandbox_lifecycle::SANDBOX_LIFECYCLE_SCENARIO;
pub use sandbox_templates::{
SANDBOX_TEMPLATE_DUPLICATE_NAME_SCENARIO, SANDBOX_TEMPLATE_GET_AFTER_DELETE_SCENARIO,
SANDBOX_TEMPLATE_LIFECYCLE_SCENARIO, SANDBOX_TEMPLATE_MISSING_TEMPLATE_SCENARIO,
SANDBOX_TEMPLATES_SCENARIO,
};
pub use smoke::SMOKE_SCENARIO;
pub use workspace_lifecycle::{WORKSPACE_LIFECYCLE_SCENARIO, WORKSPACE_TERMINATING_SCENARIO};
Loading
Loading