use std::error::Error;
use std::net::{Ipv4Addr, SocketAddr, TcpListener};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU32, Ordering};
use frame_cli::coherence::coherence_violations;
use frame_cli::error::CliError;
use frame_cli::preflight::ensure_ports_available;
use frame_cli::scaffold::{self, GitInit, NewOptions};
use frame_host::FrameConfig;
type TestResult = Result<(), Box<dyn Error>>;
static UNIQUE: AtomicU32 = AtomicU32::new(0);
fn temp_dir(tag: &str) -> Result<PathBuf, Box<dyn Error>> {
let dir = std::env::temp_dir().join(format!(
"frame-portless-cli-{tag}-{}-{}",
std::process::id(),
UNIQUE.fetch_add(1, Ordering::SeqCst)
));
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
fn write_portless_config(dir: &Path) -> Result<PathBuf, Box<dyn Error>> {
let path = dir.join("frame.toml");
std::fs::write(
&path,
"[frame]\nassets = \"page/dist\"\nauth_token = \"\"\nchannel = \"demo.events\"\n",
)?;
Ok(path)
}
#[test]
fn portless_config_has_no_coherence_violations() -> TestResult {
let dir = temp_dir("coherence")?;
let path = write_portless_config(&dir)?;
let config = FrameConfig::load(&path)?;
assert!(
!config.ports_explicit,
"a portless config is not a fully-stated topology"
);
let violations = coherence_violations(&config);
assert!(
violations.is_empty(),
"a portless config must report no coherence violations, got: {violations:?}"
);
ensure_ports_available(&config)?;
Ok(())
}
#[test]
fn explicit_stated_page_port_taken_is_named_by_preflight() -> TestResult {
let squatter = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))?;
let taken = squatter.local_addr()?;
let free: Vec<u16> = (0..3)
.map(|_| -> Result<u16, Box<dyn Error>> {
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))?;
Ok(listener.local_addr()?.port())
})
.collect::<Result<_, _>>()?;
let dir = temp_dir("explicit-taken")?;
let path = dir.join("frame.toml");
std::fs::write(
&path,
format!(
r#"[frame]
bind = "127.0.0.1:{page}"
assets = "page/dist"
auth_token = ""
channel = "demo.events"
[bus]
listen_address = "127.0.0.1:{tcp}"
health_listen_address = "127.0.0.1:{health}"
drain_timeout_ms = 1000
channels = [{{ name = "demo.events", durable = false }}]
routing_rules = []
[bus.websocket]
listen_address = "127.0.0.1:{ws}"
path = "/liminal"
allowed_origins = ["http://127.0.0.1:{page}"]
"#,
page = taken.port(),
tcp = free[0],
health = free[1],
ws = free[2],
),
)?;
let config = FrameConfig::load(&path)?;
assert!(config.ports_explicit, "this config states every port");
let result = ensure_ports_available(&config);
let Err(CliError::PortUnavailable {
role, key, addr, ..
}) = result
else {
return Err(format!("expected PortUnavailable, got {result:?}").into());
};
assert_eq!(role, "page server", "the refusal must name the socket role");
assert_eq!(
key, "[frame].bind",
"the refusal must name the frame.toml key"
);
assert_eq!(addr, taken, "the refusal must name the taken address");
drop(squatter);
Ok(())
}
#[test]
fn git_init_in_clean_dir_creates_repo_with_initial_commit() -> TestResult {
if which_git().is_none() {
return Ok(());
}
let parent = temp_dir("git-clean")?;
let project = scaffold::generate(&NewOptions {
name: "clean_app".to_owned(),
target_parent: parent.clone(),
})?;
let outcome = scaffold::init_repo(&project)?;
assert_eq!(
outcome,
GitInit::Initialized,
"a clean directory must get a fresh repository"
);
assert!(project.join(".git").is_dir(), "a .git directory must exist");
let log = git(&project, &["log", "--oneline"])?;
assert_eq!(
log.lines().count(),
1,
"there must be exactly one initial commit: {log}"
);
let tracked = git(&project, &["ls-files"])?;
for expected in [
"Cargo.toml",
"frame.toml",
"README.md",
".gitignore",
"host/src/lib.rs",
"page/src/main.ts",
] {
assert!(
tracked.lines().any(|line| line == expected),
"{expected} must be tracked in the initial commit:\n{tracked}"
);
}
std::fs::remove_dir_all(&parent)?;
Ok(())
}
#[test]
fn git_init_inside_existing_repo_skips() -> TestResult {
if which_git().is_none() {
return Ok(());
}
let parent = temp_dir("git-nested")?;
git(&parent, &["init", "-q"])?;
let project = scaffold::generate(&NewOptions {
name: "nested_app".to_owned(),
target_parent: parent.clone(),
})?;
let outcome = scaffold::init_repo(&project)?;
assert_eq!(
outcome,
GitInit::SkippedInsideExistingRepo,
"a scaffold inside an existing work tree must not nest a repository"
);
assert!(
!project.join(".git").exists(),
"no nested .git may be created inside an existing repository"
);
std::fs::remove_dir_all(&parent)?;
Ok(())
}
fn which_git() -> Option<()> {
Command::new("git")
.arg("--version")
.output()
.ok()
.filter(|output| output.status.success())
.map(|_| ())
}
fn git(dir: &Path, args: &[&str]) -> Result<String, Box<dyn Error>> {
let output = Command::new("git").args(args).current_dir(dir).output()?;
if !output.status.success() {
return Err(format!(
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
)
.into());
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}