seam-cli 0.5.38

CLI for the SeamJS compile-time rendering framework
/* src/cli/core/src/build/config/tests.rs */

use super::*;
use crate::config::SeamConfig;

fn parse_config(toml_str: &str) -> SeamConfig {
	toml::from_str(toml_str).unwrap()
}

fn parse_fullstack(extra_build: &str, extra: &str) -> SeamConfig {
	parse_config(&format!(
		r#"
[project]
name = "test"

[frontend]
entry = "src/client/main.tsx"

[build]
routes = "./src/routes.ts"
out_dir = ".seam/output"
backend_build_command = "bun build"
router_file = "src/server/router.ts"
{extra_build}
{extra}"#
	))
}

#[test]
fn builtin_bundler_with_entry() {
	let config = parse_fullstack("", "");
	let build = BuildConfig::from_seam_config(&config).unwrap();
	assert_eq!(build.entry, "src/client/main.tsx");
	assert_eq!(build.bundler_manifest(), ".seam/dist/.vite/manifest.json");
	assert!(build.is_fullstack);
}

#[test]
fn bundler_command_rejected() {
	let config = parse_config(
		r#"
[project]
name = "test"

[frontend]
entry = "src/main.tsx"

[build]
routes = "./src/routes.ts"
bundler_command = "npx vite build"
"#,
	);
	let result = BuildConfig::from_seam_config(&config);
	assert!(result.is_err());
	let msg = result.unwrap_err().to_string();
	assert!(msg.contains("bundlerCommand has been removed"));
}

#[test]
fn build_command_from_frontend_rejected() {
	let config = parse_config(
		r#"
[project]
name = "test"

[frontend]
build_command = "bun run build"
entry = "src/main.tsx"

[build]
routes = "./src/routes.ts"
"#,
	);
	let result = BuildConfig::from_seam_config(&config);
	assert!(result.is_err());
	let msg = result.unwrap_err().to_string();
	assert!(msg.contains("bundlerCommand has been removed"));
}

#[test]
fn no_entry_errors() {
	let config = parse_config(
		r#"
[project]
name = "test"

[build]
routes = "./src/routes.ts"
"#,
	);
	let result = BuildConfig::from_seam_config(&config);
	assert!(result.is_err());
	let msg = result.unwrap_err().to_string();
	assert!(msg.contains("frontend.entry"));
}

#[test]
fn build_config_obfuscate_defaults() {
	let config = parse_fullstack("", "");
	let bc = BuildConfig::from_seam_config(&config).unwrap();
	assert!(bc.obfuscate, "build defaults to obfuscate=true");
	assert!(!bc.sourcemap, "build defaults to sourcemap=false");
	assert!(bc.rpc_salt.is_none());
}

#[test]
fn dev_config_obfuscate_defaults() {
	let config = parse_fullstack("", "");
	let bc = BuildConfig::from_seam_config_dev(&config).unwrap();
	assert!(!bc.obfuscate, "dev defaults to obfuscate=false");
	assert!(bc.sourcemap, "dev defaults to sourcemap=true");
}

#[test]
fn explicit_obfuscate_overrides_defaults() {
	let config = parse_fullstack(
		"obfuscate = false\nsourcemap = true",
		"\n[dev]\nobfuscate = true\nsourcemap = false",
	);
	let bc = BuildConfig::from_seam_config(&config).unwrap();
	assert!(!bc.obfuscate);
	assert!(bc.sourcemap);

	let bc_dev = BuildConfig::from_seam_config_dev(&config).unwrap();
	assert!(bc_dev.obfuscate);
	assert!(!bc_dev.sourcemap);
}

#[test]
fn build_config_type_hint_defaults() {
	let config = parse_fullstack("", "");
	let bc = BuildConfig::from_seam_config(&config).unwrap();
	assert!(bc.type_hint, "build defaults to type_hint=true");

	let bc_dev = BuildConfig::from_seam_config_dev(&config).unwrap();
	assert!(bc_dev.type_hint, "dev defaults to type_hint=true");
}

#[test]
fn explicit_type_hint_overrides() {
	let config = parse_fullstack("type_hint = false", "\n[dev]\ntype_hint = false");
	let bc = BuildConfig::from_seam_config(&config).unwrap();
	assert!(!bc.type_hint);

	let bc_dev = BuildConfig::from_seam_config_dev(&config).unwrap();
	assert!(!bc_dev.type_hint);
}

#[test]
fn build_config_hash_length_defaults() {
	let config = parse_fullstack("", "");
	let bc = BuildConfig::from_seam_config(&config).unwrap();
	assert_eq!(bc.hash_length, 12, "build defaults to hash_length=12");

	let bc_dev = BuildConfig::from_seam_config_dev(&config).unwrap();
	assert_eq!(bc_dev.hash_length, 12, "dev inherits hash_length from build");
}

#[test]
fn explicit_hash_length_overrides() {
	let config = parse_fullstack("hash_length = 20", "\n[dev]\nhash_length = 8");
	let bc = BuildConfig::from_seam_config(&config).unwrap();
	assert_eq!(bc.hash_length, 20);

	let bc_dev = BuildConfig::from_seam_config_dev(&config).unwrap();
	assert_eq!(bc_dev.hash_length, 8);
}

#[test]
fn hash_length_validation() {
	let config = parse_fullstack("hash_length = 3", "");
	let result = BuildConfig::from_seam_config(&config);
	assert!(result.is_err());
	assert!(result.unwrap_err().to_string().contains("hash_length"));

	let config = parse_fullstack("hash_length = 65", "");
	let result = BuildConfig::from_seam_config(&config);
	assert!(result.is_err());
	assert!(result.unwrap_err().to_string().contains("hash_length"));
}

#[test]
fn pages_dir_without_routes() {
	let config = parse_config(
		r#"
[project]
name = "test"

[frontend]
entry = "src/client/main.tsx"

[build]
pages_dir = "src/pages"
out_dir = ".seam/output"
backend_build_command = "bun build"
router_file = "src/server/router.ts"
"#,
	);
	let bc = BuildConfig::from_seam_config(&config).unwrap();
	assert_eq!(bc.routes, ".seam/generated/routes.ts");
	assert_eq!(bc.pages_dir.as_deref(), Some("src/pages"));
}

#[test]
fn pages_dir_with_routes_errors() {
	let config = parse_config(
		r#"
[project]
name = "test"

[frontend]
entry = "src/client/main.tsx"

[build]
routes = "./src/routes.ts"
pages_dir = "src/pages"
out_dir = ".seam/output"
"#,
	);
	let result = BuildConfig::from_seam_config(&config);
	assert!(result.is_err());
	let msg = result.unwrap_err().to_string();
	assert!(msg.contains("mutually exclusive"));
}

#[test]
fn no_routes_no_pages_dir_errors() {
	let config = parse_config(
		r#"
[project]
name = "test"

[frontend]
entry = "src/client/main.tsx"

[build]
out_dir = ".seam/output"
"#,
	);
	let result = BuildConfig::from_seam_config(&config);
	assert!(result.is_err());
	let msg = result.unwrap_err().to_string();
	assert!(msg.contains("build.routes") || msg.contains("build.pages_dir"));
}

#[test]
fn parse_transport_section() {
	let config = parse_config(
		r#"
[project]
name = "test"

[frontend]
entry = "src/client/main.tsx"

[build]
routes = "./src/routes.ts"

[transport]
subscription = { prefer = "ws" }
stream = { prefer = "sse", fallback = ["http"] }
"#,
	);
	let t = config.transport.as_ref().unwrap();
	assert!(t.query.is_none());
	let sub = t.subscription.as_ref().unwrap();
	assert_eq!(sub.prefer, crate::config::TransportPreference::Ws);
	let stream = t.stream.as_ref().unwrap();
	assert_eq!(stream.prefer, crate::config::TransportPreference::Sse);
	assert_eq!(stream.fallback.as_ref().unwrap().len(), 1);
}

#[test]
fn absent_transport_is_none() {
	let config = parse_fullstack("", "");
	assert!(config.transport.is_none());
}

#[test]
fn partial_transport_override() {
	let config = parse_config(
		r#"
[project]
name = "test"

[frontend]
entry = "src/client/main.tsx"

[build]
routes = "./src/routes.ts"

[transport]
channel = { prefer = "ws", fallback = ["http"] }
"#,
	);
	let t = config.transport.as_ref().unwrap();
	assert!(t.query.is_none());
	assert!(t.command.is_none());
	assert!(t.stream.is_none());
	assert!(t.subscription.is_none());
	assert!(t.upload.is_none());
	assert!(t.channel.is_some());
}

#[test]
fn dist_dir_is_constant() {
	let config = parse_fullstack("", "");
	let bc = BuildConfig::from_seam_config(&config).unwrap();
	assert_eq!(bc.dist_dir(), ".seam/dist");
}

#[test]
fn config_hash_deterministic() {
	let config = parse_fullstack("", "");
	let bc = BuildConfig::from_seam_config(&config).unwrap();
	let h1 = bc.config_hash();
	let h2 = bc.config_hash();
	assert_eq!(h1, h2);
	assert_eq!(h1.len(), 16, "hash should be 16 hex chars");
}

#[test]
fn config_hash_changes_with_entry() {
	let c1 = parse_fullstack("", "");
	let c2 = parse_config(
		r#"
[project]
name = "test"

[frontend]
entry = "src/client/other.tsx"

[build]
routes = "./src/routes.ts"
out_dir = ".seam/output"
backend_build_command = "bun build"
router_file = "src/server/router.ts"
"#,
	);
	let h1 = BuildConfig::from_seam_config(&c1).unwrap().config_hash();
	let h2 = BuildConfig::from_seam_config(&c2).unwrap().config_hash();
	assert_ne!(h1, h2);
}

#[test]
fn config_hash_changes_with_obfuscate() {
	let c1 = parse_fullstack("obfuscate = true", "");
	let c2 = parse_fullstack("obfuscate = false", "");
	let h1 = BuildConfig::from_seam_config(&c1).unwrap().config_hash();
	let h2 = BuildConfig::from_seam_config(&c2).unwrap().config_hash();
	assert_ne!(h1, h2);
}

#[test]
fn config_hash_ignores_rpc_salt() {
	let config = parse_fullstack("", "");
	let mut bc1 = BuildConfig::from_seam_config(&config).unwrap();
	let mut bc2 = BuildConfig::from_seam_config(&config).unwrap();
	bc1.rpc_salt = Some("salt-a".to_string());
	bc2.rpc_salt = Some("salt-b".to_string());
	assert_eq!(bc1.config_hash(), bc2.config_hash());
}

#[test]
fn config_hash_ignores_config_path() {
	let config = parse_fullstack("", "");
	let mut bc1 = BuildConfig::from_seam_config(&config).unwrap();
	let mut bc2 = BuildConfig::from_seam_config(&config).unwrap();
	bc1.config_path = Some("/a/seam.config.ts".to_string());
	bc2.config_path = Some("/b/seam.config.ts".to_string());
	assert_eq!(bc1.config_hash(), bc2.config_hash());
}