mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-21 15:12:26 +02:00
1845 lines
57 KiB
Go
1845 lines
57 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// mustValidate validates a config map and fatals on error.
|
|
func mustValidate(t *testing.T, raw map[string]any) map[string]any {
|
|
t.Helper()
|
|
cfg, err := ValidateConfig(raw)
|
|
if err != nil {
|
|
t.Fatalf("ValidateConfig failed: %v", err)
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
// writeFile is a test helper to create a file with the given content.
|
|
func writeFile(t *testing.T, path, content string) {
|
|
t.Helper()
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
t.Fatalf("MkdirAll(%q): %v", dir, err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
t.Fatalf("WriteFile(%q): %v", path, err)
|
|
}
|
|
}
|
|
|
|
// assertContains checks that got contains the substring want.
|
|
func assertContains(t *testing.T, got, want string) {
|
|
t.Helper()
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("expected output to contain %q, but it does not.\nGot:\n%s", want, got)
|
|
}
|
|
}
|
|
|
|
// assertNotContains checks that got does NOT contain the substring want.
|
|
func assertNotContains(t *testing.T, got, want string) {
|
|
t.Helper()
|
|
if strings.Contains(got, want) {
|
|
t.Errorf("expected output to NOT contain %q, but it does.\nGot:\n%s", want, got)
|
|
}
|
|
}
|
|
|
|
// extractEnvJSON extracts and parses a JSON value from an ENV line in a Dockerfile.
|
|
func extractEnvJSON(t *testing.T, dockerfile, varName string) map[string]any {
|
|
t.Helper()
|
|
prefix := "ENV " + varName + "='"
|
|
for _, line := range strings.Split(dockerfile, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, prefix) && strings.HasSuffix(line, "'") {
|
|
jsonStr := line[len(prefix) : len(line)-1]
|
|
var result map[string]any
|
|
if err := json.Unmarshal([]byte(jsonStr), &result); err != nil {
|
|
t.Fatalf("failed to parse JSON from %s: %v\njsonStr=%s", varName, err, jsonStr)
|
|
}
|
|
return result
|
|
}
|
|
}
|
|
t.Fatalf("%s not found in Dockerfile env lines", varName)
|
|
return nil
|
|
}
|
|
|
|
// setupSimplePythonProject creates a minimal Python project directory with a graph file
|
|
// and returns the config path.
|
|
func setupSimplePythonProject(t *testing.T, dir string) string {
|
|
t.Helper()
|
|
writeFile(t, filepath.Join(dir, "agent.py"), "graph = None\n")
|
|
configPath := filepath.Join(dir, "langgraph.json")
|
|
writeFile(t, configPath, "{}\n")
|
|
return configPath
|
|
}
|
|
|
|
// setupPythonProjectWithGraphs creates a project with a graphs subdirectory.
|
|
func setupPythonProjectWithGraphs(t *testing.T, dir string) string {
|
|
t.Helper()
|
|
writeFile(t, filepath.Join(dir, "graphs", "agent.py"), "graph = None\n")
|
|
configPath := filepath.Join(dir, "langgraph.json")
|
|
writeFile(t, configPath, "{}\n")
|
|
return configPath
|
|
}
|
|
|
|
// setupNodeProject creates a minimal Node.js project directory.
|
|
func setupNodeProject(t *testing.T, dir string) string {
|
|
t.Helper()
|
|
writeFile(t, filepath.Join(dir, "graphs", "agent.js"), "export const graph = {};\n")
|
|
writeFile(t, filepath.Join(dir, "graphs", "auth.mts"), "export const auth = {};\n")
|
|
writeFile(t, filepath.Join(dir, "package.json"), `{"name":"test"}`+"\n")
|
|
configPath := filepath.Join(dir, "langgraph.json")
|
|
writeFile(t, configPath, "{}\n")
|
|
return configPath
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DefaultBaseImage tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestDefaultBaseImage(t *testing.T) {
|
|
t.Run("python_combined_mode", func(t *testing.T) {
|
|
cfg := mustValidate(t, map[string]any{
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
})
|
|
got := DefaultBaseImage(cfg, "combined_queue_worker")
|
|
if got != "langchain/langgraph-api" {
|
|
t.Fatalf("expected langchain/langgraph-api, got %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("python_default_mode", func(t *testing.T) {
|
|
cfg := mustValidate(t, map[string]any{
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
})
|
|
got := DefaultBaseImage(cfg, "")
|
|
if got != "langchain/langgraph-api" {
|
|
t.Fatalf("expected langchain/langgraph-api, got %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("python_distributed_mode", func(t *testing.T) {
|
|
cfg := mustValidate(t, map[string]any{
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
})
|
|
got := DefaultBaseImage(cfg, "distributed")
|
|
if got != "langchain/langgraph-executor" {
|
|
t.Fatalf("expected langchain/langgraph-executor, got %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("distributed_with_explicit_base_image", func(t *testing.T) {
|
|
cfg := mustValidate(t, map[string]any{
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
"base_image": "my-custom-image:latest",
|
|
})
|
|
got := DefaultBaseImage(cfg, "distributed")
|
|
if got != "my-custom-image:latest" {
|
|
t.Fatalf("expected my-custom-image:latest, got %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("nodejs", func(t *testing.T) {
|
|
cfg := mustValidate(t, map[string]any{
|
|
"node_version": "20",
|
|
"graphs": map[string]any{"agent": "./agent.js:graph"},
|
|
})
|
|
got := DefaultBaseImage(cfg, "")
|
|
if got != "langchain/langgraphjs-api" {
|
|
t.Fatalf("expected langchain/langgraphjs-api, got %q", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DockerTag tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestDockerTag(t *testing.T) {
|
|
t.Run("python_debian", func(t *testing.T) {
|
|
cfg := mustValidate(t, map[string]any{
|
|
"python_version": "3.11",
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
})
|
|
got := DockerTag(cfg, "langchain/langgraph-api", "")
|
|
if got != "langchain/langgraph-api:3.11" {
|
|
t.Fatalf("expected langchain/langgraph-api:3.11, got %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("python_explicit_debian", func(t *testing.T) {
|
|
cfg := mustValidate(t, map[string]any{
|
|
"python_version": "3.11",
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
"image_distro": "debian",
|
|
})
|
|
got := DockerTag(cfg, "langchain/langgraph-api", "")
|
|
if got != "langchain/langgraph-api:3.11" {
|
|
t.Fatalf("expected langchain/langgraph-api:3.11, got %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("python_wolfi", func(t *testing.T) {
|
|
cfg := mustValidate(t, map[string]any{
|
|
"python_version": "3.11",
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
"image_distro": "wolfi",
|
|
})
|
|
got := DockerTag(cfg, "langchain/langgraph-api", "")
|
|
if got != "langchain/langgraph-api:3.11-wolfi" {
|
|
t.Fatalf("expected langchain/langgraph-api:3.11-wolfi, got %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("node_debian", func(t *testing.T) {
|
|
cfg := mustValidate(t, map[string]any{
|
|
"node_version": "20",
|
|
"graphs": map[string]any{"agent": "./agent.js:graph"},
|
|
})
|
|
got := DockerTag(cfg, "langchain/langgraphjs-api", "")
|
|
if got != "langchain/langgraphjs-api:20" {
|
|
t.Fatalf("expected langchain/langgraphjs-api:20, got %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("node_wolfi", func(t *testing.T) {
|
|
cfg := mustValidate(t, map[string]any{
|
|
"node_version": "20",
|
|
"graphs": map[string]any{"agent": "./agent.js:graph"},
|
|
"image_distro": "wolfi",
|
|
})
|
|
got := DockerTag(cfg, "langchain/langgraphjs-api", "")
|
|
if got != "langchain/langgraphjs-api:20-wolfi" {
|
|
t.Fatalf("expected langchain/langgraphjs-api:20-wolfi, got %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("custom_base_image_wolfi", func(t *testing.T) {
|
|
cfg := mustValidate(t, map[string]any{
|
|
"python_version": "3.12",
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
"image_distro": "wolfi",
|
|
"base_image": "my-registry/custom-image",
|
|
})
|
|
got := DockerTag(cfg, "my-registry/custom-image", "")
|
|
if got != "my-registry/custom-image:3.12-wolfi" {
|
|
t.Fatalf("expected my-registry/custom-image:3.12-wolfi, got %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("multiplatform_python_node_wolfi", func(t *testing.T) {
|
|
cfg := mustValidate(t, map[string]any{
|
|
"python_version": "3.11",
|
|
"node_version": "20",
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"python": "./agent.py:graph", "js": "./agent.js:graph"},
|
|
"image_distro": "wolfi",
|
|
})
|
|
got := DockerTag(cfg, "", "")
|
|
// Should default to Python when both are present
|
|
if got != "langchain/langgraph-api:3.11-wolfi" {
|
|
t.Fatalf("expected langchain/langgraph-api:3.11-wolfi, got %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("python_versions_with_wolfi", func(t *testing.T) {
|
|
for _, version := range []string{"3.11", "3.12", "3.13"} {
|
|
cfg := mustValidate(t, map[string]any{
|
|
"python_version": version,
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
"image_distro": "wolfi",
|
|
})
|
|
expected := "langchain/langgraph-api:" + version + "-wolfi"
|
|
got := DockerTag(cfg, "", "")
|
|
if got != expected {
|
|
t.Fatalf("Python %s: expected %q, got %q", version, expected, got)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("node_versions_with_wolfi", func(t *testing.T) {
|
|
for _, version := range []string{"20", "21", "22"} {
|
|
cfg := mustValidate(t, map[string]any{
|
|
"node_version": version,
|
|
"graphs": map[string]any{"agent": "./agent.js:graph"},
|
|
"image_distro": "wolfi",
|
|
})
|
|
expected := "langchain/langgraphjs-api:" + version + "-wolfi"
|
|
got := DockerTag(cfg, "", "")
|
|
if got != expected {
|
|
t.Fatalf("Node %s: expected %q, got %q", version, expected, got)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("internal_docker_tag_overrides", func(t *testing.T) {
|
|
cfg := mustValidate(t, map[string]any{
|
|
"python_version": "3.11",
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
"_INTERNAL_docker_tag": "internal-tag",
|
|
})
|
|
got := DockerTag(cfg, "", "0.2.74")
|
|
if got != "langchain/langgraph-api:internal-tag" {
|
|
t.Fatalf("expected langchain/langgraph-api:internal-tag, got %q", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestDockerTagWithAPIVersion(t *testing.T) {
|
|
apiVersion := "0.2.74"
|
|
|
|
for _, inConfig := range []bool{false, true} {
|
|
label := "param"
|
|
if inConfig {
|
|
label = "in_config"
|
|
}
|
|
|
|
t.Run("python_default_distro_"+label, func(t *testing.T) {
|
|
raw := map[string]any{
|
|
"python_version": "3.11",
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
}
|
|
passedVersion := apiVersion
|
|
if inConfig {
|
|
raw["api_version"] = apiVersion
|
|
passedVersion = ""
|
|
}
|
|
cfg := mustValidate(t, raw)
|
|
got := DockerTag(cfg, "", passedVersion)
|
|
if got != "langchain/langgraph-api:0.2.74-py3.11" {
|
|
t.Fatalf("expected langchain/langgraph-api:0.2.74-py3.11, got %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("python_wolfi_distro_"+label, func(t *testing.T) {
|
|
raw := map[string]any{
|
|
"python_version": "3.12",
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
"image_distro": "wolfi",
|
|
}
|
|
passedVersion := apiVersion
|
|
if inConfig {
|
|
raw["api_version"] = apiVersion
|
|
passedVersion = ""
|
|
}
|
|
cfg := mustValidate(t, raw)
|
|
got := DockerTag(cfg, "", passedVersion)
|
|
if got != "langchain/langgraph-api:0.2.74-py3.12-wolfi" {
|
|
t.Fatalf("expected langchain/langgraph-api:0.2.74-py3.12-wolfi, got %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("node_default_distro_"+label, func(t *testing.T) {
|
|
raw := map[string]any{
|
|
"node_version": "20",
|
|
"graphs": map[string]any{"agent": "./agent.js:graph"},
|
|
}
|
|
passedVersion := apiVersion
|
|
if inConfig {
|
|
raw["api_version"] = apiVersion
|
|
passedVersion = ""
|
|
}
|
|
cfg := mustValidate(t, raw)
|
|
got := DockerTag(cfg, "", passedVersion)
|
|
if got != "langchain/langgraphjs-api:0.2.74-node20" {
|
|
t.Fatalf("expected langchain/langgraphjs-api:0.2.74-node20, got %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("node_wolfi_distro_"+label, func(t *testing.T) {
|
|
raw := map[string]any{
|
|
"node_version": "20",
|
|
"graphs": map[string]any{"agent": "./agent.js:graph"},
|
|
"image_distro": "wolfi",
|
|
}
|
|
passedVersion := apiVersion
|
|
if inConfig {
|
|
raw["api_version"] = apiVersion
|
|
passedVersion = ""
|
|
}
|
|
cfg := mustValidate(t, raw)
|
|
got := DockerTag(cfg, "", passedVersion)
|
|
if got != "langchain/langgraphjs-api:0.2.74-node20-wolfi" {
|
|
t.Fatalf("expected langchain/langgraphjs-api:0.2.74-node20-wolfi, got %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("custom_base_image_"+label, func(t *testing.T) {
|
|
raw := map[string]any{
|
|
"python_version": "3.11",
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
"base_image": "my-registry/custom-image",
|
|
}
|
|
passedVersion := apiVersion
|
|
if inConfig {
|
|
raw["api_version"] = apiVersion
|
|
passedVersion = ""
|
|
}
|
|
cfg := mustValidate(t, raw)
|
|
got := DockerTag(cfg, "my-registry/custom-image", passedVersion)
|
|
if got != "my-registry/custom-image:0.2.74-py3.11" {
|
|
t.Fatalf("expected my-registry/custom-image:0.2.74-py3.11, got %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("python_versions_"+label, func(t *testing.T) {
|
|
for _, pyVer := range []string{"3.11", "3.12", "3.13"} {
|
|
raw := map[string]any{
|
|
"python_version": pyVer,
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
}
|
|
passedVersion := apiVersion
|
|
if inConfig {
|
|
raw["api_version"] = apiVersion
|
|
passedVersion = ""
|
|
}
|
|
cfg := mustValidate(t, raw)
|
|
expected := "langchain/langgraph-api:" + apiVersion + "-py" + pyVer
|
|
got := DockerTag(cfg, "", passedVersion)
|
|
if got != expected {
|
|
t.Fatalf("Python %s: expected %q, got %q", pyVer, expected, got)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("multiplatform_"+label, func(t *testing.T) {
|
|
raw := map[string]any{
|
|
"python_version": "3.11",
|
|
"node_version": "20",
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"python": "./agent.py:graph", "js": "./agent.js:graph"},
|
|
}
|
|
passedVersion := apiVersion
|
|
if inConfig {
|
|
raw["api_version"] = apiVersion
|
|
passedVersion = ""
|
|
}
|
|
cfg := mustValidate(t, raw)
|
|
got := DockerTag(cfg, "", passedVersion)
|
|
if got != "langchain/langgraph-api:0.2.74-py3.11" {
|
|
t.Fatalf("expected langchain/langgraph-api:0.2.74-py3.11, got %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("langgraph_server_base_"+label, func(t *testing.T) {
|
|
raw := map[string]any{
|
|
"python_version": "3.11",
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
}
|
|
passedVersion := apiVersion
|
|
if inConfig {
|
|
raw["api_version"] = apiVersion
|
|
passedVersion = ""
|
|
}
|
|
cfg := mustValidate(t, raw)
|
|
got := DockerTag(cfg, "langchain/langgraph-server", passedVersion)
|
|
if got != "langchain/langgraph-server:0.2.74-py3.11" {
|
|
t.Fatalf("expected langchain/langgraph-server:0.2.74-py3.11, got %q", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("without_api_version", func(t *testing.T) {
|
|
cfg := mustValidate(t, map[string]any{
|
|
"python_version": "3.11",
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
})
|
|
got := DockerTag(cfg, "", "")
|
|
if got != "langchain/langgraph-api:3.11" {
|
|
t.Fatalf("expected langchain/langgraph-api:3.11, got %q", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ImageSupportsUV tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestImageSupportsUV(t *testing.T) {
|
|
t.Run("modern_image", func(t *testing.T) {
|
|
if !ImageSupportsUV("langchain/langgraph-api:0.2.47") {
|
|
t.Fatal("expected ImageSupportsUV to return true for 0.2.47")
|
|
}
|
|
})
|
|
|
|
t.Run("old_image", func(t *testing.T) {
|
|
if ImageSupportsUV("langchain/langgraph-api:0.2.46") {
|
|
t.Fatal("expected ImageSupportsUV to return false for 0.2.46")
|
|
}
|
|
})
|
|
|
|
t.Run("trial_image", func(t *testing.T) {
|
|
if ImageSupportsUV("langchain/langgraph-trial") {
|
|
t.Fatal("expected ImageSupportsUV to return false for trial image")
|
|
}
|
|
})
|
|
|
|
t.Run("no_version_tag", func(t *testing.T) {
|
|
if !ImageSupportsUV("langchain/langgraph-api") {
|
|
t.Fatal("expected ImageSupportsUV to return true for image without version")
|
|
}
|
|
})
|
|
|
|
t.Run("version_3.11", func(t *testing.T) {
|
|
if !ImageSupportsUV("langchain/langgraph-api:3.11") {
|
|
t.Fatal("expected ImageSupportsUV to return true for 3.11")
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GetBuildToolsToUninstall tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestGetBuildToolsToUninstall(t *testing.T) {
|
|
t.Run("nil_keep_pkg_tools", func(t *testing.T) {
|
|
cfg := map[string]any{}
|
|
tools, err := GetBuildToolsToUninstall(cfg)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(tools) != 3 {
|
|
t.Fatalf("expected 3 tools, got %d: %v", len(tools), tools)
|
|
}
|
|
})
|
|
|
|
t.Run("keep_pkg_tools_true", func(t *testing.T) {
|
|
cfg := map[string]any{"keep_pkg_tools": true}
|
|
tools, err := GetBuildToolsToUninstall(cfg)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if tools != nil {
|
|
t.Fatalf("expected nil, got %v", tools)
|
|
}
|
|
})
|
|
|
|
t.Run("keep_pkg_tools_false", func(t *testing.T) {
|
|
cfg := map[string]any{"keep_pkg_tools": false}
|
|
tools, err := GetBuildToolsToUninstall(cfg)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(tools) != 3 {
|
|
t.Fatalf("expected 3 tools, got %d", len(tools))
|
|
}
|
|
})
|
|
|
|
t.Run("keep_pkg_tools_list", func(t *testing.T) {
|
|
cfg := map[string]any{"keep_pkg_tools": []any{"pip", "setuptools"}}
|
|
tools, err := GetBuildToolsToUninstall(cfg)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(tools) != 1 || tools[0] != "wheel" {
|
|
t.Fatalf("expected [wheel], got %v", tools)
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BuildRuntimeEnvVars tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestBuildRuntimeEnvVars(t *testing.T) {
|
|
t.Run("graphs_only", func(t *testing.T) {
|
|
cfg := map[string]any{
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
}
|
|
vars := BuildRuntimeEnvVars(cfg)
|
|
found := false
|
|
for _, v := range vars {
|
|
if strings.Contains(v, "LANGSERVE_GRAPHS=") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("expected LANGSERVE_GRAPHS in env vars")
|
|
}
|
|
})
|
|
|
|
t.Run("with_webhooks", func(t *testing.T) {
|
|
cfg := map[string]any{
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
"webhooks": map[string]any{"env_prefix": "LG_"},
|
|
}
|
|
vars := BuildRuntimeEnvVars(cfg)
|
|
found := false
|
|
for _, v := range vars {
|
|
if strings.Contains(v, "LANGGRAPH_WEBHOOKS=") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("expected LANGGRAPH_WEBHOOKS in env vars")
|
|
}
|
|
})
|
|
|
|
t.Run("without_webhooks", func(t *testing.T) {
|
|
cfg := map[string]any{
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
}
|
|
vars := BuildRuntimeEnvVars(cfg)
|
|
for _, v := range vars {
|
|
if strings.Contains(v, "LANGGRAPH_WEBHOOKS=") {
|
|
t.Fatal("did not expect LANGGRAPH_WEBHOOKS in env vars")
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("with_encryption", func(t *testing.T) {
|
|
cfg := map[string]any{
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
"encryption": map[string]any{"path": "./enc.py:enc"},
|
|
}
|
|
vars := BuildRuntimeEnvVars(cfg)
|
|
found := false
|
|
for _, v := range vars {
|
|
if strings.Contains(v, "LANGGRAPH_ENCRYPTION=") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("expected LANGGRAPH_ENCRYPTION in env vars")
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GetNodePMInstallCmd tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestGetNodePMInstallCmd(t *testing.T) {
|
|
t.Run("npm_default", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
got := GetNodePMInstallCmd(dir)
|
|
if got != "npm i" {
|
|
t.Fatalf("expected 'npm i', got %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("yarn_lock", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, filepath.Join(dir, "yarn.lock"), "")
|
|
got := GetNodePMInstallCmd(dir)
|
|
if got != "yarn install --frozen-lockfile" {
|
|
t.Fatalf("expected 'yarn install --frozen-lockfile', got %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("pnpm_lock", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, filepath.Join(dir, "pnpm-lock.yaml"), "")
|
|
got := GetNodePMInstallCmd(dir)
|
|
if got != "pnpm i --frozen-lockfile" {
|
|
t.Fatalf("expected 'pnpm i --frozen-lockfile', got %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("package_lock_json", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, filepath.Join(dir, "package-lock.json"), "{}")
|
|
got := GetNodePMInstallCmd(dir)
|
|
if got != "npm ci" {
|
|
t.Fatalf("expected 'npm ci', got %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("bun_lock", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, filepath.Join(dir, "bun.lockb"), "")
|
|
got := GetNodePMInstallCmd(dir)
|
|
if got != "bun i" {
|
|
t.Fatalf("expected 'bun i', got %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("packageManager_pnpm", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, filepath.Join(dir, "package.json"), `{"packageManager":"pnpm@9.0.0"}`)
|
|
got := GetNodePMInstallCmd(dir)
|
|
if got != "pnpm i" {
|
|
t.Fatalf("expected 'pnpm i', got %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("packageManager_yarn", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, filepath.Join(dir, "package.json"), `{"packageManager":"yarn@4.0.0"}`)
|
|
got := GetNodePMInstallCmd(dir)
|
|
if got != "yarn install" {
|
|
t.Fatalf("expected 'yarn install', got %q", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GetPipCleanupLines tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestGetPipCleanupLines(t *testing.T) {
|
|
t.Run("uv_with_all_tools", func(t *testing.T) {
|
|
result := GetPipCleanupLines("uv pip install --system", []string{"pip", "setuptools", "wheel"}, "uv")
|
|
assertContains(t, result, "RUN pip uninstall -y pip setuptools wheel")
|
|
assertContains(t, result, "rm /usr/bin/uv /usr/bin/uvx")
|
|
assertContains(t, result, "/usr/local/lib/python*/site-packages/pip*")
|
|
})
|
|
|
|
t.Run("pip_with_all_tools", func(t *testing.T) {
|
|
result := GetPipCleanupLines("pip install", []string{"pip", "setuptools", "wheel"}, "pip")
|
|
assertContains(t, result, "RUN pip uninstall -y pip setuptools wheel")
|
|
assertNotContains(t, result, "rm /usr/bin/uv")
|
|
})
|
|
|
|
t.Run("no_tools_to_uninstall_uv", func(t *testing.T) {
|
|
result := GetPipCleanupLines("uv pip install --system", nil, "uv")
|
|
assertNotContains(t, result, "RUN pip uninstall")
|
|
assertContains(t, result, "rm /usr/bin/uv /usr/bin/uvx")
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ConfigToDocker tests -- Python
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestConfigToDockerSimple(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := setupSimplePythonProject(t, dir)
|
|
|
|
cfg := mustValidate(t, map[string]any{
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
})
|
|
|
|
dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
|
BaseImage: "langchain/langgraph-api",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToDocker failed: %v", err)
|
|
}
|
|
|
|
assertContains(t, dockerfile, "FROM langchain/langgraph-api:3.11")
|
|
assertContains(t, dockerfile, "LANGSERVE_GRAPHS=")
|
|
assertContains(t, dockerfile, "uv pip install --system")
|
|
// Should contain working directory reference
|
|
baseName := filepath.Base(dir)
|
|
assertContains(t, dockerfile, "/deps/outer-"+baseName)
|
|
}
|
|
|
|
func TestConfigToDockerPipConfig(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := setupSimplePythonProject(t, dir)
|
|
writeFile(t, filepath.Join(dir, "pipconfig.txt"), "[global]\nindex-url = https://pypi.org/simple\n")
|
|
|
|
cfg := mustValidate(t, map[string]any{
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
"pip_config_file": "pipconfig.txt",
|
|
})
|
|
|
|
dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
|
BaseImage: "langchain/langgraph-api",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToDocker failed: %v", err)
|
|
}
|
|
|
|
assertContains(t, dockerfile, "FROM langchain/langgraph-api:3.11")
|
|
assertContains(t, dockerfile, "ADD pipconfig.txt /pipconfig.txt")
|
|
assertContains(t, dockerfile, "PIP_CONFIG_FILE=/pipconfig.txt")
|
|
}
|
|
|
|
func TestConfigToDockerLocalDeps(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// Create graphs directory with a Python file (src-layout faux package)
|
|
writeFile(t, filepath.Join(dir, "graphs", "subpkg", "agent.py"), "graph = None\n")
|
|
configPath := filepath.Join(dir, "langgraph.json")
|
|
writeFile(t, configPath, "{}\n")
|
|
|
|
cfg := mustValidate(t, map[string]any{
|
|
"dependencies": []any{"./graphs"},
|
|
"graphs": map[string]any{"agent": "./graphs/subpkg/agent.py:graph"},
|
|
})
|
|
|
|
dockerfile, contexts, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
|
BaseImage: "langchain/langgraph-api-custom",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToDocker failed: %v", err)
|
|
}
|
|
|
|
assertContains(t, dockerfile, "FROM langchain/langgraph-api-custom:3.11")
|
|
assertContains(t, dockerfile, "Adding non-package dependency graphs")
|
|
assertContains(t, dockerfile, "/deps/outer-graphs")
|
|
// No additional contexts needed for child directories
|
|
if len(contexts) != 0 {
|
|
t.Fatalf("expected 0 additional contexts, got %d: %v", len(contexts), contexts)
|
|
}
|
|
}
|
|
|
|
func TestConfigToDockerPyproject(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, filepath.Join(dir, "pyproject.toml"), `[project]
|
|
name = "custom"
|
|
version = "0.1"
|
|
dependencies = ["langchain"]`)
|
|
writeFile(t, filepath.Join(dir, "graphs", "agent.py"), "graph = None\n")
|
|
configPath := filepath.Join(dir, "langgraph.json")
|
|
writeFile(t, configPath, "{}\n")
|
|
|
|
cfg := mustValidate(t, map[string]any{
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./graphs/agent.py:graph"},
|
|
})
|
|
|
|
dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
|
BaseImage: "langchain/langgraph-api",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToDocker failed: %v", err)
|
|
}
|
|
|
|
baseName := filepath.Base(dir)
|
|
assertContains(t, dockerfile, "FROM langchain/langgraph-api:3.11")
|
|
assertContains(t, dockerfile, "Adding local package .")
|
|
assertContains(t, dockerfile, "ADD . /deps/"+baseName)
|
|
assertContains(t, dockerfile, "WORKDIR /deps/"+baseName)
|
|
}
|
|
|
|
func TestConfigToDockerNodeJS(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := setupNodeProject(t, dir)
|
|
|
|
cfg := mustValidate(t, map[string]any{
|
|
"node_version": "20",
|
|
"graphs": map[string]any{"agent": "./graphs/agent.js:graph"},
|
|
"dockerfile_lines": []any{"ARG meow", "ARG foo"},
|
|
"auth": map[string]any{"path": "./graphs/auth.mts:auth"},
|
|
"ui": map[string]any{"agent": "./graphs/agent.ui.jsx"},
|
|
"ui_config": map[string]any{"shared": []any{"nuqs"}},
|
|
})
|
|
|
|
dockerfile, contexts, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
|
BaseImage: "langchain/langgraphjs-api",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToDocker failed: %v", err)
|
|
}
|
|
|
|
assertContains(t, dockerfile, "FROM langchain/langgraphjs-api:20")
|
|
assertContains(t, dockerfile, "ARG meow")
|
|
assertContains(t, dockerfile, "ARG foo")
|
|
assertContains(t, dockerfile, "RUN npm i")
|
|
assertContains(t, dockerfile, "LANGGRAPH_AUTH=")
|
|
assertContains(t, dockerfile, "LANGGRAPH_UI=")
|
|
assertContains(t, dockerfile, "LANGGRAPH_UI_CONFIG=")
|
|
assertContains(t, dockerfile, "LANGSERVE_GRAPHS=")
|
|
assertContains(t, dockerfile, "tsx /api/langgraph_api/js/build.mts")
|
|
|
|
if len(contexts) != 0 {
|
|
t.Fatalf("expected 0 additional contexts, got %d", len(contexts))
|
|
}
|
|
}
|
|
|
|
func TestConfigToDockerNodeJSInternalTag(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := setupNodeProject(t, dir)
|
|
|
|
cfg := mustValidate(t, map[string]any{
|
|
"node_version": "20",
|
|
"graphs": map[string]any{"agent": "./graphs/agent.js:graph"},
|
|
"_INTERNAL_docker_tag": "my-tag",
|
|
})
|
|
|
|
dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
|
BaseImage: "langchain/langgraphjs-api",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToDocker failed: %v", err)
|
|
}
|
|
|
|
assertContains(t, dockerfile, "FROM langchain/langgraphjs-api:my-tag")
|
|
}
|
|
|
|
func TestConfigToDockerMultiplatform(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, filepath.Join(dir, "multiplatform", "python.py"), "graph = None\n")
|
|
writeFile(t, filepath.Join(dir, "multiplatform", "js.mts"), "export const graph = {};\n")
|
|
configPath := filepath.Join(dir, "langgraph.json")
|
|
writeFile(t, configPath, "{}\n")
|
|
|
|
cfg := mustValidate(t, map[string]any{
|
|
"node_version": "22",
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{
|
|
"python": "./multiplatform/python.py:graph",
|
|
"js": "./multiplatform/js.mts:graph",
|
|
},
|
|
})
|
|
|
|
dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
|
BaseImage: "langchain/langgraph-api",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToDocker failed: %v", err)
|
|
}
|
|
|
|
// Multiplatform with both Python and Node -> Python base image
|
|
assertContains(t, dockerfile, "FROM langchain/langgraph-api:3.11")
|
|
// Should install node for JS
|
|
assertContains(t, dockerfile, "RUN /storage/install-node.sh")
|
|
assertContains(t, dockerfile, "ENV NODE_VERSION=22")
|
|
assertContains(t, dockerfile, "npm i && tsx /api/langgraph_api/js/build.mts")
|
|
assertContains(t, dockerfile, "LANGSERVE_GRAPHS=")
|
|
}
|
|
|
|
func TestConfigToDockerPipInstaller(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, filepath.Join(dir, "graphs", "agent.py"), "graph = None\n")
|
|
configPath := filepath.Join(dir, "langgraph.json")
|
|
writeFile(t, configPath, "{}\n")
|
|
|
|
baseCfg := func() map[string]any {
|
|
return map[string]any{
|
|
"python_version": "3.11",
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./graphs/agent.py:graph"},
|
|
}
|
|
}
|
|
|
|
t.Run("auto_with_uv_supporting_image", func(t *testing.T) {
|
|
raw := baseCfg()
|
|
raw["pip_installer"] = "auto"
|
|
cfg := mustValidate(t, raw)
|
|
dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
|
BaseImage: "langchain/langgraph-api:0.2.47",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToDocker failed: %v", err)
|
|
}
|
|
assertContains(t, dockerfile, "uv pip install --system")
|
|
assertContains(t, dockerfile, "rm /usr/bin/uv /usr/bin/uvx")
|
|
})
|
|
|
|
t.Run("explicit_pip", func(t *testing.T) {
|
|
raw := baseCfg()
|
|
raw["pip_installer"] = "pip"
|
|
cfg := mustValidate(t, raw)
|
|
dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
|
BaseImage: "langchain/langgraph-api:0.2.47",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToDocker failed: %v", err)
|
|
}
|
|
assertNotContains(t, dockerfile, "uv pip install --system")
|
|
assertContains(t, dockerfile, "pip install")
|
|
assertNotContains(t, dockerfile, "rm /usr/bin/uv")
|
|
})
|
|
|
|
t.Run("explicit_uv", func(t *testing.T) {
|
|
raw := baseCfg()
|
|
raw["pip_installer"] = "uv"
|
|
cfg := mustValidate(t, raw)
|
|
dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
|
BaseImage: "langchain/langgraph-api:0.2.47",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToDocker failed: %v", err)
|
|
}
|
|
assertContains(t, dockerfile, "uv pip install --system")
|
|
assertContains(t, dockerfile, "rm /usr/bin/uv /usr/bin/uvx")
|
|
})
|
|
|
|
t.Run("auto_with_old_image_uses_pip", func(t *testing.T) {
|
|
raw := baseCfg()
|
|
raw["pip_installer"] = "auto"
|
|
cfg := mustValidate(t, raw)
|
|
dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
|
BaseImage: "langchain/langgraph-api:0.2.46",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToDocker failed: %v", err)
|
|
}
|
|
assertNotContains(t, dockerfile, "uv pip install --system")
|
|
assertContains(t, dockerfile, "pip install")
|
|
assertNotContains(t, dockerfile, "rm /usr/bin/uv")
|
|
})
|
|
|
|
t.Run("default_auto_with_uv_image", func(t *testing.T) {
|
|
cfg := mustValidate(t, baseCfg())
|
|
dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
|
BaseImage: "langchain/langgraph-api:0.2.47",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToDocker failed: %v", err)
|
|
}
|
|
assertContains(t, dockerfile, "uv pip install --system")
|
|
})
|
|
}
|
|
|
|
func TestConfigToDockerWebhooksPython(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := setupSimplePythonProject(t, dir)
|
|
|
|
webhooks := map[string]any{
|
|
"env_prefix": "LG_WEBHOOK_",
|
|
"url": map[string]any{
|
|
"require_https": true,
|
|
"allowed_domains": []any{"hooks.example.com", "*.example.org"},
|
|
"allowed_ports": []any{float64(443)},
|
|
"max_url_length": float64(1024),
|
|
"disable_loopback": false,
|
|
},
|
|
"headers": map[string]any{
|
|
"x-auth": "${{ env.LG_WEBHOOK_TOKEN }}",
|
|
"x-mixed": "Bearer ${{ env.LG_WEBHOOK_TOKEN }}-suffix",
|
|
},
|
|
}
|
|
|
|
cfg := mustValidate(t, map[string]any{
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
"webhooks": webhooks,
|
|
})
|
|
|
|
dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
|
BaseImage: "langchain/langgraph-api",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToDocker failed: %v", err)
|
|
}
|
|
|
|
parsed := extractEnvJSON(t, dockerfile, "LANGGRAPH_WEBHOOKS")
|
|
if parsed["env_prefix"] != "LG_WEBHOOK_" {
|
|
t.Fatalf("expected env_prefix LG_WEBHOOK_, got %v", parsed["env_prefix"])
|
|
}
|
|
}
|
|
|
|
func TestConfigToDockerNoWebhooks(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := setupSimplePythonProject(t, dir)
|
|
|
|
cfg := mustValidate(t, map[string]any{
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
})
|
|
|
|
dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
|
BaseImage: "langchain/langgraph-api",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToDocker failed: %v", err)
|
|
}
|
|
|
|
assertNotContains(t, dockerfile, "ENV LANGGRAPH_WEBHOOKS=")
|
|
}
|
|
|
|
func TestConfigToDockerEncryption(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, filepath.Join(dir, "agent.py"), "graph = None\n")
|
|
writeFile(t, filepath.Join(dir, "encryption.py"), "encryption = None\n")
|
|
configPath := filepath.Join(dir, "langgraph.json")
|
|
writeFile(t, configPath, "{}\n")
|
|
|
|
// Test encryption config is preserved after validation
|
|
validated := mustValidate(t, map[string]any{
|
|
"python_version": "3.11",
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
"dependencies": []any{"."},
|
|
"encryption": map[string]any{"path": "./encryption.py:encryption"},
|
|
})
|
|
|
|
enc, ok := validated["encryption"].(map[string]any)
|
|
if !ok || enc == nil {
|
|
t.Fatal("encryption config should be preserved after validation")
|
|
}
|
|
if enc["path"] != "./encryption.py:encryption" {
|
|
t.Fatalf("expected encryption path ./encryption.py:encryption, got %v", enc["path"])
|
|
}
|
|
}
|
|
|
|
func TestConfigToDockerEncryptionFormatted(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, filepath.Join(dir, "agent.py"), "my_encryption = None\n")
|
|
writeFile(t, filepath.Join(dir, "graphs", "agent.py"), "graph = None\n")
|
|
configPath := filepath.Join(dir, "langgraph.json")
|
|
writeFile(t, configPath, "{}\n")
|
|
|
|
cfg := mustValidate(t, map[string]any{
|
|
"python_version": "3.11",
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./graphs/agent.py:graph"},
|
|
"encryption": map[string]any{"path": "./agent.py:my_encryption"},
|
|
})
|
|
|
|
dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
|
BaseImage: "langchain/langgraph-api",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToDocker failed: %v", err)
|
|
}
|
|
|
|
assertContains(t, dockerfile, "LANGGRAPH_ENCRYPTION=")
|
|
assertContains(t, dockerfile, "agent.py:my_encryption")
|
|
}
|
|
|
|
func TestConfigToDockerWithAPIVersion(t *testing.T) {
|
|
t.Run("python", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := setupSimplePythonProject(t, dir)
|
|
|
|
cfg := mustValidate(t, map[string]any{
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
})
|
|
|
|
dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
|
BaseImage: "langchain/langgraph-api",
|
|
APIVersion: "0.2.74",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToDocker failed: %v", err)
|
|
}
|
|
|
|
lines := strings.Split(dockerfile, "\n")
|
|
if !strings.Contains(lines[0], "FROM langchain/langgraph-api:0.2.74-py3.11") {
|
|
t.Fatalf("expected FROM line with api version, got: %s", lines[0])
|
|
}
|
|
})
|
|
|
|
t.Run("nodejs", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := setupNodeProject(t, dir)
|
|
|
|
cfg := mustValidate(t, map[string]any{
|
|
"node_version": "20",
|
|
"graphs": map[string]any{"agent": "./graphs/agent.js:graph"},
|
|
})
|
|
|
|
dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
|
BaseImage: "langchain/langgraphjs-api",
|
|
APIVersion: "0.2.74",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToDocker failed: %v", err)
|
|
}
|
|
|
|
lines := strings.Split(dockerfile, "\n")
|
|
fromLine := ""
|
|
for _, l := range lines {
|
|
if strings.HasPrefix(strings.TrimSpace(l), "FROM ") {
|
|
fromLine = strings.TrimSpace(l)
|
|
break
|
|
}
|
|
}
|
|
if fromLine != "FROM langchain/langgraphjs-api:0.2.74-node20" {
|
|
t.Fatalf("expected FROM langchain/langgraphjs-api:0.2.74-node20, got %q", fromLine)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestConfigToDockerExecutorBaseImage(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := setupSimplePythonProject(t, dir)
|
|
|
|
cfg := mustValidate(t, map[string]any{
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
})
|
|
|
|
dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
|
BaseImage: "langchain/langgraph-executor",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToDocker failed: %v", err)
|
|
}
|
|
|
|
assertContains(t, dockerfile, "FROM langchain/langgraph-executor:3.11")
|
|
assertContains(t, dockerfile, "LANGSERVE_GRAPHS=")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ConfigToDocker -- retain/remove build tools
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestConfigRetainBuildTools(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, filepath.Join(dir, "graphs", "agent.py"), "graph = None\n")
|
|
configPath := filepath.Join(dir, "langgraph.json")
|
|
writeFile(t, configPath, "{}\n")
|
|
|
|
baseCfg := func() map[string]any {
|
|
return map[string]any{
|
|
"python_version": "3.11",
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./graphs/agent.py:graph"},
|
|
}
|
|
}
|
|
|
|
t.Run("keep_pkg_tools_true", func(t *testing.T) {
|
|
raw := baseCfg()
|
|
raw["keep_pkg_tools"] = true
|
|
cfg := mustValidate(t, raw)
|
|
dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
|
BaseImage: "langchain/langgraph-api:0.2.47",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToDocker failed: %v", err)
|
|
}
|
|
|
|
for _, pkg := range []string{"pip", "setuptools", "wheel"} {
|
|
assertNotContains(t, dockerfile, "/usr/local/lib/python*/site-packages/"+pkg+"*")
|
|
}
|
|
assertNotContains(t, dockerfile, "RUN pip uninstall -y pip setuptools wheel")
|
|
})
|
|
|
|
t.Run("keep_pkg_tools_false", func(t *testing.T) {
|
|
raw := baseCfg()
|
|
raw["keep_pkg_tools"] = false
|
|
cfg := mustValidate(t, raw)
|
|
dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
|
BaseImage: "langchain/langgraph-api:0.2.47",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToDocker failed: %v", err)
|
|
}
|
|
|
|
for _, pkg := range []string{"pip", "setuptools", "wheel"} {
|
|
assertContains(t, dockerfile, "/usr/local/lib/python*/site-packages/"+pkg+"*")
|
|
}
|
|
assertContains(t, dockerfile, "RUN pip uninstall -y pip setuptools wheel")
|
|
})
|
|
|
|
t.Run("keep_pkg_tools_list", func(t *testing.T) {
|
|
raw := baseCfg()
|
|
raw["keep_pkg_tools"] = []any{"pip", "setuptools"}
|
|
cfg := mustValidate(t, raw)
|
|
dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
|
BaseImage: "langchain/langgraph-api:0.2.47",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToDocker failed: %v", err)
|
|
}
|
|
|
|
assertContains(t, dockerfile, "/usr/local/lib/python*/site-packages/wheel*")
|
|
assertNotContains(t, dockerfile, "/usr/local/lib/python*/site-packages/pip*")
|
|
assertNotContains(t, dockerfile, "/usr/local/lib/python*/site-packages/setuptools*")
|
|
assertContains(t, dockerfile, "RUN pip uninstall -y wheel")
|
|
assertNotContains(t, dockerfile, "RUN pip uninstall -y pip setuptools")
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ConfigToDocker -- PyPI dependencies
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestConfigToDockerPyPIDeps(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, filepath.Join(dir, "graphs", "agent.py"), "graph = None\n")
|
|
configPath := filepath.Join(dir, "langgraph.json")
|
|
writeFile(t, configPath, "{}\n")
|
|
|
|
cfg := mustValidate(t, map[string]any{
|
|
"python_version": "3.12",
|
|
"dependencies": []any{"./graphs/", "langchain", "langchain_openai"},
|
|
"graphs": map[string]any{"agent": "./graphs/agent.py:graph"},
|
|
})
|
|
|
|
dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
|
BaseImage: "langchain/langgraph-api",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToDocker failed: %v", err)
|
|
}
|
|
|
|
assertContains(t, dockerfile, "FROM langchain/langgraph-api:3.12")
|
|
assertContains(t, dockerfile, "langchain langchain_openai")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ConfigToDocker -- dockerfile_lines
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestConfigToDockerDockerfileLines(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, filepath.Join(dir, "graphs", "agent.py"), "graph = None\n")
|
|
configPath := filepath.Join(dir, "langgraph.json")
|
|
writeFile(t, configPath, "{}\n")
|
|
|
|
cfg := mustValidate(t, map[string]any{
|
|
"python_version": "3.12",
|
|
"dependencies": []any{"./graphs/"},
|
|
"graphs": map[string]any{"agent": "./graphs/agent.py:graph"},
|
|
"dockerfile_lines": []any{"ARG meow", "ARG foo"},
|
|
})
|
|
|
|
dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
|
BaseImage: "langchain/langgraph-api",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToDocker failed: %v", err)
|
|
}
|
|
|
|
assertContains(t, dockerfile, "ARG meow")
|
|
assertContains(t, dockerfile, "ARG foo")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ConfigToDocker -- gen UI with Python (installs node)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestConfigToDockerGenUIPython(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, filepath.Join(dir, "agent.py"), "graph = None\n")
|
|
writeFile(t, filepath.Join(dir, "graphs", "agent.ui.jsx"), "export default null;\n")
|
|
configPath := filepath.Join(dir, "langgraph.json")
|
|
writeFile(t, configPath, "{}\n")
|
|
|
|
cfg := mustValidate(t, map[string]any{
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
"ui": map[string]any{"agent": "./graphs/agent.ui.jsx"},
|
|
"ui_config": map[string]any{"shared": []any{"nuqs"}},
|
|
})
|
|
|
|
dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
|
BaseImage: "langchain/langgraph-api",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToDocker failed: %v", err)
|
|
}
|
|
|
|
assertContains(t, dockerfile, "FROM langchain/langgraph-api:3.11")
|
|
assertContains(t, dockerfile, "RUN /storage/install-node.sh")
|
|
assertContains(t, dockerfile, "LANGGRAPH_UI=")
|
|
assertContains(t, dockerfile, "LANGGRAPH_UI_CONFIG=")
|
|
assertContains(t, dockerfile, "npm i && tsx /api/langgraph_api/js/build.mts")
|
|
assertContains(t, dockerfile, "ENV NODE_VERSION=20")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ConfigToCompose tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestConfigToComposeSimple(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := setupSimplePythonProject(t, dir)
|
|
|
|
cfg := mustValidate(t, map[string]any{
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
})
|
|
|
|
compose, err := ConfigToCompose(configPath, cfg, ComposeOpts{
|
|
BaseImage: "langchain/langgraph-api",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToCompose failed: %v", err)
|
|
}
|
|
|
|
assertContains(t, compose, "pull_policy: build")
|
|
assertContains(t, compose, "dockerfile_inline:")
|
|
assertContains(t, compose, "FROM langchain/langgraph-api:3.11")
|
|
assertContains(t, compose, "LANGSERVE_GRAPHS=")
|
|
assertContains(t, compose, "context: .")
|
|
// Should use escaped variable names for compose
|
|
assertContains(t, compose, "$$dep")
|
|
}
|
|
|
|
func TestConfigToComposeEnvVars(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := setupSimplePythonProject(t, dir)
|
|
|
|
cfg := mustValidate(t, map[string]any{
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
"env": map[string]any{"OPENAI_API_KEY": "key"},
|
|
})
|
|
|
|
compose, err := ConfigToCompose(configPath, cfg, ComposeOpts{
|
|
BaseImage: "langchain/langgraph-api-custom",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToCompose failed: %v", err)
|
|
}
|
|
|
|
assertContains(t, compose, `OPENAI_API_KEY: "key"`)
|
|
assertContains(t, compose, "FROM langchain/langgraph-api-custom:3.11")
|
|
}
|
|
|
|
func TestConfigToComposeEnvFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := setupSimplePythonProject(t, dir)
|
|
|
|
cfg := mustValidate(t, map[string]any{
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
"env": ".env",
|
|
})
|
|
|
|
compose, err := ConfigToCompose(configPath, cfg, ComposeOpts{
|
|
BaseImage: "langchain/langgraph-api",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToCompose failed: %v", err)
|
|
}
|
|
|
|
assertContains(t, compose, "env_file: .env")
|
|
}
|
|
|
|
func TestConfigToComposeWithAPIVersion(t *testing.T) {
|
|
t.Run("python", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := setupSimplePythonProject(t, dir)
|
|
|
|
cfg := mustValidate(t, map[string]any{
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
})
|
|
|
|
compose, err := ConfigToCompose(configPath, cfg, ComposeOpts{
|
|
BaseImage: "langchain/langgraph-api",
|
|
APIVersion: "0.2.74",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToCompose failed: %v", err)
|
|
}
|
|
|
|
assertContains(t, compose, "FROM langchain/langgraph-api:0.2.74-py3.11")
|
|
})
|
|
|
|
t.Run("nodejs", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := setupNodeProject(t, dir)
|
|
|
|
cfg := mustValidate(t, map[string]any{
|
|
"node_version": "20",
|
|
"graphs": map[string]any{"agent": "./graphs/agent.js:graph"},
|
|
})
|
|
|
|
compose, err := ConfigToCompose(configPath, cfg, ComposeOpts{
|
|
BaseImage: "langchain/langgraphjs-api",
|
|
APIVersion: "0.2.74",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToCompose failed: %v", err)
|
|
}
|
|
|
|
assertContains(t, compose, "FROM langchain/langgraphjs-api:0.2.74-node20")
|
|
})
|
|
}
|
|
|
|
func TestConfigToComposeDistributedMode(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := setupSimplePythonProject(t, dir)
|
|
|
|
cfg := mustValidate(t, map[string]any{
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
})
|
|
|
|
compose, err := ConfigToCompose(configPath, cfg, ComposeOpts{
|
|
BaseImage: "langchain/langgraph-api",
|
|
EngineRuntimeMode: "distributed",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToCompose failed: %v", err)
|
|
}
|
|
|
|
assertContains(t, compose, "FROM langchain/langgraph-api:3.11")
|
|
assertContains(t, compose, "langgraph-orchestrator:")
|
|
assertContains(t, compose, "EXECUTOR_TARGET: langgraph-executor:8188")
|
|
assertContains(t, compose, "langgraph-executor:")
|
|
assertContains(t, compose, "FROM langchain/langgraph-executor:3.11")
|
|
assertContains(t, compose, `entrypoint: ["sh", "/storage/executor_entrypoint.sh"]`)
|
|
assertContains(t, compose, "EXECUTOR_GRPC_PORT:")
|
|
assertContains(t, compose, "ENGINE_GRPC_ADDRESS:")
|
|
assertContains(t, compose, "LSD_GRPC_SERVER_ADDRESS:")
|
|
assertContains(t, compose, `LANGGRAPH_HTTP: ""`)
|
|
assertContains(t, compose, "REDIS_URI: redis://langgraph-redis:6379")
|
|
}
|
|
|
|
func TestConfigToComposeDistributedModeWithEnvFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := setupSimplePythonProject(t, dir)
|
|
|
|
cfg := mustValidate(t, map[string]any{
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
"env": ".env",
|
|
})
|
|
|
|
compose, err := ConfigToCompose(configPath, cfg, ComposeOpts{
|
|
BaseImage: "langchain/langgraph-api",
|
|
EngineRuntimeMode: "distributed",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToCompose failed: %v", err)
|
|
}
|
|
|
|
count := strings.Count(compose, "env_file: .env")
|
|
if count != 3 {
|
|
t.Fatalf("expected env_file to appear 3 times (api, orchestrator, executor), got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestConfigToComposeDistributedTwoDockerfiles(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := setupSimplePythonProject(t, dir)
|
|
|
|
cfg := mustValidate(t, map[string]any{
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
})
|
|
|
|
compose, err := ConfigToCompose(configPath, cfg, ComposeOpts{
|
|
BaseImage: "langchain/langgraph-api",
|
|
EngineRuntimeMode: "distributed",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToCompose failed: %v", err)
|
|
}
|
|
|
|
var fromLines []string
|
|
for _, line := range strings.Split(compose, "\n") {
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.HasPrefix(trimmed, "FROM ") {
|
|
fromLines = append(fromLines, trimmed)
|
|
}
|
|
}
|
|
if len(fromLines) != 2 {
|
|
t.Fatalf("expected 2 FROM lines, got %d: %v", len(fromLines), fromLines)
|
|
}
|
|
assertContains(t, fromLines[0], "FROM langchain/langgraph-api:3.11")
|
|
assertContains(t, fromLines[1], "FROM langchain/langgraph-executor:3.11")
|
|
}
|
|
|
|
func TestConfigToComposeCombinedModeNoOrchestrator(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := setupSimplePythonProject(t, dir)
|
|
|
|
cfg := mustValidate(t, map[string]any{
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
})
|
|
|
|
compose, err := ConfigToCompose(configPath, cfg, ComposeOpts{
|
|
BaseImage: "langchain/langgraph-api",
|
|
EngineRuntimeMode: "combined_queue_worker",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToCompose failed: %v", err)
|
|
}
|
|
|
|
assertNotContains(t, compose, "langgraph-orchestrator:")
|
|
assertNotContains(t, compose, "langgraph-executor:")
|
|
}
|
|
|
|
func TestConfigToComposeDefaultModeNoOrchestrator(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := setupSimplePythonProject(t, dir)
|
|
|
|
cfg := mustValidate(t, map[string]any{
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
})
|
|
|
|
compose, err := ConfigToCompose(configPath, cfg, ComposeOpts{
|
|
BaseImage: "langchain/langgraph-api",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToCompose failed: %v", err)
|
|
}
|
|
|
|
assertNotContains(t, compose, "langgraph-orchestrator:")
|
|
assertNotContains(t, compose, "langgraph-executor:")
|
|
}
|
|
|
|
func TestConfigToComposeDistributedCorrectPaths(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := setupSimplePythonProject(t, dir)
|
|
|
|
cfg := mustValidate(t, map[string]any{
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
})
|
|
|
|
compose, err := ConfigToCompose(configPath, cfg, ComposeOpts{
|
|
BaseImage: "langchain/langgraph-api",
|
|
EngineRuntimeMode: "distributed",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToCompose failed: %v", err)
|
|
}
|
|
|
|
// Both API and executor should contain LANGSERVE_GRAPHS
|
|
count := 0
|
|
for _, line := range strings.Split(compose, "\n") {
|
|
if strings.Contains(strings.TrimSpace(line), "LANGSERVE_GRAPHS=") {
|
|
count++
|
|
}
|
|
}
|
|
if count != 2 {
|
|
t.Fatalf("expected 2 LANGSERVE_GRAPHS lines, got %d", count)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ConfigToCompose -- watch mode
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestConfigToComposeWatch(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := setupSimplePythonProject(t, dir)
|
|
|
|
cfg := mustValidate(t, map[string]any{
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
})
|
|
|
|
compose, err := ConfigToCompose(configPath, cfg, ComposeOpts{
|
|
BaseImage: "langchain/langgraph-api",
|
|
Watch: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToCompose failed: %v", err)
|
|
}
|
|
|
|
assertContains(t, compose, "develop:")
|
|
assertContains(t, compose, "watch:")
|
|
assertContains(t, compose, "action: rebuild")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// AssembleLocalDeps tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestAssembleLocalDeps(t *testing.T) {
|
|
t.Run("faux_package_flat_layout", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// Create a flat-layout faux package (directory with __init__.py)
|
|
writeFile(t, filepath.Join(dir, "mypkg", "__init__.py"), "")
|
|
writeFile(t, filepath.Join(dir, "mypkg", "graph.py"), "graph = None\n")
|
|
configPath := filepath.Join(dir, "langgraph.json")
|
|
writeFile(t, configPath, "{}\n")
|
|
|
|
cfg := map[string]any{
|
|
"dependencies": []any{"./mypkg"},
|
|
"graphs": map[string]any{"agent": "./mypkg/graph.py:graph"},
|
|
}
|
|
|
|
deps, err := AssembleLocalDeps(configPath, cfg)
|
|
if err != nil {
|
|
t.Fatalf("AssembleLocalDeps failed: %v", err)
|
|
}
|
|
|
|
if len(deps.FauxPkgs) != 1 {
|
|
t.Fatalf("expected 1 faux package, got %d", len(deps.FauxPkgs))
|
|
}
|
|
for _, faux := range deps.FauxPkgs {
|
|
assertContains(t, faux.ContainerPath, "/deps/outer-mypkg/mypkg")
|
|
}
|
|
})
|
|
|
|
t.Run("real_package_pyproject", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, filepath.Join(dir, "mypkg", "pyproject.toml"), `[project]
|
|
name = "test"
|
|
version = "0.1"`)
|
|
writeFile(t, filepath.Join(dir, "mypkg", "graph.py"), "graph = None\n")
|
|
configPath := filepath.Join(dir, "langgraph.json")
|
|
writeFile(t, configPath, "{}\n")
|
|
|
|
cfg := map[string]any{
|
|
"dependencies": []any{"./mypkg"},
|
|
"graphs": map[string]any{"agent": "./mypkg/graph.py:graph"},
|
|
}
|
|
|
|
deps, err := AssembleLocalDeps(configPath, cfg)
|
|
if err != nil {
|
|
t.Fatalf("AssembleLocalDeps failed: %v", err)
|
|
}
|
|
|
|
if len(deps.RealPkgs) != 1 {
|
|
t.Fatalf("expected 1 real package, got %d", len(deps.RealPkgs))
|
|
}
|
|
})
|
|
|
|
t.Run("real_package_setup_py", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, filepath.Join(dir, "mypkg", "setup.py"), "from setuptools import setup; setup(name='test')")
|
|
writeFile(t, filepath.Join(dir, "mypkg", "graph.py"), "graph = None\n")
|
|
configPath := filepath.Join(dir, "langgraph.json")
|
|
writeFile(t, configPath, "{}\n")
|
|
|
|
cfg := map[string]any{
|
|
"dependencies": []any{"./mypkg"},
|
|
"graphs": map[string]any{"agent": "./mypkg/graph.py:graph"},
|
|
}
|
|
|
|
deps, err := AssembleLocalDeps(configPath, cfg)
|
|
if err != nil {
|
|
t.Fatalf("AssembleLocalDeps failed: %v", err)
|
|
}
|
|
|
|
if len(deps.RealPkgs) != 1 {
|
|
t.Fatalf("expected 1 real package, got %d", len(deps.RealPkgs))
|
|
}
|
|
})
|
|
|
|
t.Run("dot_dependency_sets_workdir", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, filepath.Join(dir, "pyproject.toml"), `[project]
|
|
name = "myapp"
|
|
version = "0.1"`)
|
|
writeFile(t, filepath.Join(dir, "graph.py"), "graph = None\n")
|
|
configPath := filepath.Join(dir, "langgraph.json")
|
|
writeFile(t, configPath, "{}\n")
|
|
|
|
cfg := map[string]any{
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./graph.py:graph"},
|
|
}
|
|
|
|
deps, err := AssembleLocalDeps(configPath, cfg)
|
|
if err != nil {
|
|
t.Fatalf("AssembleLocalDeps failed: %v", err)
|
|
}
|
|
|
|
baseName := filepath.Base(dir)
|
|
expected := "/deps/" + baseName
|
|
if deps.WorkingDir != expected {
|
|
t.Fatalf("expected WorkingDir %q, got %q", expected, deps.WorkingDir)
|
|
}
|
|
})
|
|
|
|
t.Run("missing_dependency_dir", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := filepath.Join(dir, "langgraph.json")
|
|
writeFile(t, configPath, "{}\n")
|
|
|
|
cfg := map[string]any{
|
|
"dependencies": []any{"./missing"},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
}
|
|
|
|
_, err := AssembleLocalDeps(configPath, cfg)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing dependency, got nil")
|
|
}
|
|
assertContains(t, err.Error(), "Could not find local dependency")
|
|
})
|
|
|
|
t.Run("requirements_txt_detected", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, filepath.Join(dir, "mypkg", "requirements.txt"), "langchain>=0.1\n")
|
|
writeFile(t, filepath.Join(dir, "mypkg", "graph.py"), "graph = None\n")
|
|
configPath := filepath.Join(dir, "langgraph.json")
|
|
writeFile(t, configPath, "{}\n")
|
|
|
|
cfg := map[string]any{
|
|
"dependencies": []any{"./mypkg"},
|
|
"graphs": map[string]any{"agent": "./mypkg/graph.py:graph"},
|
|
}
|
|
|
|
deps, err := AssembleLocalDeps(configPath, cfg)
|
|
if err != nil {
|
|
t.Fatalf("AssembleLocalDeps failed: %v", err)
|
|
}
|
|
|
|
if len(deps.PipReqs) != 1 {
|
|
t.Fatalf("expected 1 pip req, got %d", len(deps.PipReqs))
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ConfigToDocker -- invalid inputs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestConfigToDockerInvalidInputs(t *testing.T) {
|
|
t.Run("missing_local_dependency", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := filepath.Join(dir, "langgraph.json")
|
|
writeFile(t, configPath, "{}\n")
|
|
|
|
cfg := mustValidate(t, map[string]any{
|
|
"dependencies": []any{"./missing"},
|
|
"graphs": map[string]any{"agent": "./agent.py:graph"},
|
|
})
|
|
|
|
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
|
BaseImage: "langchain/langgraph-api",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error for missing dependency")
|
|
}
|
|
})
|
|
|
|
t.Run("missing_local_module", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := setupSimplePythonProject(t, dir)
|
|
|
|
cfg := mustValidate(t, map[string]any{
|
|
"dependencies": []any{"."},
|
|
"graphs": map[string]any{"agent": "./missing_agent.py:graph"},
|
|
})
|
|
|
|
_, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
|
BaseImage: "langchain/langgraph-api",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error for missing module")
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ConfigToDocker -- requirements.txt with faux package
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestConfigToDockerWithRequirements(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, filepath.Join(dir, "mypkg", "requirements.txt"), "langchain>=0.1\n")
|
|
writeFile(t, filepath.Join(dir, "mypkg", "subpkg", "agent.py"), "graph = None\n")
|
|
configPath := filepath.Join(dir, "langgraph.json")
|
|
writeFile(t, configPath, "{}\n")
|
|
|
|
cfg := mustValidate(t, map[string]any{
|
|
"dependencies": []any{"./mypkg"},
|
|
"graphs": map[string]any{"agent": "./mypkg/subpkg/agent.py:graph"},
|
|
})
|
|
|
|
dockerfile, _, err := ConfigToDocker(configPath, cfg, DockerOpts{
|
|
BaseImage: "langchain/langgraph-api",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigToDocker failed: %v", err)
|
|
}
|
|
|
|
assertContains(t, dockerfile, "Installing local requirements")
|
|
assertContains(t, dockerfile, "requirements.txt")
|
|
}
|