use super::*;
fn fixture() -> tempfile::TempDir {
let root = tempfile::tempdir().unwrap();
create_dir_all(root.path().join(".forklaunch/contracts/native-api")).unwrap();
create_dir_all(root.path().join("artifacts/typescript")).unwrap();
create_dir_all(root.path().join(".forklaunch/openapi/legacy-api")).unwrap();
write(
root.path().join(".forklaunch/manifest.toml"),
r#"
name = "mixed-sdk"
language = "mixed"
[[projects]]
name = "native-api"
type = "Service"
routers = ["health"]
[projects.metadata]
language = "rust"
[[projects]]
name = "typescript-api"
type = "Service"
[projects.metadata]
language = "typescript"
openapiPath = "artifacts/typescript/spec.json"
[[projects]]
name = "legacy-api"
type = "Service"
[[projects]]
name = "helpers"
type = "Library"
[[projects]]
name = "minio"
type = "Service"
"#,
)
.unwrap();
let manifest_path = root.path().join(".forklaunch/manifest.toml");
let manifest_source = read_to_string(&manifest_path).unwrap();
write(&manifest_path, format!("{manifest_source}\n[framework]\nversion = \"0.1.2\"\nblueprint_version = \"0.1.0\"\n")).unwrap();
write(root.path().join(".forklaunch/contracts/native-api/contract.json"), json!({
"routes":[{"method":"GET","path":"/native","operationName":"getNative","responses":[{"statusCode":200,"body":{"schema":{"jsonSchema":{"type":"object","properties":{"origin":{"type":"string"}},"required":["origin"]}}}}]}]
}).to_string()).unwrap();
write(root.path().join("artifacts/typescript/spec.json"), json!({
"openapi":"3.1.0","info":{"title":"TypeScript service","version":"1"},
"paths":{"/profiles/{id}":{"get":{"operationId":"getProfile","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Profile","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Profile"}}}}}}}},
"components":{"schemas":{"Profile":{"type":"object","properties":{"profileName":{"type":"string"}},"required":["profileName"]}}}
}).to_string()).unwrap();
write(
root.path()
.join(".forklaunch/openapi/legacy-api/openapi.json"),
json!({
"openapi":"3.1.0","info":{"title":"Legacy service","version":"1"},"paths":{}
})
.to_string(),
)
.unwrap();
root
}
fn generate(root: &Path, check: bool) -> Result<()> {
generate_or_check_sdk_outputs(
root,
"typescript",
"contract",
None,
check,
&mut StandardStream::stdout(ColorChoice::Never),
)
}
#[test]
fn mixed_contract_source_imports_typescript_openapi_and_legacy_services() {
let root = fixture();
let manifest = read_manifest(root.path()).unwrap();
let services = http_sdk_services_from_contracts(root.path(), &manifest, None).unwrap();
assert_eq!(
services
.iter()
.map(|service| service.name.as_str())
.collect::<Vec<_>>(),
["native-api", "typescript-api", "legacy-api"]
);
let source = typescript_sdk(&services).unwrap();
assert!(source.contains("getNative:"));
assert!(source.contains("getProfile:"));
assert!(source.contains("profileName: string"));
assert!(!source.contains("helpers:"));
assert!(!source.contains("minio:"));
for service in &services {
rust_sdk(service).unwrap();
}
let selected =
http_sdk_services_from_contracts(root.path(), &manifest, Some("typescript-api")).unwrap();
assert_eq!(selected.len(), 1);
assert_eq!(selected[0].name, "typescript-api");
}
#[test]
fn lifecycle_preserves_mixed_clients_and_check_is_read_only() {
let root = fixture();
generate(root.path(), false).unwrap();
let path = root.path().join("sdks/typescript/src/index.ts");
let before = read_to_string(&path).unwrap();
let state = root.path().join(crate::core::forklaunch_state::MANIFEST);
let tracked = std::fs::read(&state).unwrap();
generate(root.path(), true).unwrap();
assert_eq!(std::fs::read(&state).unwrap(), tracked);
regenerate_sdk_outputs_if_present(root.path(), &mut StandardStream::stdout(ColorChoice::Never))
.unwrap();
assert_eq!(read_to_string(&path).unwrap(), before);
let services = crate::sdk::http_sources::read(root.path()).unwrap();
assert_eq!(
services,
BTreeSet::from(["typescript-api".into(), "legacy-api".into()])
);
}
#[test]
fn missing_tracked_typescript_export_does_not_remove_its_sdk() {
let root = fixture();
generate(root.path(), false).unwrap();
let path = root.path().join("sdks/typescript/src/index.ts");
let before = std::fs::read(&path).unwrap();
std::fs::remove_file(root.path().join("artifacts/typescript/spec.json")).unwrap();
let error = regenerate_sdk_outputs_if_present(
root.path(),
&mut StandardStream::stdout(ColorChoice::Never),
)
.unwrap_err();
assert!(format!("{error:#}").contains("Restore the TypeScript OpenAPI export"));
assert_eq!(std::fs::read(&path).unwrap(), before);
}
#[test]
fn new_unexported_typescript_service_does_not_break_native_lifecycle() {
let root = fixture();
std::fs::remove_file(root.path().join("artifacts/typescript/spec.json")).unwrap();
let services =
rust_sdk_services_for_lifecycle(root.path(), &read_manifest(root.path()).unwrap()).unwrap();
assert_eq!(services.len(), 2);
assert!(
services
.iter()
.all(|service| service.name != "typescript-api")
);
assert!(
generate(root.path(), false).is_err(),
"explicit generation requires all selected exports"
);
assert!(!root.path().join("sdks/typescript/src/index.ts").exists());
}
#[cfg(unix)]
#[test]
fn mixed_artifacts_and_tracking_reject_external_symlinks() {
let root = fixture();
let outside = tempfile::tempdir().unwrap();
let spec = root.path().join("artifacts/typescript/spec.json");
std::fs::rename(&spec, outside.path().join("spec.json")).unwrap();
std::os::unix::fs::symlink(outside.path().join("spec.json"), &spec).unwrap();
assert!(generate(root.path(), false).is_err());
std::fs::remove_file(&spec).unwrap();
std::fs::rename(outside.path().join("spec.json"), &spec).unwrap();
assert!(!root.path().join("sdks/typescript/src/index.ts").exists());
}
#[test]
fn typescript_only_artifact_emits_both_sdk_languages() {
let root = fixture();
let mut manifest = read_manifest(root.path()).unwrap();
manifest["language"] = Value::String("typescript".into());
manifest["projects"]
.as_array_mut()
.unwrap()
.retain(|project| project.get("name").and_then(Value::as_str) == Some("typescript-api"));
write(
root.path().join(".forklaunch/manifest.toml"),
toml::to_string(&manifest).unwrap(),
)
.unwrap();
assert!(!rust_project::is_rust_app_root(root.path()).unwrap());
let original_manifest = read_to_string(root.path().join(".forklaunch/manifest.toml")).unwrap();
generate_or_check_sdk_outputs(
root.path(),
"all",
"openapi",
None,
false,
&mut StandardStream::stdout(ColorChoice::Never),
)
.unwrap();
let typescript = read_to_string(root.path().join("sdks/typescript/src/index.ts")).unwrap();
let rust = read_to_string(root.path().join("sdks/rust/typescript-api-sdk/src/lib.rs")).unwrap();
assert!(typescript.contains("getProfile"));
assert!(rust.contains("get_profile"));
assert!(typescript.contains("profileName"));
assert!(rust.contains("profile_name"));
assert_eq!(
original_manifest,
read_to_string(root.path().join(".forklaunch/manifest.toml")).unwrap()
);
assert!(!root.path().join("Dockerfile.rust").exists());
generate_or_check_sdk_outputs(
root.path(),
"all",
"openapi",
None,
true,
&mut StandardStream::stdout(ColorChoice::Never),
)
.unwrap();
}