Files
coder/agent/agentcontainers/devcontainercli_test.go
Spike Curtis bddb808b25 chore: arrange imports in a standard way (#21452)
Fixes all our Go file imports to match the preferred spec that we've _mostly_ been using. For example:

```
import (
	"context"
	"time"

	"github.com/prometheus/client_golang/prometheus"
	"golang.org/x/xerrors"
	"gopkg.in/natefinch/lumberjack.v2"

	"cdr.dev/slog/v3"
	"github.com/coder/coder/v2/codersdk/agentsdk"
	"github.com/coder/serpent"
)
```

3 groups: standard library, 3rd partly libs, Coder libs.

This PR makes the change across the codebase. The PR in the stack above modifies our formatting to maintain this state of affairs, and is a separate PR so it's possible to review that one in detail.
2026-01-08 15:24:11 +04:00

774 lines
25 KiB
Go

package agentcontainers_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/ory/dockertest/v3"
"github.com/ory/dockertest/v3/docker"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/agent/agentcontainers"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty"
"github.com/coder/coder/v2/testutil"
)
func TestDevcontainerCLI_ArgsAndParsing(t *testing.T) {
t.Parallel()
testExePath, err := os.Executable()
require.NoError(t, err, "get test executable path")
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
t.Run("Up", func(t *testing.T) {
t.Parallel()
tests := []struct {
name string
logFile string
workspace string
config string
opts []agentcontainers.DevcontainerCLIUpOptions
wantArgs string
wantError bool
wantContainerID bool // If true, expect a container ID even when wantError is true.
}{
{
name: "success",
logFile: "up.log",
workspace: "/test/workspace",
wantArgs: "up --log-format json --workspace-folder /test/workspace",
wantError: false,
wantContainerID: true,
},
{
name: "success with config",
logFile: "up.log",
workspace: "/test/workspace",
config: "/test/config.json",
wantArgs: "up --log-format json --workspace-folder /test/workspace --config /test/config.json",
wantError: false,
wantContainerID: true,
},
{
name: "already exists",
logFile: "up-already-exists.log",
workspace: "/test/workspace",
wantArgs: "up --log-format json --workspace-folder /test/workspace",
wantError: false,
wantContainerID: true,
},
{
name: "docker error",
logFile: "up-error-docker.log",
workspace: "/test/workspace",
wantArgs: "up --log-format json --workspace-folder /test/workspace",
wantError: true,
wantContainerID: false,
},
{
name: "bad outcome",
logFile: "up-error-bad-outcome.log",
workspace: "/test/workspace",
wantArgs: "up --log-format json --workspace-folder /test/workspace",
wantError: true,
wantContainerID: false,
},
{
name: "does not exist",
logFile: "up-error-does-not-exist.log",
workspace: "/test/workspace",
wantArgs: "up --log-format json --workspace-folder /test/workspace",
wantError: true,
wantContainerID: false,
},
{
name: "with remove existing container",
logFile: "up.log",
workspace: "/test/workspace",
opts: []agentcontainers.DevcontainerCLIUpOptions{
agentcontainers.WithRemoveExistingContainer(),
},
wantArgs: "up --log-format json --workspace-folder /test/workspace --remove-existing-container",
wantError: false,
wantContainerID: true,
},
{
// This test verifies that when a lifecycle script like
// postCreateCommand fails, the CLI returns both an error
// and a container ID. The caller can then proceed with
// agent injection into the created container.
name: "lifecycle script failure with container",
logFile: "up-error-lifecycle-script.log",
workspace: "/test/workspace",
wantArgs: "up --log-format json --workspace-folder /test/workspace",
wantError: true,
wantContainerID: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
testExecer := &testDevcontainerExecer{
testExePath: testExePath,
wantArgs: tt.wantArgs,
wantError: tt.wantError,
logFile: filepath.Join("testdata", "devcontainercli", "parse", tt.logFile),
}
dccli := agentcontainers.NewDevcontainerCLI(logger, testExecer)
containerID, err := dccli.Up(ctx, tt.workspace, tt.config, tt.opts...)
if tt.wantError {
assert.Error(t, err, "want error")
} else {
assert.NoError(t, err, "want no error")
}
if tt.wantContainerID {
assert.NotEmpty(t, containerID, "expected non-empty container ID")
} else {
assert.Empty(t, containerID, "expected empty container ID")
}
})
}
})
t.Run("Exec", func(t *testing.T) {
t.Parallel()
tests := []struct {
name string
workspaceFolder string
configPath string
cmd string
cmdArgs []string
opts []agentcontainers.DevcontainerCLIExecOptions
wantArgs string
wantError bool
}{
{
name: "simple command",
workspaceFolder: "/test/workspace",
configPath: "",
cmd: "echo",
cmdArgs: []string{"hello"},
wantArgs: "exec --workspace-folder /test/workspace echo hello",
wantError: false,
},
{
name: "command with multiple args",
workspaceFolder: "/test/workspace",
configPath: "/test/config.json",
cmd: "ls",
cmdArgs: []string{"-la", "/workspace"},
wantArgs: "exec --workspace-folder /test/workspace --config /test/config.json ls -la /workspace",
wantError: false,
},
{
name: "empty command args",
workspaceFolder: "/test/workspace",
configPath: "",
cmd: "bash",
cmdArgs: nil,
wantArgs: "exec --workspace-folder /test/workspace bash",
wantError: false,
},
{
name: "workspace not found",
workspaceFolder: "/nonexistent/workspace",
configPath: "",
cmd: "echo",
cmdArgs: []string{"test"},
wantArgs: "exec --workspace-folder /nonexistent/workspace echo test",
wantError: true,
},
{
name: "with container ID",
workspaceFolder: "/test/workspace",
configPath: "",
cmd: "echo",
cmdArgs: []string{"hello"},
opts: []agentcontainers.DevcontainerCLIExecOptions{agentcontainers.WithExecContainerID("test-container-123")},
wantArgs: "exec --workspace-folder /test/workspace --container-id test-container-123 echo hello",
wantError: false,
},
{
name: "with container ID and config",
workspaceFolder: "/test/workspace",
configPath: "/test/config.json",
cmd: "bash",
cmdArgs: []string{"-c", "ls -la"},
opts: []agentcontainers.DevcontainerCLIExecOptions{agentcontainers.WithExecContainerID("my-container")},
wantArgs: "exec --workspace-folder /test/workspace --config /test/config.json --container-id my-container bash -c ls -la",
wantError: false,
},
{
name: "with container ID and output capture",
workspaceFolder: "/test/workspace",
configPath: "",
cmd: "cat",
cmdArgs: []string{"/etc/hostname"},
opts: []agentcontainers.DevcontainerCLIExecOptions{
agentcontainers.WithExecContainerID("test-container-789"),
},
wantArgs: "exec --workspace-folder /test/workspace --container-id test-container-789 cat /etc/hostname",
wantError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
testExecer := &testDevcontainerExecer{
testExePath: testExePath,
wantArgs: tt.wantArgs,
wantError: tt.wantError,
logFile: "", // Exec doesn't need log file parsing
}
dccli := agentcontainers.NewDevcontainerCLI(logger, testExecer)
err := dccli.Exec(ctx, tt.workspaceFolder, tt.configPath, tt.cmd, tt.cmdArgs, tt.opts...)
if tt.wantError {
assert.Error(t, err, "want error")
} else {
assert.NoError(t, err, "want no error")
}
})
}
})
t.Run("ReadConfig", func(t *testing.T) {
t.Parallel()
tests := []struct {
name string
logFile string
workspaceFolder string
configPath string
opts []agentcontainers.DevcontainerCLIReadConfigOptions
wantArgs string
wantError bool
wantConfig agentcontainers.DevcontainerConfig
}{
{
name: "WithCoderCustomization",
logFile: "read-config-with-coder-customization.log",
workspaceFolder: "/test/workspace",
configPath: "",
wantArgs: "read-configuration --include-merged-configuration --workspace-folder /test/workspace",
wantError: false,
wantConfig: agentcontainers.DevcontainerConfig{
MergedConfiguration: agentcontainers.DevcontainerMergedConfiguration{
Customizations: agentcontainers.DevcontainerMergedCustomizations{
Coder: []agentcontainers.CoderCustomization{
{
DisplayApps: map[codersdk.DisplayApp]bool{
codersdk.DisplayAppVSCodeDesktop: true,
codersdk.DisplayAppWebTerminal: true,
},
},
{
DisplayApps: map[codersdk.DisplayApp]bool{
codersdk.DisplayAppVSCodeInsiders: true,
codersdk.DisplayAppWebTerminal: false,
},
},
},
},
},
},
},
{
name: "WithoutCoderCustomization",
logFile: "read-config-without-coder-customization.log",
workspaceFolder: "/test/workspace",
configPath: "/test/config.json",
wantArgs: "read-configuration --include-merged-configuration --workspace-folder /test/workspace --config /test/config.json",
wantError: false,
wantConfig: agentcontainers.DevcontainerConfig{
MergedConfiguration: agentcontainers.DevcontainerMergedConfiguration{
Customizations: agentcontainers.DevcontainerMergedCustomizations{
Coder: nil,
},
},
},
},
{
name: "FileNotFound",
logFile: "read-config-error-not-found.log",
workspaceFolder: "/nonexistent/workspace",
configPath: "",
wantArgs: "read-configuration --include-merged-configuration --workspace-folder /nonexistent/workspace",
wantError: true,
wantConfig: agentcontainers.DevcontainerConfig{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
testExecer := &testDevcontainerExecer{
testExePath: testExePath,
wantArgs: tt.wantArgs,
wantError: tt.wantError,
logFile: filepath.Join("testdata", "devcontainercli", "readconfig", tt.logFile),
}
dccli := agentcontainers.NewDevcontainerCLI(logger, testExecer)
config, err := dccli.ReadConfig(ctx, tt.workspaceFolder, tt.configPath, []string{}, tt.opts...)
if tt.wantError {
assert.Error(t, err, "want error")
assert.Equal(t, agentcontainers.DevcontainerConfig{}, config, "expected empty config on error")
} else {
assert.NoError(t, err, "want no error")
assert.Equal(t, tt.wantConfig, config, "expected config to match")
}
})
}
})
}
// TestDevcontainerCLI_WithOutput tests that WithUpOutput and WithExecOutput capture CLI
// logs to provided writers.
func TestDevcontainerCLI_WithOutput(t *testing.T) {
t.Parallel()
// Prepare test executable and logger.
testExePath, err := os.Executable()
require.NoError(t, err, "get test executable path")
t.Run("Up", func(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("Windows uses CRLF line endings, golden file is LF")
}
// Buffers to capture stdout and stderr.
outBuf := &bytes.Buffer{}
errBuf := &bytes.Buffer{}
// Simulate CLI execution with a standard up.log file.
wantArgs := "up --log-format json --workspace-folder /test/workspace"
testExecer := &testDevcontainerExecer{
testExePath: testExePath,
wantArgs: wantArgs,
wantError: false,
logFile: filepath.Join("testdata", "devcontainercli", "parse", "up.log"),
}
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
dccli := agentcontainers.NewDevcontainerCLI(logger, testExecer)
// Call Up with WithUpOutput to capture CLI logs.
ctx := testutil.Context(t, testutil.WaitMedium)
containerID, err := dccli.Up(ctx, "/test/workspace", "", agentcontainers.WithUpOutput(outBuf, errBuf))
require.NoError(t, err, "Up should succeed")
require.NotEmpty(t, containerID, "expected non-empty container ID")
// Read expected log content.
expLog, err := os.ReadFile(filepath.Join("testdata", "devcontainercli", "parse", "up.golden"))
require.NoError(t, err, "reading expected log file")
// Verify stdout buffer contains the CLI logs and stderr is empty.
assert.Equal(t, string(expLog), outBuf.String(), "stdout buffer should match CLI logs")
assert.Empty(t, errBuf.String(), "stderr buffer should be empty on success")
})
t.Run("Exec", func(t *testing.T) {
t.Parallel()
logFile := filepath.Join(t.TempDir(), "exec.log")
f, err := os.Create(logFile)
require.NoError(t, err, "create exec log file")
_, err = f.WriteString("exec command log\n")
require.NoError(t, err, "write to exec log file")
err = f.Close()
require.NoError(t, err, "close exec log file")
// Buffers to capture stdout and stderr.
outBuf := &bytes.Buffer{}
errBuf := &bytes.Buffer{}
// Simulate CLI execution for exec command with container ID.
wantArgs := "exec --workspace-folder /test/workspace --container-id test-container-456 echo hello"
testExecer := &testDevcontainerExecer{
testExePath: testExePath,
wantArgs: wantArgs,
wantError: false,
logFile: logFile,
}
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
dccli := agentcontainers.NewDevcontainerCLI(logger, testExecer)
// Call Exec with WithExecOutput and WithContainerID to capture any command output.
ctx := testutil.Context(t, testutil.WaitMedium)
err = dccli.Exec(ctx, "/test/workspace", "", "echo", []string{"hello"},
agentcontainers.WithExecContainerID("test-container-456"),
agentcontainers.WithExecOutput(outBuf, errBuf),
)
require.NoError(t, err, "Exec should succeed")
assert.NotEmpty(t, outBuf.String(), "stdout buffer should not be empty for exec with log file")
assert.Empty(t, errBuf.String(), "stderr buffer should be empty")
})
}
// testDevcontainerExecer implements the agentexec.Execer interface for testing.
type testDevcontainerExecer struct {
testExePath string
wantArgs string
wantError bool
logFile string
}
// CommandContext returns a test binary command that simulates devcontainer responses.
func (e *testDevcontainerExecer) CommandContext(ctx context.Context, name string, args ...string) *exec.Cmd {
// Only handle "devcontainer" commands.
if name != "devcontainer" {
// For non-devcontainer commands, use a standard execer.
return agentexec.DefaultExecer.CommandContext(ctx, name, args...)
}
// Create a command that runs the test binary with special flags
// that tell it to simulate a devcontainer command.
testArgs := []string{
"-test.run=TestDevcontainerHelperProcess",
"--",
name,
}
testArgs = append(testArgs, args...)
//nolint:gosec // This is a test binary, so we don't need to worry about command injection.
cmd := exec.CommandContext(ctx, e.testExePath, testArgs...)
// Set this environment variable so the child process knows it's the helper.
cmd.Env = append(os.Environ(),
"TEST_DEVCONTAINER_WANT_HELPER_PROCESS=1",
"TEST_DEVCONTAINER_WANT_ARGS="+e.wantArgs,
"TEST_DEVCONTAINER_WANT_ERROR="+fmt.Sprintf("%v", e.wantError),
"TEST_DEVCONTAINER_LOG_FILE="+e.logFile,
)
return cmd
}
// PTYCommandContext returns a PTY command.
func (*testDevcontainerExecer) PTYCommandContext(_ context.Context, name string, args ...string) *pty.Cmd {
// This method shouldn't be called for our devcontainer tests.
panic("PTYCommandContext not expected in devcontainer tests")
}
// This is a special test helper that is executed as a subprocess.
// It simulates the behavior of the devcontainer CLI.
//
//nolint:revive,paralleltest // This is a test helper function.
func TestDevcontainerHelperProcess(t *testing.T) {
// If not called by the test as a helper process, do nothing.
if os.Getenv("TEST_DEVCONTAINER_WANT_HELPER_PROCESS") != "1" {
return
}
helperArgs := flag.Args()
if len(helperArgs) < 1 {
fmt.Fprintf(os.Stderr, "No command\n")
os.Exit(2)
}
if helperArgs[0] != "devcontainer" {
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", helperArgs[0])
os.Exit(2)
}
// Verify arguments against expected arguments and skip
// "devcontainer", it's not included in the input args.
wantArgs := os.Getenv("TEST_DEVCONTAINER_WANT_ARGS")
gotArgs := strings.Join(helperArgs[1:], " ")
if gotArgs != wantArgs {
fmt.Fprintf(os.Stderr, "Arguments don't match.\nWant: %q\nGot: %q\n",
wantArgs, gotArgs)
os.Exit(2)
}
logFilePath := os.Getenv("TEST_DEVCONTAINER_LOG_FILE")
if logFilePath != "" {
// Read and output log file for commands that need it (like "up")
output, err := os.ReadFile(logFilePath)
if err != nil {
fmt.Fprintf(os.Stderr, "Reading log file %s failed: %v\n", logFilePath, err)
os.Exit(2)
}
_, _ = io.Copy(os.Stdout, bytes.NewReader(output))
}
if os.Getenv("TEST_DEVCONTAINER_WANT_ERROR") == "true" {
os.Exit(1)
}
os.Exit(0)
}
// TestDockerDevcontainerCLI tests the DevcontainerCLI component with real Docker containers.
// This test verifies that containers can be created and recreated using the actual
// devcontainer CLI and Docker. It is skipped by default and can be run with:
//
// CODER_TEST_USE_DOCKER=1 go test ./agent/agentcontainers -run TestDockerDevcontainerCLI
//
// The test requires Docker to be installed and running.
func TestDockerDevcontainerCLI(t *testing.T) {
t.Parallel()
if os.Getenv("CODER_TEST_USE_DOCKER") != "1" {
t.Skip("skipping Docker test; set CODER_TEST_USE_DOCKER=1 to run")
}
if _, err := exec.LookPath("devcontainer"); err != nil {
t.Fatal("this test requires the devcontainer CLI: npm install -g @devcontainers/cli")
}
// Connect to Docker.
pool, err := dockertest.NewPool("")
require.NoError(t, err, "connect to Docker")
t.Run("ContainerLifecycle", func(t *testing.T) {
t.Parallel()
// Set up workspace directory with a devcontainer configuration.
workspaceFolder := t.TempDir()
configPath := setupDevcontainerWorkspace(t, workspaceFolder)
// Use a long timeout because container operations are slow.
ctx := testutil.Context(t, testutil.WaitLong)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
// Create the devcontainer CLI under test.
dccli := agentcontainers.NewDevcontainerCLI(logger, agentexec.DefaultExecer)
// Create a container.
firstID, err := dccli.Up(ctx, workspaceFolder, configPath)
require.NoError(t, err, "create container")
require.NotEmpty(t, firstID, "container ID should not be empty")
defer removeDevcontainerByID(t, pool, firstID)
// Verify container exists.
firstContainer, found := findDevcontainerByID(t, pool, firstID)
require.True(t, found, "container should exist")
// Remember the container creation time.
firstCreated := firstContainer.Created
// Recreate the container.
secondID, err := dccli.Up(ctx, workspaceFolder, configPath, agentcontainers.WithRemoveExistingContainer())
require.NoError(t, err, "recreate container")
require.NotEmpty(t, secondID, "recreated container ID should not be empty")
defer removeDevcontainerByID(t, pool, secondID)
// Verify the new container exists and is different.
secondContainer, found := findDevcontainerByID(t, pool, secondID)
require.True(t, found, "recreated container should exist")
// Verify it's a different container by checking creation time.
secondCreated := secondContainer.Created
assert.NotEqual(t, firstCreated, secondCreated, "recreated container should have different creation time")
// Verify the first container is removed by the recreation.
_, found = findDevcontainerByID(t, pool, firstID)
assert.False(t, found, "first container should be removed")
})
}
// setupDevcontainerWorkspace prepares a test environment with a minimal
// devcontainer.json configuration and returns the path to the config file.
func setupDevcontainerWorkspace(t *testing.T, workspaceFolder string) string {
t.Helper()
// Create the devcontainer directory structure.
devcontainerDir := filepath.Join(workspaceFolder, ".devcontainer")
err := os.MkdirAll(devcontainerDir, 0o755)
require.NoError(t, err, "create .devcontainer directory")
// Write a minimal configuration with test labels for identification.
configPath := filepath.Join(devcontainerDir, "devcontainer.json")
content := `{
"image": "alpine:latest",
"containerEnv": {
"TEST_CONTAINER": "true"
},
"runArgs": ["--label=com.coder.test=devcontainercli", "--label=` + agentcontainers.DevcontainerIsTestRunLabel + `=true"]
}`
err = os.WriteFile(configPath, []byte(content), 0o600)
require.NoError(t, err, "create devcontainer.json file")
return configPath
}
// findDevcontainerByID locates a container by its ID and verifies it has our
// test label. Returns the container and whether it was found.
func findDevcontainerByID(t *testing.T, pool *dockertest.Pool, id string) (*docker.Container, bool) {
t.Helper()
container, err := pool.Client.InspectContainer(id)
if err != nil {
t.Logf("Inspect container failed: %v", err)
return nil, false
}
require.Equal(t, "devcontainercli", container.Config.Labels["com.coder.test"], "sanity check failed: container should have the test label")
return container, true
}
// removeDevcontainerByID safely cleans up a test container by ID, verifying
// it has our test label before removal to prevent accidental deletion.
func removeDevcontainerByID(t *testing.T, pool *dockertest.Pool, id string) {
t.Helper()
errNoSuchContainer := &docker.NoSuchContainer{}
// Check if the container has the expected label.
container, err := pool.Client.InspectContainer(id)
if err != nil {
if errors.As(err, &errNoSuchContainer) {
t.Logf("Container %s not found, skipping removal", id)
return
}
require.NoError(t, err, "inspect container")
}
require.Equal(t, "devcontainercli", container.Config.Labels["com.coder.test"], "sanity check failed: container should have the test label")
t.Logf("Removing container with ID: %s", id)
err = pool.Client.RemoveContainer(docker.RemoveContainerOptions{
ID: container.ID,
Force: true,
RemoveVolumes: true,
})
if err != nil && !errors.As(err, &errNoSuchContainer) {
assert.NoError(t, err, "remove container failed")
}
}
func TestDevcontainerFeatures_OptionsAsEnvs(t *testing.T) {
t.Parallel()
realConfigJSON := `{
"mergedConfiguration": {
"features": {
"./code-server": {
"port": 9090
},
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"moby": "false"
}
}
}
}`
var realConfig agentcontainers.DevcontainerConfig
err := json.Unmarshal([]byte(realConfigJSON), &realConfig)
require.NoError(t, err, "unmarshal JSON payload")
tests := []struct {
name string
features agentcontainers.DevcontainerFeatures
want []string
}{
{
name: "code-server feature",
features: agentcontainers.DevcontainerFeatures{
"./code-server": map[string]any{
"port": 9090,
},
},
want: []string{
"FEATURE_CODE_SERVER_OPTION_PORT=9090",
},
},
{
name: "docker-in-docker feature",
features: agentcontainers.DevcontainerFeatures{
"ghcr.io/devcontainers/features/docker-in-docker:2": map[string]any{
"moby": "false",
},
},
want: []string{
"FEATURE_DOCKER_IN_DOCKER_OPTION_MOBY=false",
},
},
{
name: "multiple features with multiple options",
features: agentcontainers.DevcontainerFeatures{
"./code-server": map[string]any{
"port": 9090,
"password": "secret",
},
"ghcr.io/devcontainers/features/docker-in-docker:2": map[string]any{
"moby": "false",
"docker-dash-compose-version": "v2",
},
},
want: []string{
"FEATURE_CODE_SERVER_OPTION_PASSWORD=secret",
"FEATURE_CODE_SERVER_OPTION_PORT=9090",
"FEATURE_DOCKER_IN_DOCKER_OPTION_DOCKER_DASH_COMPOSE_VERSION=v2",
"FEATURE_DOCKER_IN_DOCKER_OPTION_MOBY=false",
},
},
{
name: "feature with non-map value (should be ignored)",
features: agentcontainers.DevcontainerFeatures{
"./code-server": map[string]any{
"port": 9090,
},
"./invalid-feature": "not-a-map",
},
want: []string{
"FEATURE_CODE_SERVER_OPTION_PORT=9090",
},
},
{
name: "real config example",
features: realConfig.MergedConfiguration.Features,
want: []string{
"FEATURE_CODE_SERVER_OPTION_PORT=9090",
"FEATURE_DOCKER_IN_DOCKER_OPTION_MOBY=false",
},
},
{
name: "empty features",
features: agentcontainers.DevcontainerFeatures{},
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := tt.features.OptionsAsEnvs()
if diff := cmp.Diff(tt.want, got); diff != "" {
require.Failf(t, "OptionsAsEnvs() mismatch (-want +got):\n%s", diff)
}
})
}
}