fabro/lib/crates/fabro-cli/tests/it/cmd/sandbox_cp.rs
Bryan Helmkamp e18772888e
Model run sandbox lifecycle explicitly (#431)
## Summary

Fixes sandbox state reporting by separating a requested sandbox plan
from an initialized sandbox instance. Runs now project sandbox lifecycle
as `planned`, `initializing`, `ready`, or `failed`, and live sandbox
operations only proceed once a real instance exists.

## Changes

- Introduces `RunSandboxPlan`, `RunSandboxInstance`, and
lifecycle-backed `RunSandbox` domain types, with serde validation that
prevents `ready` sandboxes without an instance.
- Updates store projection behavior so sandbox events transition through
planned, initializing, ready, and failed states while preserving
requested provider/image/snapshot separately from runtime metadata.
- Tightens server sandbox handlers so
details/files/services/terminal/VNC helpers require an initialized
instance and return a clear 404 when the sandbox was never created.
- Updates the OpenAPI contract and regenerated clients so `Run.sandbox`
exposes lifecycle state while `SandboxDetails.sandbox` contains only
initialized instance metadata.
- Updates the web UI to render lifecycle state directly from run
summaries, hide the Sandbox tab for pure planned sandboxes, and disable
sandbox controls until the instance is ready.
- Cleans up duplicated lifecycle display/type logic and duplicate
server-side sandbox instance loading found during review.

| Lifecycle state | Meaning | Live controls |
| --- | --- | --- |
| `planned` | Sandbox was requested but no provider instance exists |
Hidden/disabled |
| `initializing` | Provider setup has started | State view only |
| `ready` | Runtime instance exists | Enabled |
| `failed` | Provider setup failed with error details | State view only
|

## Testing

- `cargo check --workspace`
- `cargo +nightly-2026-04-14 fmt --check --all`
- `git diff --check`
- `cd apps/fabro-web && bun run typecheck`
- `cd apps/fabro-web && bun test app/routes/run-detail.test.ts
app/routes/run-sandbox.test.tsx
app/components/run-summary-panel.test.tsx`
- `cargo nextest run -p fabro-types --test sandbox_model_serde`
- `cargo nextest run -p fabro-store
run_created_projects_planned_sandbox_lifecycle
sandbox_lifecycle_events_update_projected_sandbox_state
run_failed_before_sandbox_events_leaves_sandbox_planned`
- `cargo nextest run -p fabro-server
planned_sandbox_returns_404_from_details_endpoint
planned_sandbox_rejects_live_operations
failed_sandbox_rejects_live_operations
local_sandbox_returns_provider_neutral_details`
- `cargo nextest run -p fabro-api --test run_sandbox_round_trip`
- `cargo nextest run -p fabro-api --test sandbox_details_round_trip`

---

[![Compound
Engineering](https://img.shields.io/badge/Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin)
🤖 Generated with GPT-5 via [Codex](https://openai.com/codex)
2026-05-27 12:48:56 -04:00

147 lines
4.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#![expect(
clippy::disallowed_methods,
reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path"
)]
use fabro_test::{fabro_snapshot, test_context};
use super::support::{read_text, setup_local_sandbox_run, setup_seeded_created_dry_run, text_tree};
#[test]
fn help() {
let context = test_context!();
let mut cmd = context.command();
cmd.args(["sandbox", "cp", "--help"]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
Copy files to/from a run's sandbox
Usage: fabro sandbox cp [OPTIONS] <SRC> <DST>
Arguments:
<SRC> Source: `<run-id>:<path>` or local path
<DST> Destination: `<run-id>:<path>` or local path
Options:
--json Output as JSON [env: FABRO_JSON=]
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
-r, --recursive Recurse into directories
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--verbose Enable verbose output [env: FABRO_VERBOSE=]
-h, --help Print help
----- stderr -----
");
}
#[test]
fn sandbox_cp_run_without_sandbox_json_errors_cleanly() {
let context = test_context!();
let run = setup_seeded_created_dry_run(&context);
let dest = context.temp_dir.join("missing.txt");
let mut cmd = context.cp();
cmd.args([&format!("{}:foo.txt", run.run_id), dest.to_str().unwrap()]);
fabro_snapshot!(context.filters(), cmd, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× Run sandbox was not created.
");
}
#[test]
fn sandbox_cp_downloads_file_from_run() {
let context = test_context!();
let setup = setup_local_sandbox_run(&context);
let dest = context.temp_dir.join("downloaded-root.txt");
let mut cmd = context.cp();
cmd.args([
&format!("{}:sandbox_dir/download_me/root.txt", setup.run.run_id),
dest.to_str().unwrap(),
]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
");
assert_eq!(read_text(&dest), "keep");
}
#[test]
fn sandbox_cp_downloads_file_from_store_without_sandbox_json() {
let context = test_context!();
let setup = setup_local_sandbox_run(&context);
let dest = context.temp_dir.join("downloaded-from-store.txt");
let mut cmd = context.cp();
cmd.args([
&format!("{}:sandbox_dir/download_me/root.txt", setup.run.run_id),
dest.to_str().unwrap(),
]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
");
assert_eq!(read_text(&dest), "keep");
}
#[test]
fn sandbox_cp_uploads_file_to_run() {
let context = test_context!();
let setup = setup_local_sandbox_run(&context);
let local = context.temp_dir.join("upload.txt");
std::fs::write(&local, "uploaded-root")
.unwrap_or_else(|err| panic!("failed to write {}: {err}", local.display()));
let mut cmd = context.cp();
cmd.args([
local.to_str().unwrap(),
&format!("{}:sandbox_dir/uploaded.txt", setup.run.run_id),
]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
");
assert_eq!(
read_text(&setup.workspace_dir.join("sandbox_dir/uploaded.txt")),
"uploaded-root"
);
}
#[test]
fn sandbox_cp_recursive_downloads_directory() {
let context = test_context!();
let setup = setup_local_sandbox_run(&context);
let dest = context.temp_dir.join("download-dir");
let mut cmd = context.cp();
cmd.args([
"-r",
&format!("{}:sandbox_dir/download_me", setup.run.run_id),
dest.to_str().unwrap(),
]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
");
insta::assert_snapshot!(
text_tree(&dest).join("\n"),
@r"
nested/child.txt = nested
root.txt = keep
"
);
}