mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-26 01:22:24 +02:00
850 lines
31 KiB
Rust
850 lines
31 KiB
Rust
pub mod docker_tag;
|
|
pub mod local_deps;
|
|
pub mod path_rewrite;
|
|
pub mod schema;
|
|
|
|
use std::path::Path;
|
|
|
|
use schema::{Config, EnvConfig, GraphSpec, KeepPkgTools};
|
|
|
|
use crate::constants::{
|
|
BUILD_TOOLS, DEFAULT_IMAGE_DISTRO, DEFAULT_NODE_VERSION, DEFAULT_PYTHON_VERSION,
|
|
MIN_NODE_VERSION, MIN_PYTHON_VERSION, VALID_DISTROS, VALID_PIP_INSTALLERS,
|
|
};
|
|
|
|
/// Check if a graph spec references a Node.js file based on extension.
|
|
pub fn is_node_graph(spec: &GraphSpec) -> bool {
|
|
let path_str = match spec {
|
|
GraphSpec::Path(s) => s.as_str(),
|
|
GraphSpec::Dict(m) => match m.get("path").and_then(|v| v.as_str()) {
|
|
Some(p) => p,
|
|
None => return false,
|
|
},
|
|
};
|
|
|
|
let file_path = path_str.split(':').next().unwrap_or("");
|
|
matches!(
|
|
Path::new(file_path)
|
|
.extension()
|
|
.and_then(|e| e.to_str()),
|
|
Some("ts" | "mts" | "cts" | "js" | "mjs" | "cjs")
|
|
)
|
|
}
|
|
|
|
/// Parse a Python version string "major.minor" into (major, minor).
|
|
fn parse_version(version_str: &str) -> Result<(u32, u32), String> {
|
|
let cleaned = version_str.split('-').next().unwrap_or(version_str);
|
|
let parts: Vec<&str> = cleaned.split('.').collect();
|
|
if parts.len() != 2 {
|
|
return Err(format!("Invalid version format: {version_str}"));
|
|
}
|
|
let major: u32 = parts[0]
|
|
.parse()
|
|
.map_err(|_| format!("Invalid version format: {version_str}"))?;
|
|
let minor: u32 = parts[1]
|
|
.parse()
|
|
.map_err(|_| format!("Invalid version format: {version_str}"))?;
|
|
Ok((major, minor))
|
|
}
|
|
|
|
/// Parse a Node.js version string (major only) into u32.
|
|
fn parse_node_version(version_str: &str) -> Result<u32, String> {
|
|
if version_str.contains('.') {
|
|
return Err(format!(
|
|
"Invalid Node.js version format: {version_str}. Use major version only (e.g., '20')."
|
|
));
|
|
}
|
|
version_str
|
|
.parse::<u32>()
|
|
.map_err(|_| format!("Invalid Node.js version format: {version_str}. Use major version only (e.g., '20')."))
|
|
}
|
|
|
|
/// Validate a configuration dictionary.
|
|
pub fn validate_config(mut config: Config) -> Result<Config, String> {
|
|
let graphs = &config.graphs;
|
|
|
|
let some_node = graphs.values().any(|spec| is_node_graph(spec));
|
|
let some_python = graphs.values().any(|spec| !is_node_graph(spec));
|
|
|
|
// Set defaults for node_version and python_version
|
|
if config.node_version.is_none() && some_node {
|
|
config.node_version = Some(DEFAULT_NODE_VERSION.to_string());
|
|
}
|
|
if config.python_version.is_none() && some_python {
|
|
config.python_version = Some(DEFAULT_PYTHON_VERSION.to_string());
|
|
}
|
|
|
|
// Default image_distro
|
|
if config.image_distro.is_none() {
|
|
config.image_distro = Some(DEFAULT_IMAGE_DISTRO.to_string());
|
|
}
|
|
|
|
// Default pip_installer
|
|
if config.pip_installer.is_none() {
|
|
config.pip_installer = Some("auto".to_string());
|
|
}
|
|
|
|
// Default env
|
|
if config.env.is_none() {
|
|
config.env = Some(EnvConfig::default());
|
|
}
|
|
|
|
// Validate _INTERNAL_docker_tag vs api_version
|
|
if config.internal_docker_tag.is_some() && config.api_version.is_some() {
|
|
return Err("Cannot specify both _INTERNAL_docker_tag and api_version.".to_string());
|
|
}
|
|
|
|
// Validate api_version format
|
|
if let Some(ref api_version) = config.api_version {
|
|
let cleaned = api_version.split('-').next().unwrap_or(api_version);
|
|
let parts: Vec<&str> = cleaned.split('.').collect();
|
|
if parts.len() > 3 {
|
|
return Err("Version must be major or major.minor or major.minor.patch.".to_string());
|
|
}
|
|
for part in &parts {
|
|
part.parse::<u32>()
|
|
.map_err(|_| format!("Invalid version format: {api_version}"))?;
|
|
}
|
|
}
|
|
|
|
// Validate node_version
|
|
if let Some(ref node_version) = config.node_version {
|
|
let major = parse_node_version(node_version)?;
|
|
if major < MIN_NODE_VERSION {
|
|
return Err(format!(
|
|
"Node.js version {node_version} is not supported. Minimum required version is {MIN_NODE_VERSION}."
|
|
));
|
|
}
|
|
}
|
|
|
|
// Validate python_version
|
|
if let Some(ref pyversion) = config.python_version {
|
|
let cleaned = pyversion.split('-').next().unwrap_or(pyversion);
|
|
if cleaned.split('.').count() != 2
|
|
|| !cleaned.split('.').all(|p| p.chars().all(|c| c.is_ascii_digit()))
|
|
{
|
|
return Err(format!(
|
|
"Invalid Python version format: {pyversion}. \
|
|
Use 'major.minor' format (e.g., '3.11'). \
|
|
Patch version cannot be specified."
|
|
));
|
|
}
|
|
if parse_version(pyversion)? < MIN_PYTHON_VERSION {
|
|
return Err(format!(
|
|
"Python version {pyversion} is not supported. \
|
|
Minimum required version is {}.{}.",
|
|
MIN_PYTHON_VERSION.0, MIN_PYTHON_VERSION.1
|
|
));
|
|
}
|
|
|
|
if config.dependencies.is_empty() {
|
|
return Err(
|
|
"No dependencies found in config. Add at least one dependency to 'dependencies' list."
|
|
.to_string(),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Validate graphs
|
|
if config.graphs.is_empty() {
|
|
return Err(
|
|
"No graphs found in config. Add at least one graph to 'graphs' dictionary."
|
|
.to_string(),
|
|
);
|
|
}
|
|
|
|
// Validate image_distro
|
|
if let Some(ref distro) = config.image_distro {
|
|
if distro == "bullseye" {
|
|
return Err(
|
|
"Bullseye images were deprecated in version 0.4.13. \
|
|
Please use 'bookworm' or 'debian' instead."
|
|
.to_string(),
|
|
);
|
|
}
|
|
if !VALID_DISTROS.contains(&distro.as_str()) {
|
|
return Err(format!(
|
|
"Invalid image_distro: '{distro}'. Must be one of 'debian', 'wolfi', or 'bookworm'."
|
|
));
|
|
}
|
|
}
|
|
|
|
// Validate pip_installer
|
|
if let Some(ref pip_installer) = config.pip_installer {
|
|
if !VALID_PIP_INSTALLERS.contains(&pip_installer.as_str()) {
|
|
return Err(format!(
|
|
"Invalid pip_installer: '{pip_installer}'. Must be 'auto', 'pip', or 'uv'."
|
|
));
|
|
}
|
|
}
|
|
|
|
// Validate auth config
|
|
if let Some(ref auth_conf) = config.auth {
|
|
if let Some(ref path) = auth_conf.path {
|
|
if !path.contains(':') {
|
|
return Err(format!(
|
|
"Invalid auth.path format: '{path}'. \
|
|
Must be in format './path/to/file.py:attribute_name'"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate encryption config
|
|
if let Some(ref encryption_conf) = config.encryption {
|
|
if let Some(ref path) = encryption_conf.path {
|
|
if !path.contains(':') {
|
|
return Err(format!(
|
|
"Invalid encryption.path format: '{path}'. \
|
|
Must be in format './path/to/file.py:attribute_name'"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate http config
|
|
if let Some(ref http_conf) = config.http {
|
|
if let Some(ref app) = http_conf.app {
|
|
if !app.contains(':') {
|
|
return Err(format!(
|
|
"Invalid http.app format: '{app}'. \
|
|
Must be in format './path/to/file.py:attribute_name'"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate keep_pkg_tools
|
|
if let Some(ref keep_pkg_tools) = config.keep_pkg_tools {
|
|
match keep_pkg_tools {
|
|
KeepPkgTools::List(tools) => {
|
|
for tool in tools {
|
|
if !BUILD_TOOLS.contains(&tool.as_str()) {
|
|
return Err(format!(
|
|
"Invalid keep_pkg_tools: '{tool}'. \
|
|
Must be one of 'pip', 'setuptools', 'wheel'."
|
|
));
|
|
}
|
|
}
|
|
}
|
|
KeepPkgTools::Bool(_) => {}
|
|
}
|
|
}
|
|
|
|
Ok(config)
|
|
}
|
|
|
|
/// Load and validate a configuration file.
|
|
pub fn validate_config_file(config_path: &Path) -> Result<Config, String> {
|
|
let content = std::fs::read_to_string(config_path)
|
|
.map_err(|e| format!("Could not read config file {}: {e}", config_path.display()))?;
|
|
|
|
let config: Config = serde_json::from_str(&content)
|
|
.map_err(|e| format!("Invalid JSON in config file {}: {e}", config_path.display()))?;
|
|
|
|
let validated = validate_config(config)?;
|
|
|
|
// Check package.json engines
|
|
if validated.node_version.is_some() {
|
|
let package_json_path = config_path.parent().unwrap().join("package.json");
|
|
if package_json_path.is_file() {
|
|
let pkg_content = std::fs::read_to_string(&package_json_path)
|
|
.map_err(|e| format!("Could not read package.json: {e}"))?;
|
|
let pkg: serde_json::Value = serde_json::from_str(&pkg_content).map_err(|_| {
|
|
format!(
|
|
"Invalid package.json found in langgraph config directory {}: file is not valid JSON",
|
|
package_json_path.display()
|
|
)
|
|
})?;
|
|
|
|
if let Some(engines) = pkg.get("engines").and_then(|e| e.as_object()) {
|
|
// Only 'node' engine is supported
|
|
if engines.keys().any(|k| k != "node") {
|
|
return Err(format!(
|
|
"Only 'node' engine is supported in package.json engines. Got engines: {:?}",
|
|
engines.keys().collect::<Vec<_>>()
|
|
));
|
|
}
|
|
if let Some(node_version) = engines.get("node").and_then(|v| v.as_str()) {
|
|
let major = parse_node_version(node_version)?;
|
|
if major < MIN_NODE_VERSION {
|
|
return Err(format!(
|
|
"Node.js version in package.json engines must be >= {MIN_NODE_VERSION} \
|
|
(major version only), got '{node_version}'. Minor/patch versions \
|
|
(like '20.x.y') are not supported to prevent deployment issues \
|
|
when new Node.js versions are released."
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(validated)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
use std::io::Write;
|
|
|
|
fn config_from_json(val: serde_json::Value) -> Config {
|
|
serde_json::from_value::<Config>(val).unwrap()
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// test_validate_config
|
|
// ---------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_validate_config_minimal() {
|
|
let config = config_from_json(json!({
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.py:graph"}
|
|
}));
|
|
let result = validate_config(config).unwrap();
|
|
assert_eq!(result.python_version.as_deref(), Some("3.11"));
|
|
assert_eq!(result.node_version, None);
|
|
assert_eq!(result.pip_installer.as_deref(), Some("auto"));
|
|
assert_eq!(result.image_distro.as_deref(), Some("debian"));
|
|
assert_eq!(result.base_image, None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_full() {
|
|
let config = config_from_json(json!({
|
|
"python_version": "3.12",
|
|
"dependencies": [".", "langchain"],
|
|
"graphs": {"agent": "./agent.py:graph"},
|
|
"pip_config_file": "/etc/pip.conf",
|
|
"dockerfile_lines": ["RUN apt-get update"],
|
|
"env": ".env"
|
|
}));
|
|
let result = validate_config(config).unwrap();
|
|
assert_eq!(result.python_version.as_deref(), Some("3.12"));
|
|
assert_eq!(result.pip_config_file.as_deref(), Some("/etc/pip.conf"));
|
|
assert_eq!(result.dockerfile_lines, vec!["RUN apt-get update"]);
|
|
assert!(matches!(result.env, Some(EnvConfig::File(ref s)) if s == ".env"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_python_313() {
|
|
let config = config_from_json(json!({
|
|
"python_version": "3.13",
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.py:graph"}
|
|
}));
|
|
let result = validate_config(config).unwrap();
|
|
assert_eq!(result.python_version.as_deref(), Some("3.13"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_python_39_error() {
|
|
let config = config_from_json(json!({
|
|
"python_version": "3.9",
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.py:graph"}
|
|
}));
|
|
let err = validate_config(config).unwrap_err();
|
|
assert!(err.contains("Minimum required version"), "Expected 'Minimum required version' but got: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_missing_dependencies() {
|
|
let config = config_from_json(json!({
|
|
"python_version": "3.11",
|
|
"graphs": {"agent": "./agent.py:graph"}
|
|
}));
|
|
let err = validate_config(config).unwrap_err();
|
|
assert!(err.contains("dependencies"), "Expected error about dependencies but got: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_missing_graphs() {
|
|
let config = config_from_json(json!({
|
|
"dependencies": ["."]
|
|
}));
|
|
let err = validate_config(config).unwrap_err();
|
|
assert!(err.contains("graphs"), "Expected error about graphs but got: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_python_version_with_patch() {
|
|
let config = config_from_json(json!({
|
|
"python_version": "3.11.0",
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.py:graph"}
|
|
}));
|
|
let err = validate_config(config).unwrap_err();
|
|
assert!(err.contains("Invalid Python version format"), "Expected 'Invalid Python version format' but got: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_python_version_major_only() {
|
|
let config = config_from_json(json!({
|
|
"python_version": "3",
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.py:graph"}
|
|
}));
|
|
let err = validate_config(config).unwrap_err();
|
|
assert!(err.contains("Invalid Python version format"), "Expected 'Invalid Python version format' but got: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_python_version_non_numeric() {
|
|
let config = config_from_json(json!({
|
|
"python_version": "abc.def",
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.py:graph"}
|
|
}));
|
|
let err = validate_config(config).unwrap_err();
|
|
assert!(err.contains("Invalid Python version format"), "Expected 'Invalid Python version format' but got: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_python_310_error() {
|
|
let config = config_from_json(json!({
|
|
"python_version": "3.10",
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.py:graph"}
|
|
}));
|
|
let err = validate_config(config).unwrap_err();
|
|
assert!(err.contains("Minimum required version"), "Expected 'Minimum required version' but got: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_python_312_slim() {
|
|
let config = config_from_json(json!({
|
|
"python_version": "3.12-slim",
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.py:graph"}
|
|
}));
|
|
let result = validate_config(config).unwrap();
|
|
assert_eq!(result.python_version.as_deref(), Some("3.12-slim"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_http_app_no_colon() {
|
|
let config = config_from_json(json!({
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.py:graph"},
|
|
"http": {"app": "../../examples/my_app.py"}
|
|
}));
|
|
let err = validate_config(config).unwrap_err();
|
|
assert!(err.contains("Invalid http.app format"), "Expected 'Invalid http.app format' but got: {err}");
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// test_validate_config_image_distro
|
|
// ---------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_validate_config_image_distro_debian() {
|
|
let config = config_from_json(json!({
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.py:graph"},
|
|
"image_distro": "debian"
|
|
}));
|
|
let result = validate_config(config).unwrap();
|
|
assert_eq!(result.image_distro.as_deref(), Some("debian"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_image_distro_wolfi() {
|
|
let config = config_from_json(json!({
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.py:graph"},
|
|
"image_distro": "wolfi"
|
|
}));
|
|
let result = validate_config(config).unwrap();
|
|
assert_eq!(result.image_distro.as_deref(), Some("wolfi"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_image_distro_default() {
|
|
let config = config_from_json(json!({
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.py:graph"}
|
|
}));
|
|
let result = validate_config(config).unwrap();
|
|
assert_eq!(result.image_distro.as_deref(), Some("debian"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_image_distro_bullseye_error() {
|
|
let config = config_from_json(json!({
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.py:graph"},
|
|
"image_distro": "bullseye"
|
|
}));
|
|
let err = validate_config(config).unwrap_err();
|
|
assert!(err.contains("Bullseye images were deprecated"), "Expected 'Bullseye images were deprecated' but got: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_image_distro_ubuntu_error() {
|
|
let config = config_from_json(json!({
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.py:graph"},
|
|
"image_distro": "ubuntu"
|
|
}));
|
|
let err = validate_config(config).unwrap_err();
|
|
assert!(err.contains("Invalid image_distro: 'ubuntu'"), "Expected \"Invalid image_distro: 'ubuntu'\" but got: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_image_distro_alpine_error() {
|
|
let config = config_from_json(json!({
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.py:graph"},
|
|
"image_distro": "alpine"
|
|
}));
|
|
let err = validate_config(config).unwrap_err();
|
|
assert!(err.contains("Invalid image_distro: 'alpine'"), "Expected \"Invalid image_distro: 'alpine'\" but got: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_node_with_wolfi() {
|
|
let config = config_from_json(json!({
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.mts:graph"},
|
|
"image_distro": "wolfi"
|
|
}));
|
|
let result = validate_config(config).unwrap();
|
|
assert_eq!(result.image_distro.as_deref(), Some("wolfi"));
|
|
assert_eq!(result.node_version.as_deref(), Some("20"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_node_default_distro() {
|
|
let config = config_from_json(json!({
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.mts:graph"}
|
|
}));
|
|
let result = validate_config(config).unwrap();
|
|
assert_eq!(result.image_distro.as_deref(), Some("debian"));
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// test_validate_config_pip_installer
|
|
// ---------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_validate_config_pip_installer_auto() {
|
|
let config = config_from_json(json!({
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.py:graph"},
|
|
"pip_installer": "auto"
|
|
}));
|
|
let result = validate_config(config).unwrap();
|
|
assert_eq!(result.pip_installer.as_deref(), Some("auto"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_pip_installer_pip() {
|
|
let config = config_from_json(json!({
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.py:graph"},
|
|
"pip_installer": "pip"
|
|
}));
|
|
let result = validate_config(config).unwrap();
|
|
assert_eq!(result.pip_installer.as_deref(), Some("pip"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_pip_installer_uv() {
|
|
let config = config_from_json(json!({
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.py:graph"},
|
|
"pip_installer": "uv"
|
|
}));
|
|
let result = validate_config(config).unwrap();
|
|
assert_eq!(result.pip_installer.as_deref(), Some("uv"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_pip_installer_default() {
|
|
let config = config_from_json(json!({
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.py:graph"}
|
|
}));
|
|
let result = validate_config(config).unwrap();
|
|
assert_eq!(result.pip_installer.as_deref(), Some("auto"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_pip_installer_conda_error() {
|
|
let config = config_from_json(json!({
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.py:graph"},
|
|
"pip_installer": "conda"
|
|
}));
|
|
let err = validate_config(config).unwrap_err();
|
|
assert!(err.contains("Invalid pip_installer: 'conda'"), "Expected \"Invalid pip_installer: 'conda'\" but got: {err}");
|
|
assert!(err.contains("Must be 'auto', 'pip', or 'uv'"), "Expected \"Must be 'auto', 'pip', or 'uv'\" but got: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_pip_installer_invalid_error() {
|
|
let config = config_from_json(json!({
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.py:graph"},
|
|
"pip_installer": "invalid"
|
|
}));
|
|
let err = validate_config(config).unwrap_err();
|
|
assert!(err.contains("Invalid pip_installer: 'invalid'"), "Expected \"Invalid pip_installer: 'invalid'\" but got: {err}");
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// test_validate_config_multiplatform
|
|
// ---------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_validate_config_js_only_no_explicit_versions() {
|
|
let config = config_from_json(json!({
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./js.mts:graph"}
|
|
}));
|
|
let result = validate_config(config).unwrap();
|
|
assert_eq!(result.node_version.as_deref(), Some("20"));
|
|
assert_eq!(result.python_version, None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_both_versions_explicit() {
|
|
let config = config_from_json(json!({
|
|
"python_version": "3.12",
|
|
"node_version": "22",
|
|
"dependencies": ["."],
|
|
"graphs": {
|
|
"agent_py": "./agent.py:graph",
|
|
"agent_js": "./agent.mts:graph"
|
|
}
|
|
}));
|
|
let result = validate_config(config).unwrap();
|
|
assert_eq!(result.python_version.as_deref(), Some("3.12"));
|
|
assert_eq!(result.node_version.as_deref(), Some("22"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_mixed_graphs_no_explicit_versions() {
|
|
let config = config_from_json(json!({
|
|
"dependencies": ["."],
|
|
"graphs": {
|
|
"agent_py": "./agent.py:graph",
|
|
"agent_js": "./agent.mts:graph"
|
|
}
|
|
}));
|
|
let result = validate_config(config).unwrap();
|
|
assert_eq!(result.node_version.as_deref(), Some("20"));
|
|
assert_eq!(result.python_version.as_deref(), Some("3.11"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_mixed_graphs_node_version_only() {
|
|
let config = config_from_json(json!({
|
|
"node_version": "22",
|
|
"dependencies": ["."],
|
|
"graphs": {
|
|
"agent_py": "./agent.py:graph",
|
|
"agent_js": "./agent.mts:graph"
|
|
}
|
|
}));
|
|
let result = validate_config(config).unwrap();
|
|
assert_eq!(result.node_version.as_deref(), Some("22"));
|
|
assert_eq!(result.python_version.as_deref(), Some("3.11"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_mixed_graphs_python_version_only() {
|
|
let config = config_from_json(json!({
|
|
"python_version": "3.12",
|
|
"dependencies": ["."],
|
|
"graphs": {
|
|
"agent_py": "./agent.py:graph",
|
|
"agent_js": "./agent.mts:graph"
|
|
}
|
|
}));
|
|
let result = validate_config(config).unwrap();
|
|
assert_eq!(result.node_version.as_deref(), Some("20"));
|
|
assert_eq!(result.python_version.as_deref(), Some("3.12"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_unknown_extension_assumes_python() {
|
|
let config = config_from_json(json!({
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "local.workflow:graph"}
|
|
}));
|
|
let result = validate_config(config).unwrap();
|
|
assert_eq!(result.python_version.as_deref(), Some("3.11"));
|
|
assert_eq!(result.node_version, None);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// test_validate_config_encryption
|
|
// ---------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_validate_config_encryption_valid() {
|
|
let config = config_from_json(json!({
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.py:graph"},
|
|
"encryption": {"path": "./encryption.py:encryption"}
|
|
}));
|
|
let result = validate_config(config).unwrap();
|
|
assert!(result.encryption.is_some());
|
|
assert_eq!(
|
|
result.encryption.as_ref().unwrap().path.as_deref(),
|
|
Some("./encryption.py:encryption")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_encryption_no_colon_error() {
|
|
let config = config_from_json(json!({
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.py:graph"},
|
|
"encryption": {"path": "./encryption.py"}
|
|
}));
|
|
let err = validate_config(config).unwrap_err();
|
|
assert!(err.contains("Invalid encryption.path format"), "Expected 'Invalid encryption.path format' but got: {err}");
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// test_validate_config_file
|
|
// ---------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_validate_config_file_node_config() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let config_path = tmp.path().join("langgraph.json");
|
|
let config_json = json!({
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.mts:graph"}
|
|
});
|
|
let mut f = std::fs::File::create(&config_path).unwrap();
|
|
write!(f, "{}", serde_json::to_string(&config_json).unwrap()).unwrap();
|
|
drop(f);
|
|
|
|
let result = validate_config_file(&config_path).unwrap();
|
|
assert_eq!(result.node_version.as_deref(), Some("20"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_file_with_valid_package_json() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let config_path = tmp.path().join("langgraph.json");
|
|
let config_json = json!({
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.mts:graph"}
|
|
});
|
|
std::fs::write(&config_path, serde_json::to_string(&config_json).unwrap()).unwrap();
|
|
|
|
let pkg_path = tmp.path().join("package.json");
|
|
let pkg_json = json!({
|
|
"engines": {"node": "20"}
|
|
});
|
|
std::fs::write(&pkg_path, serde_json::to_string(&pkg_json).unwrap()).unwrap();
|
|
|
|
let result = validate_config_file(&config_path).unwrap();
|
|
assert_eq!(result.node_version.as_deref(), Some("20"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_file_package_json_minor_version_error() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let config_path = tmp.path().join("langgraph.json");
|
|
let config_json = json!({
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.mts:graph"}
|
|
});
|
|
std::fs::write(&config_path, serde_json::to_string(&config_json).unwrap()).unwrap();
|
|
|
|
let pkg_path = tmp.path().join("package.json");
|
|
let pkg_json = json!({
|
|
"engines": {"node": "20.18"}
|
|
});
|
|
std::fs::write(&pkg_path, serde_json::to_string(&pkg_json).unwrap()).unwrap();
|
|
|
|
let err = validate_config_file(&config_path).unwrap_err();
|
|
assert!(err.contains("Use major version only"), "Expected 'Use major version only' but got: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_file_package_json_old_node_error() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let config_path = tmp.path().join("langgraph.json");
|
|
let config_json = json!({
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.mts:graph"}
|
|
});
|
|
std::fs::write(&config_path, serde_json::to_string(&config_json).unwrap()).unwrap();
|
|
|
|
let pkg_path = tmp.path().join("package.json");
|
|
let pkg_json = json!({
|
|
"engines": {"node": "18"}
|
|
});
|
|
std::fs::write(&pkg_path, serde_json::to_string(&pkg_json).unwrap()).unwrap();
|
|
|
|
let err = validate_config_file(&config_path).unwrap_err();
|
|
assert!(err.contains("must be >= 20"), "Expected 'must be >= 20' but got: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_file_package_json_deno_engine_error() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let config_path = tmp.path().join("langgraph.json");
|
|
let config_json = json!({
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.mts:graph"}
|
|
});
|
|
std::fs::write(&config_path, serde_json::to_string(&config_json).unwrap()).unwrap();
|
|
|
|
let pkg_path = tmp.path().join("package.json");
|
|
let pkg_json = json!({
|
|
"engines": {"node": "20", "deno": "1.0"}
|
|
});
|
|
std::fs::write(&pkg_path, serde_json::to_string(&pkg_json).unwrap()).unwrap();
|
|
|
|
let err = validate_config_file(&config_path).unwrap_err();
|
|
assert!(err.contains("Only 'node' engine is supported"), "Expected 'Only node engine is supported' but got: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_file_invalid_package_json() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let config_path = tmp.path().join("langgraph.json");
|
|
let config_json = json!({
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.mts:graph"}
|
|
});
|
|
std::fs::write(&config_path, serde_json::to_string(&config_json).unwrap()).unwrap();
|
|
|
|
let pkg_path = tmp.path().join("package.json");
|
|
std::fs::write(&pkg_path, "this is not valid json!!!").unwrap();
|
|
|
|
let err = validate_config_file(&config_path).unwrap_err();
|
|
assert!(err.contains("Invalid package.json"), "Expected 'Invalid package.json' but got: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_config_file_python_ignores_bad_package_json() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let config_path = tmp.path().join("langgraph.json");
|
|
let config_json = json!({
|
|
"dependencies": ["."],
|
|
"graphs": {"agent": "./agent.py:graph"}
|
|
});
|
|
std::fs::write(&config_path, serde_json::to_string(&config_json).unwrap()).unwrap();
|
|
|
|
// Write a bad package.json - should be ignored for Python-only config
|
|
let pkg_path = tmp.path().join("package.json");
|
|
std::fs::write(&pkg_path, "this is not valid json!!!").unwrap();
|
|
|
|
let result = validate_config_file(&config_path).unwrap();
|
|
assert_eq!(result.python_version.as_deref(), Some("3.11"));
|
|
}
|
|
}
|