Ctrl K
ComunidadGuías…

builder/build/cnb/cnb_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package cnb

import (
	"context"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/eapache/channels"
	"github.com/goodrain/rainbond/builder"
	"github.com/goodrain/rainbond/builder/build"
	"github.com/goodrain/rainbond/builder/parser/code"
	"github.com/goodrain/rainbond/event"
	corev1 "k8s.io/api/core/v1"
	"k8s.io/client-go/kubernetes"
)

// --- helpers ---

func newNodeDir(t *testing.T) string {
	t.Helper()
	dir := t.TempDir()
	os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name":"test"}`), 0644)
	return dir
}

// --- config.go ---

// capability_id: rainbond.cnb.builder-image
func TestGetCNBBuilderImage(t *testing.T) {
	t.Run("default", func(t *testing.T) {
		os.Unsetenv("CNB_BUILDER_IMAGE")
		if got := GetCNBBuilderImage(); got != DefaultCNBBuilder {
			t.Errorf("got %q; want %q", got, DefaultCNBBuilder)
		}
	})
	t.Run("custom", func(t *testing.T) {
		os.Setenv("CNB_BUILDER_IMAGE", "custom:latest")
		defer os.Unsetenv("CNB_BUILDER_IMAGE")
		if got := GetCNBBuilderImage(); got != "custom:latest" {
			t.Errorf("got %q; want %q", got, "custom:latest")
		}
	})
}

// capability_id: rainbond.cnb.run-image
func TestGetCNBRunImage(t *testing.T) {
	os.Unsetenv("CNB_RUN_IMAGE")
	if got := GetCNBRunImage(); got != DefaultCNBRunImage {
		t.Errorf("got %q; want %q", got, DefaultCNBRunImage)
	}
}

// --- order.go ---

// capability_id: rainbond.cnb.order-toml
func TestWriteCustomOrder(t *testing.T) {
	b := &Builder{}

	t.Run("writes order.toml with version", func(t *testing.T) {
		dir := t.TempDir()
		re := &build.Request{SourceDir: dir}
		bps := []orderBuildpack{{ID: "paketo-buildpacks/nginx", Version: "1.0.12"}}
		flag := b.writeCustomOrder(re, bps, "test")
		if flag != "-order=/workspace/.cnb-order.toml" {
			t.Errorf("got flag %q", flag)
		}
		content, _ := os.ReadFile(filepath.Join(dir, ".cnb-order.toml"))
		if !strings.Contains(string(content), `id = "paketo-buildpacks/nginx"`) {
			t.Error("expected nginx buildpack ID")
		}
		if !strings.Contains(string(content), `version = "1.0.12"`) {
			t.Error("expected version")
		}
	})

	t.Run("optional buildpack", func(t *testing.T) {
		dir := t.TempDir()
		re := &build.Request{SourceDir: dir}
		bps := []orderBuildpack{{ID: "bp/test", Optional: true}}
		b.writeCustomOrder(re, bps, "test")
		content, _ := os.ReadFile(filepath.Join(dir, ".cnb-order.toml"))
		if !strings.Contains(string(content), "optional = true") {
			t.Error("expected optional = true")
		}
	})

	t.Run("no version omits version line", func(t *testing.T) {
		dir := t.TempDir()
		re := &build.Request{SourceDir: dir}
		bps := []orderBuildpack{{ID: "bp/test"}}
		b.writeCustomOrder(re, bps, "test")
		content, _ := os.ReadFile(filepath.Join(dir, ".cnb-order.toml"))
		if strings.Contains(string(content), "version") {
			t.Error("expected no version line")
		}
	})
}

// capability_id: rainbond.cnb.static-buildpacks
func TestStaticBuildpacks(t *testing.T) {
	s := &staticConfig{}
	dir := t.TempDir() // no package.json = pure static
	re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{}}
	bps := s.CustomOrder(re)
	if len(bps) != 1 || bps[0].ID != "paketo-buildpacks/nginx" {
		t.Errorf("expected single nginx buildpack, got %+v", bps)
	}
}

// --- mirror.go ---

// capability_id: rainbond.cnb.mirror-config
func TestInjectMirrorConfig(t *testing.T) {
	n := &nodejsConfig{}

	t.Run("skip for pure static", func(t *testing.T) {
		dir := t.TempDir()
		re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{}}
		n.InjectMirrorConfig(re)
		if _, err := os.Stat(filepath.Join(dir, ".npmrc")); !os.IsNotExist(err) {
			t.Error("expected no .npmrc for pure static")
		}
	})

	t.Run("project source with existing config", func(t *testing.T) {
		dir := newNodeDir(t)
		os.WriteFile(filepath.Join(dir, ".npmrc"), []byte("custom"), 0644)
		re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{"CNB_MIRROR_SOURCE": "project"}}
		n.InjectMirrorConfig(re)
		content, _ := os.ReadFile(filepath.Join(dir, ".npmrc"))
		if string(content) != "custom" {
			t.Error("project .npmrc should be preserved")
		}
	})

	t.Run("project source without config falls through", func(t *testing.T) {
		dir := newNodeDir(t)
		re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{"CNB_MIRROR_SOURCE": "project"}}
		n.InjectMirrorConfig(re)
		if _, err := os.Stat(filepath.Join(dir, ".npmrc")); os.IsNotExist(err) {
			t.Error("expected .npmrc fallback")
		}
	})

	t.Run("creates both npmrc and yarnrc", func(t *testing.T) {
		dir := newNodeDir(t)
		re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{}}
		n.InjectMirrorConfig(re)
		for _, f := range []string{".npmrc", ".yarnrc"} {
			if _, err := os.Stat(filepath.Join(dir, f)); os.IsNotExist(err) {
				t.Errorf("expected %s to be created", f)
			}
		}
	})
}

// capability_id: rainbond.cnb.config-file
func TestInjectConfigFile(t *testing.T) {
	t.Run("existing file not overwritten", func(t *testing.T) {
		dir := t.TempDir()
		os.WriteFile(filepath.Join(dir, ".npmrc"), []byte("existing"), 0644)
		re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{}}
		injectConfigFile(re, ".npmrc", "CNB_MIRROR_NPMRC")
		content, _ := os.ReadFile(filepath.Join(dir, ".npmrc"))
		if string(content) != "existing" {
			t.Error("should not overwrite")
		}
	})

	t.Run("user provided content", func(t *testing.T) {
		dir := t.TempDir()
		re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{"CNB_MIRROR_NPMRC": "custom"}}
		injectConfigFile(re, ".npmrc", "CNB_MIRROR_NPMRC")
		content, _ := os.ReadFile(filepath.Join(dir, ".npmrc"))
		if string(content) != "custom" {
			t.Errorf("got %q; want custom", string(content))
		}
	})

	t.Run("china mirror disabled", func(t *testing.T) {
		dir := t.TempDir()
		os.Setenv("ENABLE_CHINA_MIRROR", "false")
		defer os.Unsetenv("ENABLE_CHINA_MIRROR")
		re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{}}
		injectConfigFile(re, ".npmrc", "CNB_MIRROR_NPMRC")
		if _, err := os.Stat(filepath.Join(dir, ".npmrc")); !os.IsNotExist(err) {
			t.Error("expected no .npmrc when mirror disabled")
		}
	})

	t.Run("default npmrc", func(t *testing.T) {
		dir := t.TempDir()
		os.Setenv("ENABLE_CHINA_MIRROR", "true")
		defer os.Unsetenv("ENABLE_CHINA_MIRROR")
		re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{}}
		injectConfigFile(re, ".npmrc", "CNB_MIRROR_NPMRC")
		content, _ := os.ReadFile(filepath.Join(dir, ".npmrc"))
		if string(content) != DefaultNpmrcContent {
			t.Errorf("got %q; want default", string(content))
		}
	})

	t.Run("default yarnrc", func(t *testing.T) {
		dir := t.TempDir()
		os.Setenv("ENABLE_CHINA_MIRROR", "true")
		defer os.Unsetenv("ENABLE_CHINA_MIRROR")
		re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{}}
		injectConfigFile(re, ".yarnrc", "CNB_MIRROR_YARNRC")
		content, _ := os.ReadFile(filepath.Join(dir, ".yarnrc"))
		if string(content) != DefaultYarnrcContent {
			t.Errorf("got %q; want default", string(content))
		}
	})

	t.Run("unknown file type", func(t *testing.T) {
		dir := t.TempDir()
		re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{}}
		if err := injectConfigFile(re, ".unknown", "K"); err != nil {
			t.Errorf("unexpected error: %v", err)
		}
	})
}

// capability_id: rainbond.cnb.npmrc-registry-parse
func TestNpmRegistryFromNpmrc(t *testing.T) {
	tests := []struct{ name, content, want string }{
		{"simple", "registry=https://registry.npmmirror.com\n", "https://registry.npmmirror.com"},
		{"no trailing newline", "registry=https://registry.npmmirror.com", "https://registry.npmmirror.com"},
		{"surrounding space", "  registry = https://registry.npmmirror.com  \n", "https://registry.npmmirror.com"},
		{"quoted", `registry="https://registry.npmmirror.com"`, "https://registry.npmmirror.com"},
		{"among other keys", "shamefully-hoist=true\nregistry=https://nexus.internal/repo\nstrict-peer-dependencies=false\n", "https://nexus.internal/repo"},
		{"comments skipped", "# registry=https://wrong.example\n; registry=https://wrong.example\nregistry=https://right.example\n", "https://right.example"},
		{"scoped only", "@myorg:registry=https://nexus.internal/repo\n", ""},
		{"no registry key", "shamefully-hoist=true\n", ""},
		{"empty", "", ""},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := npmRegistryFromNpmrc(tt.content); got != tt.want {
				t.Errorf("npmRegistryFromNpmrc(%q) = %q; want %q", tt.content, got, tt.want)
			}
		})
	}
}

// capability_id: rainbond.cnb.npm-global-registry
func TestResolveNpmRegistry(t *testing.T) {
	t.Setenv("ENABLE_CHINA_MIRROR", "true")

	t.Run("project npmrc wins over platform default", func(t *testing.T) {
		dir := newNodeDir(t)
		os.WriteFile(filepath.Join(dir, ".npmrc"), []byte("registry=https://nexus.internal/repo\n"), 0644)
		re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{"CNB_MIRROR_SOURCE": "project"}}
		if got := resolveNpmRegistry(re); got != "https://nexus.internal/repo" {
			t.Errorf("got %q; want the project registry", got)
		}
	})

	t.Run("explicit env wins over project npmrc", func(t *testing.T) {
		dir := newNodeDir(t)
		os.WriteFile(filepath.Join(dir, ".npmrc"), []byte("registry=https://nexus.internal/repo\n"), 0644)
		re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{"CNB_NPM_REGISTRY": "https://explicit.example"}}
		if got := resolveNpmRegistry(re); got != "https://explicit.example" {
			t.Errorf("got %q; want the explicit override", got)
		}
	})

	t.Run("BP_NODE_PROJECT_PATH npmrc preferred over root", func(t *testing.T) {
		dir := newNodeDir(t)
		os.WriteFile(filepath.Join(dir, ".npmrc"), []byte("registry=https://root.example\n"), 0644)
		sub := filepath.Join(dir, "frontend")
		os.MkdirAll(sub, 0755)
		os.WriteFile(filepath.Join(sub, ".npmrc"), []byte("registry=https://sub.example\n"), 0644)
		re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{"BP_NODE_PROJECT_PATH": "frontend"}}
		if got := resolveNpmRegistry(re); got != "https://sub.example" {
			t.Errorf("got %q; want the subdirectory registry", got)
		}
	})

	t.Run("falls back to CNB_MIRROR_NPMRC", func(t *testing.T) {
		dir := newNodeDir(t)
		re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{"CNB_MIRROR_NPMRC": "registry=https://custom.example\n"}}
		if got := resolveNpmRegistry(re); got != "https://custom.example" {
			t.Errorf("got %q; want the CNB_MIRROR_NPMRC registry", got)
		}
	})

	t.Run("falls back to china mirror default", func(t *testing.T) {
		dir := newNodeDir(t)
		re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{}}
		if got := resolveNpmRegistry(re); got != "https://registry.npmmirror.com" {
			t.Errorf("got %q; want the default mirror", got)
		}
	})

	t.Run("project npmrc without registry key falls through", func(t *testing.T) {
		dir := newNodeDir(t)
		os.WriteFile(filepath.Join(dir, ".npmrc"), []byte("shamefully-hoist=true\n"), 0644)
		re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{}}
		if got := resolveNpmRegistry(re); got != "https://registry.npmmirror.com" {
			t.Errorf("got %q; want the default mirror", got)
		}
	})

	t.Run("china mirror disabled yields nothing", func(t *testing.T) {
		t.Setenv("ENABLE_CHINA_MIRROR", "false")
		dir := newNodeDir(t)
		re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{}}
		if got := resolveNpmRegistry(re); got != "" {
			t.Errorf("got %q; want empty", got)
		}
	})
}

// capability_id: rainbond.cnb.npm-global-registry
// NPM_CONFIG_REGISTRY must reach the buildpack process: npm ignores the project
// .npmrc during `npm install -g pnpm@...`, so the env var is the only lever.
func TestNodejsNpmRegistryAnnotation(t *testing.T) {
	t.Setenv("ENABLE_CHINA_MIRROR", "true")
	dir := newNodeDir(t)
	os.WriteFile(filepath.Join(dir, ".npmrc"), []byte("registry=https://registry.npmmirror.com\n"), 0644)

	annotations := (&Builder{}).buildPlatformAnnotations(&build.Request{
		SourceDir: dir,
		BuildEnvs: map[string]string{"CNB_MIRROR_SOURCE": "project", "CNB_FRAMEWORK": "vite"},
	})

	if got := annotations["cnb-npm-config-registry"]; got != "https://registry.npmmirror.com" {
		t.Fatalf("cnb-npm-config-registry = %q; want the project registry", got)
	}
	if got := annotationKeyToBPEnv("cnb-npm-config-registry"); got != "NPM_CONFIG_REGISTRY" {
		t.Fatalf("annotation maps to env %q; want NPM_CONFIG_REGISTRY", got)
	}

	volume, _ := (&Builder{}).createPlatformVolume(annotations, nil)
	if volume == nil {
		t.Fatal("expected a platform volume")
	}
	var found bool
	for _, source := range volume.VolumeSource.Projected.Sources {
		if source.DownwardAPI == nil {
			continue
		}
		for _, item := range source.DownwardAPI.Items {
			if item.Path == "env/NPM_CONFIG_REGISTRY" {
				found = true
			}
		}
	}
	if !found {
		t.Error("platform volume missing env/NPM_CONFIG_REGISTRY")
	}
}

// --- platform.go ---

// capability_id: rainbond.cnb.annotation-key-encode
func TestBpEnvToAnnotationKey(t *testing.T) {
	tests := []struct{ input, want string }{
		{"BP_NODE_VERSION", "cnb-bp-node-version"},
		{"BP_WEB_SERVER", "cnb-bp-web-server"},
		{"BP_", "cnb-bp-"},
	}
	for _, tt := range tests {
		if got := bpEnvToAnnotationKey(tt.input); got != tt.want {
			t.Errorf("bpEnvToAnnotationKey(%q) = %q; want %q", tt.input, got, tt.want)
		}
	}
}

// capability_id: rainbond.cnb.annotation-key-decode
func TestAnnotationKeyToBPEnv(t *testing.T) {
	tests := []struct{ input, want string }{
		{"cnb-bp-node-version", "BP_NODE_VERSION"},
		{"cnb-bp-web-server", "BP_WEB_SERVER"},
		{"cnb-node-env", "NODE_ENV"},
	}
	for _, tt := range tests {
		if got := annotationKeyToBPEnv(tt.input); got != tt.want {
			t.Errorf("annotationKeyToBPEnv(%q) = %q; want %q", tt.input, got, tt.want)
		}
	}
}

// capability_id: rainbond.cnb.platform-annotations
func TestBuildPlatformAnnotations(t *testing.T) {
	nodeDir := newNodeDir(t)

	t.Run("node.js defaults", func(t *testing.T) {
		ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{}})
		if ann["cnb-node-env"] != "production" {
			t.Errorf("cnb-node-env = %q; want production", ann["cnb-node-env"])
		}
	})

	t.Run("custom NODE_ENV", func(t *testing.T) {
		ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{"CNB_NODE_ENV": "development"}})
		if ann["cnb-node-env"] != "development" {
			t.Errorf("got %q", ann["cnb-node-env"])
		}
	})

	t.Run("node version", func(t *testing.T) {
		ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{"CNB_NODE_VERSION": "20.10.0"}})
		if ann["cnb-bp-node-version"] != "20.10.0" {
			t.Errorf("got %q", ann["cnb-bp-node-version"])
		}
	})

	t.Run("RUNTIMES fallback", func(t *testing.T) {
		ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{"RUNTIMES": "18.17.0"}})
		if ann["cnb-bp-node-version"] != "18.17.0" {
			t.Errorf("got %q", ann["cnb-bp-node-version"])
		}
	})

	t.Run("static build", func(t *testing.T) {
		ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{"CNB_OUTPUT_DIR": "dist"}})
		if ann["cnb-bp-web-server"] != "nginx" {
			t.Error("expected nginx")
		}
		if ann["cnb-bp-web-server-root"] != "dist" {
			t.Errorf("got root %q", ann["cnb-bp-web-server-root"])
		}
	})

	t.Run("server framework skips nginx", func(t *testing.T) {
		ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{
			"CNB_FRAMEWORK":  "nestjs",
			"CNB_OUTPUT_DIR": "dist",
		}})
		if _, ok := ann["cnb-bp-web-server"]; ok {
			t.Error("server framework should not have nginx")
		}
	})

	t.Run("nextjs SSR skips nginx", func(t *testing.T) {
		ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{
			"CNB_FRAMEWORK": "nextjs",
		}})
		if _, ok := ann["cnb-bp-web-server"]; ok {
			t.Error("nextjs SSR without CNB_OUTPUT_DIR should not have nginx")
		}
	})

	t.Run("nextjs static export gets nginx", func(t *testing.T) {
		ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{
			"CNB_FRAMEWORK":  "nextjs-static",
			"CNB_OUTPUT_DIR": "out",
		}})
		if ann["cnb-bp-web-server"] != "nginx" {
			t.Error("nextjs-static should have nginx")
		}
		if ann["cnb-bp-web-server-root"] != "out" {
			t.Errorf("expected output dir 'out', got %q", ann["cnb-bp-web-server-root"])
		}
	})

	t.Run("nuxt static export gets nginx", func(t *testing.T) {
		ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{
			"CNB_FRAMEWORK":  "nuxt-static",
			"CNB_OUTPUT_DIR": "dist",
		}})
		if ann["cnb-bp-web-server"] != "nginx" {
			t.Error("nuxt-static should have nginx")
		}
	})

	t.Run("static framework gets nginx", func(t *testing.T) {
		ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{
			"CNB_FRAMEWORK": "react",
		}})
		if ann["cnb-bp-web-server"] != "nginx" {
			t.Error("static framework should have nginx")
		}
		if ann["cnb-bp-web-server-root"] != "dist" {
			t.Errorf("default output dir should be dist, got %q", ann["cnb-bp-web-server-root"])
		}
	})

	t.Run("static framework with custom output dir", func(t *testing.T) {
		ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{
			"CNB_FRAMEWORK":  "vue",
			"CNB_OUTPUT_DIR": "build",
		}})
		if ann["cnb-bp-web-server-root"] != "build" {
			t.Errorf("got %q; want build", ann["cnb-bp-web-server-root"])
		}
	})

	t.Run("build script", func(t *testing.T) {
		ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{"CNB_BUILD_SCRIPT": "build:prod"}})
		if ann["cnb-bp-node-run-scripts"] != "build:prod" {
			t.Errorf("got %q", ann["cnb-bp-node-run-scripts"])
		}
	})

	t.Run("BP_ passthrough", func(t *testing.T) {
		ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{"BP_CUSTOM_VAR": "val"}})
		if ann["cnb-bp-custom-var"] != "val" {
			t.Error("BP_ passthrough failed")
		}
	})

	t.Run("CNB_START_SCRIPT to BP_NPM_START_SCRIPT", func(t *testing.T) {
		ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{"CNB_START_SCRIPT": "start:prod"}})
		if ann["cnb-bp-npm-start-script"] != "start:prod" {
			t.Errorf("expected start:prod, got %q", ann["cnb-bp-npm-start-script"])
		}
	})

	t.Run("CNB_START_SCRIPT with pnpm", func(t *testing.T) {
		ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{
			"CNB_START_SCRIPT": "serve",
			"CNB_PACKAGE_TOOL": "pnpm",
		}})
		if ann["cnb-bp-npm-start-script"] != "serve" {
			t.Errorf("expected serve, got %q", ann["cnb-bp-npm-start-script"])
		}
	})

	t.Run("BP_NPM_START_SCRIPT passthrough", func(t *testing.T) {
		ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: nodeDir, BuildEnvs: map[string]string{"BP_NPM_START_SCRIPT": "start:prod"}})
		if ann["cnb-bp-npm-start-script"] != "start:prod" {
			t.Errorf("expected start:prod, got %q", ann["cnb-bp-npm-start-script"])
		}
	})

	t.Run("pure static project", func(t *testing.T) {
		dir := t.TempDir()
		ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: dir, BuildEnvs: map[string]string{}})
		if ann["cnb-bp-web-server"] != "nginx" {
			t.Error("pure static should have nginx")
		}
		if ann["cnb-bp-web-server-root"] != "." {
			t.Errorf("got %q; want '.'", ann["cnb-bp-web-server-root"])
		}
	})

	t.Run("pure static with custom output dir", func(t *testing.T) {
		dir := t.TempDir()
		ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: dir, BuildEnvs: map[string]string{"CNB_OUTPUT_DIR": "public"}})
		if ann["cnb-bp-web-server-root"] != "public" {
			t.Errorf("got %q; want public", ann["cnb-bp-web-server-root"])
		}
	})

	t.Run("static project nginx config takes precedence over generated defaults", func(t *testing.T) {
		for _, lang := range []code.Lang{code.Static, code.Nodejs} {
			t.Run(string(lang), func(t *testing.T) {
				dir := t.TempDir()
				configPath := filepath.Join(dir, "nginx.conf")
				config := []byte("daemon off;\nevents {}\nhttp { server { listen 8080; root /workspace; } }\n")
				if err := os.WriteFile(configPath, config, 0644); err != nil {
					t.Fatal(err)
				}
				b := &Builder{}
				re := &build.Request{Lang: lang, SourceDir: dir, BuildEnvs: map[string]string{"CNB_OUTPUT_DIR": "public"}}
				ann := b.buildPlatformAnnotations(re)
				for _, key := range []string{"cnb-bp-web-server", "cnb-bp-web-server-root", "cnb-bp-web-server-enable-push-state"} {
					if value, exists := ann[key]; exists {
						t.Errorf("project nginx.conf must suppress generated default %s=%q", key, value)
					}
				}
				vol, _ := b.createPlatformVolume(ann, nil)
				for _, source := range vol.Projected.Sources {
					if source.DownwardAPI == nil {
						continue
					}
					for _, item := range source.DownwardAPI.Items {
						if item.Path == "env/BP_WEB_SERVER" {
							t.Error("buildpack must not receive automatic config generation flag")
						}
					}
				}
				bps := getLanguageConfig(re).CustomOrder(re)
				if len(bps) != 1 || bps[0].ID != "paketo-buildpacks/nginx" {
					t.Fatalf("custom config still requires nginx buildpack, got %+v", bps)
				}
				got, err := os.ReadFile(configPath)
				if err != nil || string(got) != string(config) {
					t.Fatalf("project config changed: content=%q, err=%v", got, err)
				}
			})
		}
	})

	t.Run("nginx config directory does not suppress generated defaults", func(t *testing.T) {
		dir := t.TempDir()
		if err := os.Mkdir(filepath.Join(dir, "nginx.conf"), 0755); err != nil {
			t.Fatal(err)
		}
		ann := (&Builder{}).buildPlatformAnnotations(&build.Request{Lang: code.Static, SourceDir: dir})
		if ann["cnb-bp-web-server"] != "nginx" || ann["cnb-bp-web-server-root"] != "." || ann["cnb-bp-web-server-enable-push-state"] != "true" {
			t.Errorf("expected generated static defaults, got %v", ann)
		}
	})
}

// capability_id: rainbond.cnb.platform-volume
func TestCreatePlatformVolume(t *testing.T) {
	b := &Builder{}
	dir := newNodeDir(t)
	re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{"CNB_NODE_VERSION": "20"}}
	annotations := b.buildPlatformAnnotations(re)
	vol, mount := b.createPlatformVolume(annotations, nil)
	if vol == nil || mount == nil {
		t.Fatal("expected volume and mount")
	}
	if vol.Name != "platform" || mount.MountPath != "/platform" {
		t.Errorf("vol=%q mount=%q", vol.Name, mount.MountPath)
	}
	if vol.Projected == nil || len(vol.Projected.Sources) == 0 {
		t.Fatal("expected projected volume sources")
	}
	if vol.Projected.Sources[0].DownwardAPI == nil || len(vol.Projected.Sources[0].DownwardAPI.Items) == 0 {
		t.Error("expected DownwardAPI items")
	}
}

func TestCreatePlatformVolumeWithBinding(t *testing.T) {
	b := &Builder{}
	annotations := map[string]string{
		"cnb-bp-jvm-version":          "17",
		"cnb-binding-java-maven-type": "maven",
	}
	bindings := []platformBinding{{
		Name:          "java-maven",
		Type:          "maven",
		ConfigMapName: "java-maven",
		ConfigMapKey:  "mavensetting",
		TargetFile:    "settings.xml",
	}}
	vol, _ := b.createPlatformVolume(annotations, bindings)
	if vol == nil || vol.Projected == nil {
		t.Fatal("expected projected platform volume")
	}
	if len(vol.Projected.Sources) != 2 {
		t.Fatalf("expected 2 projected sources, got %d", len(vol.Projected.Sources))
	}
	configSource := vol.Projected.Sources[1].ConfigMap
	if configSource == nil {
		t.Fatal("expected configmap projection for binding")
	}
	if configSource.Name != "java-maven" {
		t.Fatalf("expected java-maven configmap, got %q", configSource.Name)
	}
	if len(configSource.Items) != 1 || configSource.Items[0].Path != "bindings/java-maven/settings.xml" {
		t.Fatalf("unexpected binding projection items: %+v", configSource.Items)
	}
}

func TestBindingTypeAnnotationKeyShortensLongBindingNames(t *testing.T) {
	bindingName := "06c421b8d22d718065929cfdea86b232-20260401205923-procfile-9mcpf"
	got := bindingTypeAnnotationKey(bindingName)
	if len(got) > 63 {
		t.Fatalf("annotation key length = %d, want <= 63: %q", len(got), got)
	}
	if !strings.HasPrefix(got, "cnb-binding-") {
		t.Fatalf("annotation key %q missing prefix", got)
	}
	if !strings.HasSuffix(got, "-type") {
		t.Fatalf("annotation key %q missing suffix", got)
	}
	if got == "cnb-binding-"+bindingName+"-type" {
		t.Fatalf("expected long binding name to be shortened, got %q", got)
	}
	if again := bindingTypeAnnotationKey(bindingName); again != got {
		t.Fatalf("annotation key should be deterministic: got %q, again %q", got, again)
	}
	if other := bindingTypeAnnotationKey(bindingName + "-other"); other == got {
		t.Fatalf("different binding names should not share annotation key: %q", other)
	}
}

// --- job.go ---

// capability_id: rainbond.cnb.env-vars
func TestBuildEnvVars(t *testing.T) {
	b := &Builder{}

	t.Run("basic envs", func(t *testing.T) {
		envs := b.buildEnvVars(&build.Request{BuildEnvs: map[string]string{}})
		m := make(map[string]string)
		for _, e := range envs {
			m[e.Name] = e.Value
		}
		if m["CNB_PLATFORM_API"] != "0.13" {
			t.Error("expected CNB_PLATFORM_API=0.13")
		}
		if m["DOCKER_CONFIG"] != "/home/cnb/.docker" {
			t.Error("expected DOCKER_CONFIG=/home/cnb/.docker")
		}
	})

	t.Run("proxy settings", func(t *testing.T) {
		os.Setenv("HTTP_PROXY", "http://p:8080")
		os.Setenv("HTTPS_PROXY", "https://p:8443")
		os.Setenv("NO_PROXY", "localhost")
		defer func() {
			os.Unsetenv("HTTP_PROXY")
			os.Unsetenv("HTTPS_PROXY")
			os.Unsetenv("NO_PROXY")
		}()
		envs := b.buildEnvVars(&build.Request{BuildEnvs: map[string]string{}})
		m := make(map[string]string)
		for _, e := range envs {
			m[e.Name] = e.Value
		}
		if m["HTTP_PROXY"] != "http://p:8080" {
			t.Error("HTTP_PROXY not set")
		}
	})
}

// capability_id: rainbond.cnb.creator-args
func TestBuildCreatorArgs(t *testing.T) {
	b := &Builder{}

	t.Run("basic args", func(t *testing.T) {
		dir := newNodeDir(t)
		re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{}}
		args := b.buildCreatorArgs(re, "img:v1", "run:v1")
		for _, r := range []string{"-app=/workspace", "-layers=/layers", "-platform=/platform"} {
			found := false
			for _, a := range args {
				if a == r {
					found = true
				}
			}
			if !found {
				t.Errorf("missing arg %s", r)
			}
		}
		if args[len(args)-1] != "img:v1" {
			t.Errorf("last arg = %q", args[len(args)-1])
		}
	})

	t.Run("pure static gets custom order", func(t *testing.T) {
		dir := t.TempDir()
		args := b.buildCreatorArgs(&build.Request{SourceDir: dir, BuildEnvs: map[string]string{}}, "img:v1", "run:v1")
		hasOrder := false
		for _, a := range args {
			if strings.HasPrefix(a, "-order=") {
				hasOrder = true
			}
		}
		if !hasOrder {
			t.Error("expected -order flag for pure static")
		}
	})

	t.Run("node project uses default order", func(t *testing.T) {
		dir := newNodeDir(t)
		args := b.buildCreatorArgs(&build.Request{SourceDir: dir, BuildEnvs: map[string]string{}}, "img:v1", "run:v1")
		for _, a := range args {
			if strings.HasPrefix(a, "-order=") {
				t.Error("node project should use default builder order, not custom order")
			}
		}
	})
}

// capability_id: rainbond.cnb.volume-mounts
func TestCreateVolumeAndMount(t *testing.T) {
	b := &Builder{}
	re := &build.Request{SourceDir: "/tmp/src", CacheDir: "/tmp/cache"}
	vols, mounts := b.createVolumeAndMount(re, "secret-name")
	if len(vols) != 4 {
		t.Errorf("expected 4 volumes, got %d", len(vols))
	}
	if len(mounts) != 4 {
		t.Errorf("expected 4 mounts, got %d", len(mounts))
	}
	// Check docker-config uses secret
	found := false
	for _, v := range vols {
		if v.Name == "docker-config" && v.Secret != nil && v.Secret.SecretName == "secret-name" {
			found = true
		}
	}
	if !found {
		t.Error("expected docker-config volume with secret")
	}
}

// --- build.go ---

// capability_id: rainbond.cnb.source-dir-permissions
func TestSetSourceDirPermissions(t *testing.T) {
	b := &Builder{}

	t.Run("removes .git and sets permissions recursively", func(t *testing.T) {
		dir := t.TempDir()
		gitDir := filepath.Join(dir, ".git")
		os.Mkdir(gitDir, 0755)
		os.WriteFile(filepath.Join(gitDir, "config"), []byte("test"), 0644)
		subDir := filepath.Join(dir, "src")
		os.Mkdir(subDir, 0755)
		os.WriteFile(filepath.Join(subDir, "main.go"), []byte("package main"), 0644)
		b.setSourceDirPermissions(&build.Request{SourceDir: dir})
		if _, err := os.Stat(gitDir); !os.IsNotExist(err) {
			t.Error(".git should be removed")
		}
		info, _ := os.Stat(dir)
		if info.Mode().Perm() != 0777 {
			t.Errorf("dir permissions = %o; want 0777", info.Mode().Perm())
		}
		subInfo, _ := os.Stat(subDir)
		if subInfo.Mode().Perm() != 0777 {
			t.Errorf("subdir permissions = %o; want 0777", subInfo.Mode().Perm())
		}
		fileInfo, _ := os.Stat(filepath.Join(subDir, "main.go"))
		if fileInfo.Mode().Perm() != 0777 {
			t.Errorf("file permissions = %o; want 0777", fileInfo.Mode().Perm())
		}
	})

	t.Run("no .git directory", func(t *testing.T) {
		dir := t.TempDir()
		if err := b.setSourceDirPermissions(&build.Request{SourceDir: dir}); err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
	})

	t.Run("keeps .git when BUILD_KEEP_GIT is true", func(t *testing.T) {
		dir := t.TempDir()
		gitDir := filepath.Join(dir, ".git")
		if err := os.Mkdir(gitDir, 0755); err != nil {
			t.Fatalf("create .git dir: %v", err)
		}
		if err := os.WriteFile(filepath.Join(gitDir, "config"), []byte("test"), 0644); err != nil {
			t.Fatalf("write .git config: %v", err)
		}

		err := b.setSourceDirPermissions(&build.Request{
			SourceDir: dir,
			BuildEnvs: map[string]string{"BUILD_KEEP_GIT": "true"},
		})
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if _, err := os.Stat(gitDir); err != nil {
			t.Fatalf(".git should be kept when BUILD_KEEP_GIT=true, got %v", err)
		}
	})
}

// capability_id: rainbond.cnb.new-builder
func TestNewBuilder(t *testing.T) {
	b, err := NewBuilder()
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if b == nil {
		t.Error("expected non-nil builder")
	}
}

// capability_id: rainbond.cnb.project-file-validation
func TestValidateProjectFiles(t *testing.T) {
	b := &Builder{}
	logger := event.NewLogger("test", nil)

	t.Run("empty lock file returns error", func(t *testing.T) {
		dir := t.TempDir()
		os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte{}, 0644)
		re := &build.Request{SourceDir: dir, Logger: logger}
		if err := b.validateProjectFiles(re); err == nil {
			t.Error("expected error for empty lock file")
		}
	})

	t.Run("valid lock file passes", func(t *testing.T) {
		dir := t.TempDir()
		os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte(`{"lockfileVersion":3}`), 0644)
		re := &build.Request{SourceDir: dir, Logger: logger}
		if err := b.validateProjectFiles(re); err != nil {
			t.Errorf("unexpected error: %v", err)
		}
	})

	t.Run("no lock file passes", func(t *testing.T) {
		dir := t.TempDir()
		re := &build.Request{SourceDir: dir, Logger: logger}
		if err := b.validateProjectFiles(re); err != nil {
			t.Errorf("unexpected error: %v", err)
		}
	})
}

// noopLogger and related types removed - using event.NewLogger instead

// --- waitingComplete (job.go) ---

// capability_id: rainbond.cnb.waiting-complete
func TestWaitingComplete(t *testing.T) {
	b := &Builder{}
	logger := event.GetTestLogger()

	t.Run("complete then logcomplete", func(t *testing.T) {
		reChan := channels.NewRingChannel(10)
		re := &build.Request{Logger: logger}
		go func() {
			reChan.In() <- "complete"
			reChan.In() <- "logcomplete"
		}()
		if err := b.waitingComplete(re, reChan); err != nil {
			t.Errorf("unexpected error: %v", err)
		}
	})

	t.Run("logcomplete then complete", func(t *testing.T) {
		reChan := channels.NewRingChannel(10)
		re := &build.Request{Logger: logger}
		go func() {
			reChan.In() <- "logcomplete"
			reChan.In() <- "complete"
		}()
		if err := b.waitingComplete(re, reChan); err != nil {
			t.Errorf("unexpected error: %v", err)
		}
	})

	t.Run("failed then logcomplete", func(t *testing.T) {
		reChan := channels.NewRingChannel(10)
		re := &build.Request{Logger: logger}
		go func() {
			reChan.In() <- "failed"
			reChan.In() <- "logcomplete"
		}()
		err := b.waitingComplete(re, reChan)
		if err == nil {
			t.Error("expected error for failed job")
		}
	})

	t.Run("logcomplete then failed", func(t *testing.T) {
		reChan := channels.NewRingChannel(10)
		re := &build.Request{Logger: logger}
		go func() {
			reChan.In() <- "logcomplete"
			reChan.In() <- "failed"
		}()
		err := b.waitingComplete(re, reChan)
		if err == nil {
			t.Error("expected error for failed job")
		}
	})

	t.Run("cancel then logcomplete", func(t *testing.T) {
		reChan := channels.NewRingChannel(10)
		re := &build.Request{Logger: logger}
		go func() {
			reChan.In() <- "cancel"
			reChan.In() <- "logcomplete"
		}()
		err := b.waitingComplete(re, reChan)
		if err == nil {
			t.Error("expected error for canceled job")
		}
		if !strings.Contains(err.Error(), "canceled") {
			t.Errorf("expected 'canceled' in error, got %q", err.Error())
		}
	})

	t.Run("non-string status ignored", func(t *testing.T) {
		reChan := channels.NewRingChannel(10)
		re := &build.Request{Logger: logger}
		go func() {
			reChan.In() <- 12345
			reChan.In() <- "complete"
			reChan.In() <- "logcomplete"
		}()
		if err := b.waitingComplete(re, reChan); err != nil {
			t.Errorf("unexpected error: %v", err)
		}
	})
}

// --- createPlatformVolume edge cases ---

// capability_id: rainbond.cnb.platform-volume
func TestCreatePlatformVolumeEmpty(t *testing.T) {
	b := &Builder{}
	vol, mount := b.createPlatformVolume(map[string]string{}, nil)
	if vol != nil || mount != nil {
		t.Error("expected nil for empty annotations")
	}
}

// capability_id: rainbond.cnb.platform-volume
func TestCreatePlatformVolumeNonCNBKeys(t *testing.T) {
	b := &Builder{}
	vol, mount := b.createPlatformVolume(map[string]string{"other-key": "val"}, nil)
	if vol != nil || mount != nil {
		t.Error("expected nil when no cnb- prefixed keys")
	}
}

// --- writeCustomOrder error path ---

// capability_id: rainbond.cnb.order-write-failure
func TestWriteCustomOrderFailure(t *testing.T) {
	b := &Builder{}
	re := &build.Request{SourceDir: "/nonexistent/path/that/does/not/exist"}
	bps := []orderBuildpack{{ID: "bp/test"}}
	flag := b.writeCustomOrder(re, bps, "test")
	if flag != "" {
		t.Errorf("expected empty flag on write failure, got %q", flag)
	}
}

// --- injectMirrorConfig error propagation ---

// capability_id: rainbond.cnb.mirror-config-write-error
func TestInjectMirrorConfigWriteError(t *testing.T) {
	n := &nodejsConfig{}
	dir := newNodeDir(t)
	// Make dir read-only so WriteFile fails
	os.Chmod(dir, 0555)
	defer os.Chmod(dir, 0755)
	re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{
		"CNB_MIRROR_NPMRC": "custom-content",
	}}
	err := n.InjectMirrorConfig(re)
	if err == nil {
		t.Error("expected error when directory is read-only")
	}
}

// --- setSourceDirPermissions with nonexistent dir ---

// capability_id: rainbond.cnb.source-dir-permissions
func TestSetSourceDirPermissionsNonexistent(t *testing.T) {
	b := &Builder{}
	err := b.setSourceDirPermissions(&build.Request{SourceDir: "/nonexistent/path"})
	if err == nil {
		t.Error("expected error for nonexistent directory")
	}
}

// --- dependency mirror defaults to online URL ---

// capability_id: rainbond.cnb.dependency-mirror
func TestBuildPlatformAnnotationsMirrorDefault(t *testing.T) {
	os.Unsetenv("BP_DEPENDENCY_MIRROR")
	dir := newNodeDir(t)
	ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: dir, BuildEnvs: map[string]string{}})
	if ann["cnb-bp-dependency-mirror"] != defaultOnlineMirror {
		t.Errorf("expected default dependency mirror %s, got %q", defaultOnlineMirror, ann["cnb-bp-dependency-mirror"])
	}
}

// capability_id: rainbond.cnb.dependency-mirror
func TestBuildPlatformAnnotationsMirrorExplicit(t *testing.T) {
	os.Setenv("BP_DEPENDENCY_MIRROR", "https://example.com/mirror")
	defer os.Unsetenv("BP_DEPENDENCY_MIRROR")
	dir := newNodeDir(t)
	ann := (&Builder{}).buildPlatformAnnotations(&build.Request{SourceDir: dir, BuildEnvs: map[string]string{}})
	if ann["cnb-bp-dependency-mirror"] != "https://example.com/mirror" {
		t.Errorf("expected dependency mirror from env, got %q", ann["cnb-bp-dependency-mirror"])
	}
}

// capability_id: rainbond.cnb.dependency-mirror
func TestGetDependencyMirrorOffline(t *testing.T) {
	os.Unsetenv("BP_DEPENDENCY_MIRROR")
	dir := t.TempDir()
	// Temporarily override the marker path for testing
	origMarker := offlineMirrorMarker
	marker := filepath.Join(dir, "BP_DEPENDENCY_MIRROR")
	os.WriteFile(marker, []byte("file:///grdata/cnb\n"), 0644)

	// We can't easily override the const, so test getDependencyMirror indirectly
	// by setting the env var (highest priority)
	os.Setenv("BP_DEPENDENCY_MIRROR", "file:///grdata/cnb")
	defer os.Unsetenv("BP_DEPENDENCY_MIRROR")
	got := getDependencyMirror()
	if got != "file:///grdata/cnb" {
		t.Errorf("expected file:///grdata/cnb, got %q", got)
	}
	_ = origMarker
}

// --- BP_ passthrough does not override explicit keys ---

// capability_id: rainbond.cnb.bp-annotation-priority
func TestBuildPlatformAnnotationsBPNoOverride(t *testing.T) {
	dir := newNodeDir(t)
	ann := (&Builder{}).buildPlatformAnnotations(&build.Request{
		SourceDir: dir,
		BuildEnvs: map[string]string{
			"CNB_BUILD_SCRIPT":    "build:prod",
			"BP_NODE_RUN_SCRIPTS": "should-not-override",
		},
	})
	if ann["cnb-bp-node-run-scripts"] != "build:prod" {
		t.Errorf("BP_ passthrough should not override explicit key, got %q", ann["cnb-bp-node-run-scripts"])
	}
}

// --- mock job controller ---

type mockJobCtrl struct {
	jobs                        []*corev1.Pod
	getJobsErr                  error
	execJobErr                  error
	execJobFn                   func(ctx context.Context, job *corev1.Pod, logger io.Writer, result *channels.RingChannel) error
	deleted                     []string
	languageBuildSettings       map[string]string
	defaultLanguageBuildSetting string
}

func (m *mockJobCtrl) ExecJob(ctx context.Context, job *corev1.Pod, logger io.Writer, result *channels.RingChannel) error {
	if m.execJobFn != nil {
		return m.execJobFn(ctx, job, logger, result)
	}
	return m.execJobErr
}
func (m *mockJobCtrl) GetJob(name string) (*corev1.Pod, error) { return nil, nil }
func (m *mockJobCtrl) GetServiceJobs(serviceID string) ([]*corev1.Pod, error) {
	return m.jobs, m.getJobsErr
}
func (m *mockJobCtrl) DeleteJob(name string) { m.deleted = append(m.deleted, name) }
func (m *mockJobCtrl) GetLanguageBuildSetting(_ context.Context, _ code.Lang, name string) string {
	if m.languageBuildSettings == nil {
		return ""
	}
	return m.languageBuildSettings[name]
}
func (m *mockJobCtrl) GetDefaultLanguageBuildSetting(_ context.Context, _ code.Lang) string {
	return m.defaultLanguageBuildSetting
}

func newTestBuilder(ctrl *mockJobCtrl) *Builder {
	return &Builder{
		jobCtrl: ctrl,
		createAuthSecret: func(re *build.Request) (corev1.Secret, error) {
			return corev1.Secret{}, nil
		},
		deleteAuthSecret: func(re *build.Request, name string) {},
		prepareBuildKit: func(ctx context.Context, kubeClient kubernetes.Interface, namespace, cmName, imageDomain string) error {
			return nil
		},
	}
}

// --- stopPreBuildJob ---

// capability_id: rainbond.cnb.prebuild-job-cleanup
func TestStopPreBuildJob(t *testing.T) {
	t.Run("deletes existing jobs", func(t *testing.T) {
		ctrl := &mockJobCtrl{
			jobs: []*corev1.Pod{
				{ObjectMeta: corev1.Pod{}.ObjectMeta},
			},
		}
		ctrl.jobs[0].Name = "job-1"
		b := newTestBuilder(ctrl)
		b.stopPreBuildJob(&build.Request{ServiceID: "svc1"})
		if len(ctrl.deleted) != 1 || ctrl.deleted[0] != "job-1" {
			t.Errorf("expected job-1 deleted, got %v", ctrl.deleted)
		}
	})

	t.Run("no jobs", func(t *testing.T) {
		ctrl := &mockJobCtrl{}
		b := newTestBuilder(ctrl)
		b.stopPreBuildJob(&build.Request{ServiceID: "svc1"})
		if len(ctrl.deleted) != 0 {
			t.Error("expected no deletions")
		}
	})

	t.Run("error getting jobs", func(t *testing.T) {
		ctrl := &mockJobCtrl{getJobsErr: fmt.Errorf("api error")}
		b := newTestBuilder(ctrl)
		b.stopPreBuildJob(&build.Request{ServiceID: "svc1"})
		if len(ctrl.deleted) != 0 {
			t.Error("expected no deletions on error")
		}
	})
}

// --- runCNBBuildJob ---

// capability_id: rainbond.cnb.build-job-execution
func TestRunCNBBuildJob(t *testing.T) {
	logger := event.GetTestLogger()

	t.Run("exec job failure", func(t *testing.T) {
		ctrl := &mockJobCtrl{execJobErr: fmt.Errorf("exec failed")}
		b := newTestBuilder(ctrl)
		dir := newNodeDir(t)
		re := &build.Request{
			ServiceID:     "svc1",
			DeployVersion: "v1",
			RbdNamespace:  "ns",
			Arch:          "amd64",
			SourceDir:     dir,
			CacheDir:      "/tmp/cache",
			BuildEnvs:     map[string]string{},
			Logger:        logger,
		}
		err := b.runCNBBuildJob(re, "img:v1")
		if err == nil || !strings.Contains(err.Error(), "exec failed") {
			t.Errorf("expected exec failed error, got %v", err)
		}
	})

	t.Run("auth secret failure", func(t *testing.T) {
		ctrl := &mockJobCtrl{}
		b := &Builder{
			jobCtrl: ctrl,
			createAuthSecret: func(re *build.Request) (corev1.Secret, error) {
				return corev1.Secret{}, fmt.Errorf("auth failed")
			},
			deleteAuthSecret: func(re *build.Request, name string) {},
			prepareBuildKit: func(ctx context.Context, kubeClient kubernetes.Interface, namespace, cmName, imageDomain string) error {
				return nil
			},
		}
		dir := newNodeDir(t)
		re := &build.Request{
			ServiceID:     "svc1",
			DeployVersion: "v1",
			RbdNamespace:  "ns",
			Arch:          "amd64",
			SourceDir:     dir,
			CacheDir:      "/tmp/cache",
			BuildEnvs:     map[string]string{},
			Logger:        logger,
		}
		err := b.runCNBBuildJob(re, "img:v1")
		if err == nil || !strings.Contains(err.Error(), "auth failed") {
			t.Errorf("expected auth failed error, got %v", err)
		}
	})

	t.Run("successful job with complete", func(t *testing.T) {
		ctrl := &mockJobCtrl{
			execJobFn: func(ctx context.Context, job *corev1.Pod, logger io.Writer, result *channels.RingChannel) error {
				go func() {
					result.In() <- "complete"
					result.In() <- "logcomplete"
				}()
				return nil
			},
		}
		b := newTestBuilder(ctrl)
		dir := newNodeDir(t)
		re := &build.Request{
			ServiceID:     "svc1",
			DeployVersion: "v1",
			RbdNamespace:  "ns",
			Arch:          "amd64",
			SourceDir:     dir,
			CacheDir:      "/tmp/cache",
			BuildEnvs:     map[string]string{},
			Logger:        logger,
		}
		err := b.runCNBBuildJob(re, "img:v1")
		if err != nil {
			t.Errorf("unexpected error: %v", err)
		}
		if len(ctrl.deleted) != 1 {
			t.Errorf("expected job cleanup, got %d deletions", len(ctrl.deleted))
		}
	})

	t.Run("procfile build env is injected through binding and cleaned up", func(t *testing.T) {
		var capturedPod *corev1.Pod
		ctrl := &mockJobCtrl{
			execJobFn: func(ctx context.Context, job *corev1.Pod, logger io.Writer, result *channels.RingChannel) error {
				capturedPod = job
				go func() {
					result.In() <- "complete"
					result.In() <- "logcomplete"
				}()
				return nil
			},
		}
		var createdConfigMap *corev1.ConfigMap
		var deletedConfigMapNames []string
		b := &Builder{
			jobCtrl: ctrl,
			createAuthSecret: func(re *build.Request) (corev1.Secret, error) {
				return corev1.Secret{}, nil
			},
			deleteAuthSecret: func(re *build.Request, name string) {},
			prepareBuildKit: func(ctx context.Context, kubeClient kubernetes.Interface, namespace, cmName, imageDomain string) error {
				return nil
			},
			createConfigMap: func(ctx context.Context, kubeClient kubernetes.Interface, namespace string, cm *corev1.ConfigMap) (*corev1.ConfigMap, error) {
				createdConfigMap = cm.DeepCopy()
				if createdConfigMap.Name == "" {
					createdConfigMap.Name = createdConfigMap.GenerateName + "abc123"
				}
				return createdConfigMap, nil
			},
			deleteConfigMap: func(ctx context.Context, kubeClient kubernetes.Interface, namespace, name string) error {
				deletedConfigMapNames = append(deletedConfigMapNames, name)
				return nil
			},
		}
		re := &build.Request{
			ServiceID:     "svc1",
			DeployVersion: "v1",
			RbdNamespace:  "ns",
			Arch:          "amd64",
			SourceDir:     t.TempDir(),
			CacheDir:      "/tmp/cache",
			BuildEnvs: map[string]string{
				"BUILD_AUTO_PROCFILE": "web: python manage.py runserver 0.0.0.0:$_PORT",
			},
			Logger: logger,
		}

		err := b.runCNBBuildJob(re, "img:v1")
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if capturedPod == nil {
			t.Fatal("pod was not captured")
		}

		var bindingName string
		foundBindingProjection := false
		for _, vol := range capturedPod.Spec.Volumes {
			if vol.Name != "platform" || vol.Projected == nil {
				continue
			}
			for _, source := range vol.Projected.Sources {
				if source.ConfigMap == nil || len(source.ConfigMap.Items) != 1 {
					continue
				}
				item := source.ConfigMap.Items[0]
				if !strings.HasSuffix(item.Path, "/Procfile") {
					continue
				}
				foundBindingProjection = true
				bindingName = source.ConfigMap.Name
				wantPath := "bindings/" + bindingName + "/Procfile"
				if item.Path != wantPath {
					t.Fatalf("procfile binding path = %q, want %q", item.Path, wantPath)
				}
			}
		}
		if !foundBindingProjection {
			t.Fatal("expected Procfile binding projection in CNB platform volume")
		}
		if got := capturedPod.Annotations[bindingTypeAnnotationKey(bindingName)]; got != "Procfile" {
			t.Fatalf("binding annotation = %q, want Procfile", got)
		}
		if createdConfigMap == nil {
			t.Fatal("expected temporary Procfile ConfigMap to be created")
		}
		if createdConfigMap.Data["Procfile"] != "web: python manage.py runserver 0.0.0.0:$_PORT\n" {
			t.Fatalf("created Procfile content = %q, want %q", createdConfigMap.Data["Procfile"], "web: python manage.py runserver 0.0.0.0:$_PORT\n")
		}
		if createdConfigMap.Name != bindingName {
			t.Fatalf("created ConfigMap name = %q, want %q", createdConfigMap.Name, bindingName)
		}
		if len(deletedConfigMapNames) != 1 || deletedConfigMapNames[0] != bindingName {
			t.Fatalf("expected temporary Procfile ConfigMap cleanup for %q, got %v", bindingName, deletedConfigMapNames)
		}
	})

	t.Run("long procfile binding names keep annotation keys within kubernetes limits", func(t *testing.T) {
		var capturedPod *corev1.Pod
		ctrl := &mockJobCtrl{
			execJobFn: func(ctx context.Context, job *corev1.Pod, logger io.Writer, result *channels.RingChannel) error {
				capturedPod = job
				go func() {
					result.In() <- "complete"
					result.In() <- "logcomplete"
				}()
				return nil
			},
		}
		var createdConfigMap *corev1.ConfigMap
		b := &Builder{
			jobCtrl: ctrl,
			createAuthSecret: func(re *build.Request) (corev1.Secret, error) {
				return corev1.Secret{}, nil
			},
			deleteAuthSecret: func(re *build.Request, name string) {},
			prepareBuildKit: func(ctx context.Context, kubeClient kubernetes.Interface, namespace, cmName, imageDomain string) error {
				return nil
			},
			createConfigMap: func(ctx context.Context, kubeClient kubernetes.Interface, namespace string, cm *corev1.ConfigMap) (*corev1.ConfigMap, error) {
				createdConfigMap = cm.DeepCopy()
				createdConfigMap.Name = cm.GenerateName + "9mcpf"
				return createdConfigMap, nil
			},
			deleteConfigMap: func(ctx context.Context, kubeClient kubernetes.Interface, namespace, name string) error {
				return nil
			},
		}
		re := &build.Request{
			ServiceID:     "06c421b8d22d718065929cfdea86b232",
			DeployVersion: "20260401205923",
			RbdNamespace:  "ns",
			Arch:          "amd64",
			SourceDir:     t.TempDir(),
			CacheDir:      "/tmp/cache",
			BuildEnvs: map[string]string{
				"BUILD_AUTO_PROCFILE": "web: python manage.py runserver 0.0.0.0:$_PORT",
			},
			Logger: logger,
		}

		err := b.runCNBBuildJob(re, "img:v1")
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if capturedPod == nil {
			t.Fatal("pod was not captured")
		}
		if createdConfigMap == nil {
			t.Fatal("expected temporary Procfile ConfigMap to be created")
		}

		annotationKey := bindingTypeAnnotationKey(createdConfigMap.Name)
		if len(annotationKey) > 63 {
			t.Fatalf("annotation key length = %d, want <= 63: %q", len(annotationKey), annotationKey)
		}
		if got := capturedPod.Annotations[annotationKey]; got != "Procfile" {
			t.Fatalf("binding annotation = %q, want Procfile", got)
		}

		foundPlatformMount := false
		foundBindingFieldRef := false
		wantFieldPath := "metadata.annotations['" + annotationKey + "']"
		for _, mount := range capturedPod.Spec.Containers[0].VolumeMounts {
			if mount.Name == "platform" && mount.MountPath == "/platform" {
				foundPlatformMount = true
			}
		}
		for _, vol := range capturedPod.Spec.Volumes {
			if vol.Name != "platform" || vol.Projected == nil {
				continue
			}
			for _, source := range vol.Projected.Sources {
				if source.DownwardAPI == nil {
					continue
				}
				for _, item := range source.DownwardAPI.Items {
					if item.Path != "bindings/"+createdConfigMap.Name+"/type" {
						continue
					}
					foundBindingFieldRef = true
					if item.FieldRef == nil || item.FieldRef.FieldPath != wantFieldPath {
						t.Fatalf("binding type fieldRef = %+v, want %q", item.FieldRef, wantFieldPath)
					}
				}
			}
		}
		if !foundPlatformMount {
			t.Fatal("expected platform volume mount to be present")
		}
		if !foundBindingFieldRef {
			t.Fatal("expected binding type DownwardAPI item for long Procfile binding")
		}
	})

	t.Run("CNB_START_SCRIPT in pod annotations and downward API", func(t *testing.T) {
		var capturedPod *corev1.Pod
		ctrl := &mockJobCtrl{
			execJobFn: func(ctx context.Context, job *corev1.Pod, logger io.Writer, result *channels.RingChannel) error {
				capturedPod = job
				go func() {
					result.In() <- "complete"
					result.In() <- "logcomplete"
				}()
				return nil
			},
		}
		b := newTestBuilder(ctrl)
		dir := newNodeDir(t)
		re := &build.Request{
			ServiceID:     "svc1",
			DeployVersion: "v1",
			RbdNamespace:  "ns",
			Arch:          "amd64",
			SourceDir:     dir,
			CacheDir:      "/tmp/cache",
			BuildEnvs:     map[string]string{"CNB_START_SCRIPT": "start:prod"},
			Logger:        logger,
		}
		err := b.runCNBBuildJob(re, "img:v1")
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if capturedPod == nil {
			t.Fatal("pod was not captured")
		}
		if len(capturedPod.Spec.Containers) != 1 {
			t.Fatalf("expected 1 container, got %d", len(capturedPod.Spec.Containers))
		}
		if capturedPod.Spec.Containers[0].ImagePullPolicy != corev1.PullIfNotPresent {
			t.Fatalf("container ImagePullPolicy = %q; want %q", capturedPod.Spec.Containers[0].ImagePullPolicy, corev1.PullIfNotPresent)
		}

		// Verify Pod annotation
		if v, ok := capturedPod.Annotations["cnb-bp-npm-start-script"]; !ok || v != "start:prod" {
			t.Errorf("Pod annotation cnb-bp-npm-start-script = %q (exists=%v); want start:prod", v, ok)
			t.Logf("All annotations: %v", capturedPod.Annotations)
		}

		// Verify DownwardAPI volume has the env file
		foundEnvFile := false
		for _, vol := range capturedPod.Spec.Volumes {
			if vol.Name == "platform" && vol.Projected != nil {
				for _, source := range vol.Projected.Sources {
					if source.DownwardAPI == nil {
						continue
					}
					for _, item := range source.DownwardAPI.Items {
						if item.Path == "env/BP_NPM_START_SCRIPT" {
							foundEnvFile = true
							if item.FieldRef.FieldPath != "metadata.annotations['cnb-bp-npm-start-script']" {
								t.Errorf("FieldPath = %q; want metadata.annotations['cnb-bp-npm-start-script']", item.FieldRef.FieldPath)
							}
						}
					}
				}
			}
		}
		if !foundEnvFile {
			t.Error("DownwardAPI volume missing env/BP_NPM_START_SCRIPT item")
		}
	})

	t.Run("CNB_START_SCRIPT and BP_* passthrough in pod annotations and downward API", func(t *testing.T) {
		var capturedPod *corev1.Pod
		ctrl := &mockJobCtrl{
			execJobFn: func(ctx context.Context, job *corev1.Pod, logger io.Writer, result *channels.RingChannel) error {
				capturedPod = job
				go func() {
					result.In() <- "complete"
					result.In() <- "logcomplete"
				}()
				return nil
			},
		}
		b := newTestBuilder(ctrl)
		dir := newNodeDir(t)
		re := &build.Request{
			ServiceID:     "svc1",
			DeployVersion: "v1",
			RbdNamespace:  "ns",
			Arch:          "amd64",
			SourceDir:     dir,
			CacheDir:      "/tmp/cache",
			BuildEnvs: map[string]string{
				"CNB_START_SCRIPT": "start:prod",
				"BP_NODE_VERSION":  "20.10.0",
				"BP_CUSTOM_FLAG":   "enabled",
			},
			Logger: logger,
		}
		err := b.runCNBBuildJob(re, "img:v1")
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if capturedPod == nil {
			t.Fatal("pod was not captured")
		}

		// Verify all annotations exist on the Pod
		wantAnnotations := map[string]string{
			"cnb-bp-npm-start-script": "start:prod",
			"cnb-bp-node-version":     "20.10.0",
			"cnb-bp-custom-flag":      "enabled",
		}
		for annKey, wantVal := range wantAnnotations {
			if got, ok := capturedPod.Annotations[annKey]; !ok {
				t.Errorf("missing annotation %q", annKey)
			} else if got != wantVal {
				t.Errorf("annotation %q = %q; want %q", annKey, got, wantVal)
			}
		}

		// Verify DownwardAPI volume has all env files
		wantEnvFiles := map[string]string{
			"env/BP_NPM_START_SCRIPT": "metadata.annotations['cnb-bp-npm-start-script']",
			"env/BP_NODE_VERSION":     "metadata.annotations['cnb-bp-node-version']",
			"env/BP_CUSTOM_FLAG":      "metadata.annotations['cnb-bp-custom-flag']",
		}
		foundFiles := make(map[string]bool)
		for _, vol := range capturedPod.Spec.Volumes {
			if vol.Name == "platform" && vol.Projected != nil {
				for _, source := range vol.Projected.Sources {
					if source.DownwardAPI == nil {
						continue
					}
					for _, item := range source.DownwardAPI.Items {
						if wantField, ok := wantEnvFiles[item.Path]; ok {
							foundFiles[item.Path] = true
							if item.FieldRef.FieldPath != wantField {
								t.Errorf("DownwardAPI %s FieldPath = %q; want %q", item.Path, item.FieldRef.FieldPath, wantField)
							}
						}
					}
				}
			}
		}
		for path := range wantEnvFiles {
			if !foundFiles[path] {
				t.Errorf("DownwardAPI volume missing %s item", path)
			}
		}
	})

	t.Run("java maven fields are visible in pod annotations and envs", func(t *testing.T) {
		var capturedPod *corev1.Pod
		ctrl := &mockJobCtrl{
			defaultLanguageBuildSetting: "default-maven",
			execJobFn: func(ctx context.Context, job *corev1.Pod, logger io.Writer, result *channels.RingChannel) error {
				capturedPod = job
				go func() {
					result.In() <- "complete"
					result.In() <- "logcomplete"
				}()
				return nil
			},
		}
		b := newTestBuilder(ctrl)
		re := &build.Request{
			ServiceID:     "svc1",
			DeployVersion: "v1",
			RbdNamespace:  "ns",
			Arch:          "amd64",
			SourceDir:     t.TempDir(),
			CacheDir:      "/tmp/cache",
			Lang:          code.JavaMaven,
			BuildEnvs: map[string]string{
				"BUILD_MAVEN_CUSTOM_GOALS":   "clean package",
				"BUILD_MAVEN_CUSTOM_OPTS":    "-DskipTests",
				"BUILD_MAVEN_JAVA_OPTS":      "-Xmx1024m",
				"BUILD_MAVEN_BUILT_MODULE":   "service-a",
				"BUILD_MAVEN_BUILT_ARTIFACT": "service-a/target/app.jar",
			},
			Logger: logger,
		}

		err := b.runCNBBuildJob(re, "img:v1")
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if capturedPod == nil {
			t.Fatal("pod was not captured")
		}
		if len(capturedPod.Spec.Containers) != 1 {
			t.Fatalf("expected 1 container, got %d", len(capturedPod.Spec.Containers))
		}

		wantAnnotations := map[string]string{
			"cnb-bp-maven-build-arguments":            "clean package",
			"cnb-bp-maven-additional-build-arguments": "-DskipTests",
			"cnb-bp-maven-built-module":               "service-a",
			"cnb-bp-maven-built-artifact":             "service-a/target/app.jar",
			"cnb-bp-maven-settings-path":              "/platform/bindings/default-maven/settings.xml",
			"cnb-lang":                                "C.UTF-8",
			"cnb-lc-all":                              "C.UTF-8",
			"cnb-maven-opts":                          "-Xmx1024m -Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8",
		}
		for key, wantValue := range wantAnnotations {
			if got := capturedPod.Annotations[key]; got != wantValue {
				t.Fatalf("annotation %s = %q, want %q", key, got, wantValue)
			}
		}

		wantEnv := map[string]string{
			"BP_MAVEN_BUILD_ARGUMENTS":            "clean package",
			"BP_MAVEN_ADDITIONAL_BUILD_ARGUMENTS": "-DskipTests",
			"BP_MAVEN_BUILT_MODULE":               "service-a",
			"BP_MAVEN_BUILT_ARTIFACT":             "service-a/target/app.jar",
			"MAVEN_OPTS":                          "-Xmx1024m -Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8",
		}
		foundEnv := map[string]bool{}
		for _, env := range capturedPod.Spec.Containers[0].Env {
			if wantValue, ok := wantEnv[env.Name]; ok {
				foundEnv[env.Name] = true
				if env.Value != wantValue {
					t.Fatalf("env %s = %q, want %q", env.Name, env.Value, wantValue)
				}
			}
		}
		for name := range wantEnv {
			if !foundEnv[name] {
				t.Fatalf("missing env %s in CNB pod", name)
			}
		}

		wantPlatformEnvFiles := map[string]string{
			"env/LANG":       "metadata.annotations['cnb-lang']",
			"env/LC_ALL":     "metadata.annotations['cnb-lc-all']",
			"env/MAVEN_OPTS": "metadata.annotations['cnb-maven-opts']",
		}
		foundPlatformEnvFiles := map[string]bool{}
		for _, vol := range capturedPod.Spec.Volumes {
			if vol.Name != "platform" || vol.Projected == nil {
				continue
			}
			for _, source := range vol.Projected.Sources {
				if source.DownwardAPI == nil {
					continue
				}
				for _, item := range source.DownwardAPI.Items {
					if wantField, ok := wantPlatformEnvFiles[item.Path]; ok {
						foundPlatformEnvFiles[item.Path] = true
						if item.FieldRef == nil || item.FieldRef.FieldPath != wantField {
							t.Fatalf("platform env %s fieldRef = %+v, want %q", item.Path, item.FieldRef, wantField)
						}
					}
				}
			}
		}
		for path := range wantPlatformEnvFiles {
			if !foundPlatformEnvFiles[path] {
				t.Fatalf("missing platform env file %s in CNB pod", path)
			}
		}
	})

	t.Run("java maven derives built module from built artifact in pod", func(t *testing.T) {
		var capturedPod *corev1.Pod
		ctrl := &mockJobCtrl{
			defaultLanguageBuildSetting: "default-maven",
			execJobFn: func(ctx context.Context, job *corev1.Pod, logger io.Writer, result *channels.RingChannel) error {
				capturedPod = job
				go func() {
					result.In() <- "complete"
					result.In() <- "logcomplete"
				}()
				return nil
			},
		}
		b := newTestBuilder(ctrl)
		re := &build.Request{
			ServiceID:     "svc1",
			DeployVersion: "v1",
			RbdNamespace:  "ns",
			Arch:          "amd64",
			SourceDir:     t.TempDir(),
			CacheDir:      "/tmp/cache",
			Lang:          code.JavaMaven,
			BuildEnvs: map[string]string{
				"BUILD_MAVEN_BUILT_ARTIFACT": "ruoyi-admin/target/ruoyi-admin.jar",
			},
			Logger: logger,
		}

		err := b.runCNBBuildJob(re, "img:v1")
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if capturedPod == nil {
			t.Fatal("pod was not captured")
		}

		if got := capturedPod.Annotations["cnb-bp-maven-built-module"]; got != "ruoyi-admin" {
			t.Fatalf("annotation cnb-bp-maven-built-module = %q, want %q", got, "ruoyi-admin")
		}
		if got := capturedPod.Annotations["cnb-bp-maven-built-artifact"]; got != "ruoyi-admin/target/ruoyi-admin.jar" {
			t.Fatalf("annotation cnb-bp-maven-built-artifact = %q, want %q", got, "ruoyi-admin/target/ruoyi-admin.jar")
		}

		foundEnv := map[string]string{}
		for _, env := range capturedPod.Spec.Containers[0].Env {
			foundEnv[env.Name] = env.Value
		}
		if got := foundEnv["BP_MAVEN_BUILT_MODULE"]; got != "ruoyi-admin" {
			t.Fatalf("env BP_MAVEN_BUILT_MODULE = %q, want %q", got, "ruoyi-admin")
		}
		if got := foundEnv["BP_MAVEN_BUILT_ARTIFACT"]; got != "ruoyi-admin/target/ruoyi-admin.jar" {
			t.Fatalf("env BP_MAVEN_BUILT_ARTIFACT = %q, want %q", got, "ruoyi-admin/target/ruoyi-admin.jar")
		}
	})
}

// --- config.go: offline mode ---

// capability_id: rainbond.cnb.offline-mode
func TestIsOfflineMode(t *testing.T) {
	t.Run("offline when marker file exists", func(t *testing.T) {
		dir := t.TempDir()
		marker := filepath.Join(dir, "BP_DEPENDENCY_MIRROR")
		os.WriteFile(marker, []byte("file:///grdata/cnb"), 0644)

		orig := offlineMirrorMarker
		offlineMirrorMarker = marker
		defer func() { offlineMirrorMarker = orig }()

		if !isOfflineMode() {
			t.Error("expected offline mode when marker file exists")
		}
	})

	t.Run("online when marker file absent", func(t *testing.T) {
		orig := offlineMirrorMarker
		offlineMirrorMarker = "/nonexistent/path/marker"
		defer func() { offlineMirrorMarker = orig }()

		if isOfflineMode() {
			t.Error("expected online mode when marker file absent")
		}
	})
}

// capability_id: rainbond.cnb.offline-mode
func TestGetCNBBuilderImageOffline(t *testing.T) {
	tests := []struct {
		name           string
		envVar         string
		offline        bool
		registryDomain string
		want           string
	}{
		{
			name: "default online",
			want: DefaultCNBBuilder,
		},
		{
			name:   "env var override",
			envVar: "custom-registry.com/builder:v1",
			want:   "custom-registry.com/builder:v1",
		},
		{
			name:           "offline with goodrain.me",
			offline:        true,
			registryDomain: "goodrain.me",
			want:           "goodrain.me/" + cnbBuilderShortName,
		},
		{
			name:           "offline with custom registry",
			offline:        true,
			registryDomain: "harbor.example.com/lib",
			want:           "harbor.example.com/lib/" + cnbBuilderShortName,
		},
		{
			name:           "env var takes priority over offline",
			envVar:         "custom:v1",
			offline:        true,
			registryDomain: "goodrain.me",
			want:           "custom:v1",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			os.Unsetenv("CNB_BUILDER_IMAGE")
			if tt.envVar != "" {
				os.Setenv("CNB_BUILDER_IMAGE", tt.envVar)
				defer os.Unsetenv("CNB_BUILDER_IMAGE")
			}

			orig := offlineMirrorMarker
			if tt.offline {
				dir := t.TempDir()
				marker := filepath.Join(dir, "marker")
				os.WriteFile(marker, []byte("file:///grdata/cnb"), 0644)
				offlineMirrorMarker = marker
			} else {
				offlineMirrorMarker = "/nonexistent/marker"
			}
			defer func() { offlineMirrorMarker = orig }()

			if tt.registryDomain != "" {
				origDomain := builder.REGISTRYDOMAIN
				builder.REGISTRYDOMAIN = tt.registryDomain
				defer func() { builder.REGISTRYDOMAIN = origDomain }()
			}

			if got := GetCNBBuilderImage(); got != tt.want {
				t.Errorf("GetCNBBuilderImage() = %q; want %q", got, tt.want)
			}
		})
	}
}

// capability_id: rainbond.cnb.offline-mode
func TestGetCNBRunImageOffline(t *testing.T) {
	tests := []struct {
		name           string
		envVar         string
		offline        bool
		registryDomain string
		want           string
	}{
		{
			name: "default online",
			want: DefaultCNBRunImage,
		},
		{
			name:           "offline with goodrain.me",
			offline:        true,
			registryDomain: "goodrain.me",
			want:           "goodrain.me/" + cnbRunShortName,
		},
		{
			name:           "offline with custom registry",
			offline:        true,
			registryDomain: "harbor.example.com/lib",
			want:           "harbor.example.com/lib/" + cnbRunShortName,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			os.Unsetenv("CNB_RUN_IMAGE")
			if tt.envVar != "" {
				os.Setenv("CNB_RUN_IMAGE", tt.envVar)
				defer os.Unsetenv("CNB_RUN_IMAGE")
			}

			orig := offlineMirrorMarker
			if tt.offline {
				dir := t.TempDir()
				marker := filepath.Join(dir, "marker")
				os.WriteFile(marker, []byte("file:///grdata/cnb"), 0644)
				offlineMirrorMarker = marker
			} else {
				offlineMirrorMarker = "/nonexistent/marker"
			}
			defer func() { offlineMirrorMarker = orig }()

			if tt.registryDomain != "" {
				origDomain := builder.REGISTRYDOMAIN
				builder.REGISTRYDOMAIN = tt.registryDomain
				defer func() { builder.REGISTRYDOMAIN = origDomain }()
			}

			if got := GetCNBRunImage(); got != tt.want {
				t.Errorf("GetCNBRunImage() = %q; want %q", got, tt.want)
			}
		})
	}
}

// --- job.go: insecure-registry for run image ---

// capability_id: rainbond.cnb.creator-args-insecure-registry
func TestBuildCreatorArgsInsecureRegistry(t *testing.T) {
	b := &Builder{}

	t.Run("same registry - single insecure-registry", func(t *testing.T) {
		origDomain := builder.REGISTRYDOMAIN
		builder.REGISTRYDOMAIN = "goodrain.me"
		defer func() { builder.REGISTRYDOMAIN = origDomain }()

		dir := newNodeDir(t)
		re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{}}
		args := b.buildCreatorArgs(re, "goodrain.me/app:v1", "goodrain.me/run:v1")

		count := 0
		for _, a := range args {
			if strings.HasPrefix(a, "-insecure-registry=") {
				count++
			}
		}
		if count != 1 {
			t.Errorf("expected 1 insecure-registry flag, got %d; args=%v", count, args)
		}
	})

	t.Run("different registry - only local insecure-registry", func(t *testing.T) {
		origDomain := builder.REGISTRYDOMAIN
		builder.REGISTRYDOMAIN = "goodrain.me"
		defer func() { builder.REGISTRYDOMAIN = origDomain }()

		dir := newNodeDir(t)
		re := &build.Request{SourceDir: dir, BuildEnvs: map[string]string{}}
		args := b.buildCreatorArgs(re, "goodrain.me/app:v1", "registry.cn-hangzhou.aliyuncs.com/goodrain/run:v1")

		insecureFlags := []string{}
		for _, a := range args {
			if strings.HasPrefix(a, "-insecure-registry=") {
				insecureFlags = append(insecureFlags, a)
			}
		}
		if len(insecureFlags) != 1 {
			t.Errorf("expected 1 insecure-registry flag (local only), got %d; flags=%v", len(insecureFlags), insecureFlags)
		}
		for _, f := range insecureFlags {
			if strings.Contains(f, "registry.cn-hangzhou.aliyuncs.com") {
				t.Errorf("external registry should NOT be marked as insecure; flags=%v", insecureFlags)
			}
		}
	})
}