fabro/lib/crates/fabro-devcontainer/tests/it/e2e.rs
Bryan Helmkamp 5eeacd7864 fmt
2026-04-11 11:27:46 -04:00

594 lines
20 KiB
Rust

//! End-to-end tests exercising full resolver pipeline with realistic
//! devcontainer configs. These tests verify the 4 critical gaps are wired
//! correctly through the entire stack:
//! 1. onCreateCommand
//! 2. build.args
//! 3. containerEnv
//! 4. dockerComposeFile array
use fabro_devcontainer::{Command, DevcontainerResolver};
use super::helpers::fixture_path;
/// Realistic Python project: Dockerfile + build.args + containerEnv +
/// onCreateCommand + remoteEnv Verifies all 4 gaps work together in a single
/// config.
#[tokio::test]
async fn realistic_python_project() {
let config = DevcontainerResolver::resolve(&fixture_path("realistic-python"))
.await
.unwrap();
// Gap 2: build.args exposed for docker build --build-arg
assert_eq!(
config.build_args.get("PYTHON_VERSION").map(String::as_str),
Some("3.12")
);
// Gap 3: containerEnv baked into Dockerfile as ENV directives
assert!(config.dockerfile.contains("ENV PIP_NO_CACHE_DIR=1"));
assert!(config.dockerfile.contains("ENV PYTHONDONTWRITEBYTECODE=1"));
assert_eq!(
config
.container_env
.get("PIP_NO_CACHE_DIR")
.map(String::as_str),
Some("1")
);
// After fix: only containerEnv is baked into Dockerfile (remoteEnv is
// runtime-only)
assert!(config.dockerfile.contains("ENV PYTHONUNBUFFERED=1"));
// environment HashMap gets the remoteEnv value
assert_eq!(
config
.environment
.get("PYTHONUNBUFFERED")
.map(String::as_str),
Some("yes")
);
// Gap 3: remoteEnv with variable substitution
assert_eq!(
config.environment.get("PYTHONPATH").map(String::as_str),
Some("/workspaces/realistic-python/src")
);
// Gap 1: onCreateCommand parsed and exposed
assert_eq!(config.on_create_commands.len(), 1);
assert!(
matches!(&config.on_create_commands[0], Command::Shell(s) if s == "pip install -r requirements.txt")
);
// Other lifecycle commands still work
assert_eq!(config.post_create_commands.len(), 1);
assert!(
matches!(&config.post_create_commands[0], Command::Shell(s) if s == "python manage.py migrate")
);
assert_eq!(config.post_start_commands.len(), 1);
assert!(
matches!(&config.post_start_commands[0], Command::Shell(s) if s == "python manage.py runserver 0.0.0.0:8000")
);
// Dockerfile content is the actual file (not generated FROM line)
assert!(config.dockerfile.contains("ARG PYTHON_VERSION=3.11"));
assert!(config.dockerfile.contains("apt-get update"));
// Standard fields
assert_eq!(config.remote_user.as_deref(), Some("developer"));
assert_eq!(config.forwarded_ports, vec![8000, 5432]);
assert!(config.compose_files.is_empty());
}
/// Realistic compose project: multi-file compose + containerEnv +
/// onCreateCommand + remoteEnv Verifies gaps 1, 3, 4 work together in compose
/// mode.
#[tokio::test]
async fn realistic_compose_project() {
let config = DevcontainerResolver::resolve(&fixture_path("realistic-compose"))
.await
.unwrap();
// Gap 4: multiple compose files resolved
assert_eq!(config.compose_files.len(), 2);
assert_eq!(config.compose_service.as_deref(), Some("app"));
// Gap 4: image from base compose file (override doesn't change image)
assert!(config.dockerfile.contains("FROM node:20-bookworm"));
// Ports merged from both compose files (base: 3000, 9229; override: 4000) +
// forwardPorts (8080)
assert!(config.forwarded_ports.contains(&3000));
assert!(config.forwarded_ports.contains(&9229));
assert!(config.forwarded_ports.contains(&4000));
assert!(config.forwarded_ports.contains(&8080));
assert_eq!(config.forwarded_ports.len(), 4);
// Gap 4: environment merged from both compose files + remoteEnv
assert_eq!(
config.environment.get("NODE_ENV").map(String::as_str),
Some("development")
);
assert_eq!(
config.environment.get("DEBUG").map(String::as_str),
Some("true")
);
assert_eq!(
config.environment.get("LOG_LEVEL").map(String::as_str),
Some("verbose")
);
// remoteEnv values
assert_eq!(
config.environment.get("DATABASE_URL").map(String::as_str),
Some("postgres://postgres:devpass@db:5432/myapp_dev")
);
assert_eq!(
config.environment.get("REDIS_URL").map(String::as_str),
Some("redis://redis:6379")
);
// Gap 3: containerEnv exposed on config
assert_eq!(
config.container_env.get("TERM").map(String::as_str),
Some("xterm-256color")
);
assert_eq!(
config.container_env.get("EDITOR").map(String::as_str),
Some("vim")
);
// Gap 1: onCreateCommand in compose mode
assert_eq!(config.on_create_commands.len(), 1);
assert!(matches!(&config.on_create_commands[0], Command::Shell(s) if s == "npm ci"));
// Other lifecycle commands
assert_eq!(config.post_create_commands.len(), 1);
assert!(
matches!(&config.post_create_commands[0], Command::Shell(s) if s == "npm run db:migrate")
);
assert_eq!(config.post_start_commands.len(), 1);
assert!(matches!(&config.post_start_commands[0], Command::Shell(s) if s == "npm run dev"));
// User comes from compose (node) but remoteUser also set to node
assert_eq!(config.remote_user.as_deref(), Some("node"));
assert_eq!(config.workspace_folder, "/workspace");
}
/// All lifecycle commands in different forms: string, array, object, and the
/// new onCreateCommand.
#[tokio::test]
async fn all_lifecycle_command_forms() {
let config = DevcontainerResolver::resolve(&fixture_path("all-lifecycle"))
.await
.unwrap();
// initializeCommand as string
assert_eq!(config.initialize_commands.len(), 1);
assert!(matches!(&config.initialize_commands[0], Command::Shell(s) if s == "echo pre-build"));
// Gap 1: onCreateCommand as array
assert_eq!(config.on_create_commands.len(), 1);
assert!(
matches!(&config.on_create_commands[0], Command::Args(args) if args == &["make", "setup"])
);
// postCreateCommand as object (parallel)
assert_eq!(config.post_create_commands.len(), 1);
assert!(matches!(&config.post_create_commands[0], Command::Parallel(map) if map.len() == 2));
// postStartCommand as string
assert_eq!(config.post_start_commands.len(), 1);
assert!(matches!(&config.post_start_commands[0], Command::Shell(s) if s == "echo started"));
}
/// Verify containerEnv doesn't pollute the environment HashMap (which is
/// remoteEnv only).
#[tokio::test]
async fn container_env_separate_from_environment() {
let config = DevcontainerResolver::resolve(&fixture_path("realistic-python"))
.await
.unwrap();
// container_env has containerEnv values
assert!(config.container_env.contains_key("PYTHONDONTWRITEBYTECODE"));
assert!(config.container_env.contains_key("PIP_NO_CACHE_DIR"));
// environment only has remoteEnv values (not containerEnv-only keys)
assert!(!config.environment.contains_key("PYTHONDONTWRITEBYTECODE"));
assert!(!config.environment.contains_key("PIP_NO_CACHE_DIR"));
// PYTHONUNBUFFERED is in both - environment gets remoteEnv value
assert_eq!(
config
.environment
.get("PYTHONUNBUFFERED")
.map(String::as_str),
Some("yes")
);
}
/// Verify build_args default to empty in non-dockerfile modes.
#[tokio::test]
async fn build_args_empty_in_image_and_compose_modes() {
let image_config = DevcontainerResolver::resolve(&fixture_path("image-only"))
.await
.unwrap();
assert!(image_config.build_args.is_empty());
let compose_config = DevcontainerResolver::resolve(&fixture_path("compose-mode"))
.await
.unwrap();
assert!(compose_config.build_args.is_empty());
}
/// Verify build_target is None for image-only and compose modes.
#[tokio::test]
async fn build_target_none_in_image_and_compose_modes() {
let image_config = DevcontainerResolver::resolve(&fixture_path("image-only"))
.await
.unwrap();
assert!(image_config.build_target.is_none());
let compose_config = DevcontainerResolver::resolve(&fixture_path("compose-mode"))
.await
.unwrap();
assert!(compose_config.build_target.is_none());
}
/// Gap 1: remoteEnv values must NOT appear as ENV directives in the generated
/// Dockerfile. Only containerEnv should be baked in.
#[tokio::test]
async fn remote_env_excluded_from_dockerfile() {
// image-only fixture has remoteEnv: {"EDITOR": "code"} and containerEnv:
// {"DEBIAN_FRONTEND": "noninteractive"}
let config = DevcontainerResolver::resolve(&fixture_path("image-only"))
.await
.unwrap();
// containerEnv IS in the Dockerfile
assert!(
config
.dockerfile
.contains("ENV DEBIAN_FRONTEND=noninteractive")
);
// remoteEnv is NOT in the Dockerfile
assert!(!config.dockerfile.contains("EDITOR=code"));
// remoteEnv IS in the environment HashMap (runtime-only)
assert_eq!(
config.environment.get("EDITOR").map(String::as_str),
Some("code")
);
}
/// Gap 2: forwardPorts in compose mode are merged with compose service ports,
/// with deduplication.
#[tokio::test]
async fn forward_ports_merged_and_deduped_in_compose() {
// compose-mode fixture has compose ports [3000, 9229] and forwardPorts [3000,
// 5173]
let config = DevcontainerResolver::resolve(&fixture_path("compose-mode"))
.await
.unwrap();
// 3000 appears in both compose ports and forwardPorts — should NOT be
// duplicated
assert_eq!(config.forwarded_ports, vec![3000, 9229, 5173]);
}
/// Gap 3: build.target is parsed and exposed in dockerfile mode.
#[tokio::test]
async fn build_target_in_dockerfile_mode() {
let config = DevcontainerResolver::resolve(&fixture_path("dockerfile-mode"))
.await
.unwrap();
assert_eq!(config.build_target.as_deref(), Some("dev"));
}
/// Gap 4: forwardPorts string formats ("host:container", "port") are parsed
/// correctly.
#[tokio::test]
async fn forward_ports_string_formats() {
// image-only fixture has forwardPorts: [3000, "8080:80", "9090"]
let config = DevcontainerResolver::resolve(&fixture_path("image-only"))
.await
.unwrap();
// 3000 is a plain number
assert!(config.forwarded_ports.contains(&3000));
// "8080:80" extracts container port 80
assert!(config.forwarded_ports.contains(&80));
// "9090" is parsed as a plain port number
assert!(config.forwarded_ports.contains(&9090));
// host port 8080 should NOT appear (only container port matters)
assert!(!config.forwarded_ports.contains(&8080));
assert_eq!(config.forwarded_ports, vec![3000, 80, 9090]);
}
/// Verify compose_files is empty for non-compose modes.
#[tokio::test]
async fn compose_files_empty_in_non_compose_modes() {
let image_config = DevcontainerResolver::resolve(&fixture_path("image-only"))
.await
.unwrap();
assert!(image_config.compose_files.is_empty());
let df_config = DevcontainerResolver::resolve(&fixture_path("dockerfile-mode"))
.await
.unwrap();
assert!(df_config.compose_files.is_empty());
}
/// Verify on_create_commands defaults to empty when not specified.
#[tokio::test]
async fn on_create_commands_empty_when_not_specified() {
let config = DevcontainerResolver::resolve(&fixture_path("variables"))
.await
.unwrap();
assert!(config.on_create_commands.is_empty());
}
/// Verify container_env defaults to empty when not specified.
#[tokio::test]
async fn container_env_empty_when_not_specified() {
let config = DevcontainerResolver::resolve(&fixture_path("variables"))
.await
.unwrap();
assert!(config.container_env.is_empty());
}
// === Gap e2e tests: local features exercising dependsOn, containerEnv,
// lifecycle hooks ===
/// Gap 5: Local path feature references are resolved through the full pipeline.
#[tokio::test]
async fn local_feature_refs_resolved() {
let config = DevcontainerResolver::resolve(&fixture_path("local-features"))
.await
.unwrap();
// Base image preserved
assert!(
config
.dockerfile
.contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu")
);
// Feature install.sh snippets are in the Dockerfile
assert!(config.dockerfile.contains("node-feature"));
assert!(config.dockerfile.contains("python-feature"));
// Node feature option "version=20" passed as env var
assert!(config.dockerfile.contains("export VERSION=\"20\""));
}
/// Gap 1: dependsOn auto-injects missing features through the full pipeline.
/// node-feature dependsOn ./base-utils which is NOT listed in devcontainer.json
/// features.
#[tokio::test]
async fn depends_on_auto_injects_missing_feature() {
let config = DevcontainerResolver::resolve(&fixture_path("local-features"))
.await
.unwrap();
// base-utils was auto-injected and its install.sh snippet is in the Dockerfile
assert!(config.dockerfile.contains("base-utils"));
// base-utils must appear before node-feature (dependency ordering)
let base_pos = config.dockerfile.find("base-utils").unwrap();
let node_pos = config.dockerfile.find("node-feature").unwrap();
assert!(
base_pos < node_pos,
"base-utils (pos {base_pos}) should appear before node-feature (pos {node_pos})"
);
}
/// Gap 2: Feature containerEnv is merged into the Dockerfile and config.
#[tokio::test]
async fn feature_container_env_merged() {
let config = DevcontainerResolver::resolve(&fixture_path("local-features"))
.await
.unwrap();
// Feature containerEnv values baked into Dockerfile
assert!(config.dockerfile.contains("ENV NODE_INSTALLED=true"));
assert!(
config
.dockerfile
.contains("ENV NODE_PATH=/usr/local/lib/node_modules")
);
assert!(config.dockerfile.contains("ENV PYTHON_INSTALLED=true"));
assert!(config.dockerfile.contains("ENV BASE_UTILS_INSTALLED=true"));
// Devcontainer.json containerEnv also present
assert!(config.dockerfile.contains("ENV DEVCONTAINER=true"));
// All values in config.container_env
assert_eq!(
config
.container_env
.get("NODE_INSTALLED")
.map(String::as_str),
Some("true")
);
assert_eq!(
config
.container_env
.get("PYTHON_INSTALLED")
.map(String::as_str),
Some("true")
);
assert_eq!(
config
.container_env
.get("BASE_UTILS_INSTALLED")
.map(String::as_str),
Some("true")
);
assert_eq!(
config.container_env.get("DEVCONTAINER").map(String::as_str),
Some("true")
);
}
/// Gap 3: Feature lifecycle hooks are appended after devcontainer.json
/// lifecycle commands.
#[tokio::test]
async fn feature_lifecycle_hooks_appended() {
let config = DevcontainerResolver::resolve(&fixture_path("local-features"))
.await
.unwrap();
// onCreateCommand: devcontainer.json first, then features
// devcontainer.json: "echo devcontainer-setup"
// base-utils: "echo base-utils-setup"
// node-feature: "echo node-setup"
assert!(config.on_create_commands.len() >= 2);
assert!(
matches!(&config.on_create_commands[0], Command::Shell(s) if s == "echo devcontainer-setup")
);
// Feature on_create_commands appear after devcontainer.json's
let feature_on_create: Vec<&str> = config.on_create_commands[1..]
.iter()
.filter_map(|cmd| match cmd {
Command::Shell(s) => Some(s.as_str()),
_ => None,
})
.collect();
assert!(feature_on_create.contains(&"echo base-utils-setup"));
assert!(feature_on_create.contains(&"echo node-setup"));
// postCreateCommand: devcontainer.json first, then python-feature
assert!(config.post_create_commands.len() >= 2);
assert!(
matches!(&config.post_create_commands[0], Command::Shell(s) if s == "echo devcontainer-post-create")
);
let feature_post_create: Vec<&str> = config.post_create_commands[1..]
.iter()
.filter_map(|cmd| match cmd {
Command::Shell(s) => Some(s.as_str()),
_ => None,
})
.collect();
assert!(feature_post_create.contains(&"echo python-post-create"));
// postStartCommand: only node-feature contributes (no devcontainer.json
// postStartCommand)
assert!(!config.post_start_commands.is_empty());
let post_start: Vec<&str> = config
.post_start_commands
.iter()
.filter_map(|cmd| match cmd {
Command::Shell(s) => Some(s.as_str()),
_ => None,
})
.collect();
assert!(post_start.contains(&"echo node-started"));
}
/// Fix 1: Shorthand version syntax "1.21" is normalized to {"version": "1.21"}.
#[tokio::test]
async fn feature_shorthand_version_syntax() {
let config = DevcontainerResolver::resolve(&fixture_path("feature-options"))
.await
.unwrap();
// "1.21" string should become version=1.21 env var
assert!(
config.dockerfile.contains("export VERSION=\"1.21\""),
"shorthand string \"1.21\" should set VERSION env var, got:\n{}",
config.dockerfile,
);
}
/// Fix 2: Hyphenated option IDs are converted to valid env var names
/// (node-version → NODE_VERSION).
#[tokio::test]
async fn feature_option_id_hyphen_to_underscore() {
let config = DevcontainerResolver::resolve(&fixture_path("feature-options"))
.await
.unwrap();
// node-version default "none" should export as NODE_VERSION (not NODE-VERSION)
assert!(
config.dockerfile.contains("export NODE_VERSION=\"none\""),
"hyphenated option 'node-version' should become NODE_VERSION env var, got:\n{}",
config.dockerfile,
);
assert!(
!config.dockerfile.contains("NODE-VERSION"),
"NODE-VERSION (with hyphen) should not appear in Dockerfile",
);
}
/// Fix 3: _REMOTE_USER and related env vars are emitted in feature install
/// snippets.
#[tokio::test]
async fn feature_install_user_env_vars() {
let config = DevcontainerResolver::resolve(&fixture_path("feature-options"))
.await
.unwrap();
// remoteUser is "developer", so _REMOTE_USER should be "developer"
assert!(
config.dockerfile.contains("_REMOTE_USER=\"developer\""),
"_REMOTE_USER should be set to remoteUser value, got:\n{}",
config.dockerfile,
);
assert!(
config.dockerfile.contains("_CONTAINER_USER=\"root\""),
"_CONTAINER_USER should always be root",
);
assert!(
config
.dockerfile
.contains("_REMOTE_USER_HOME=\"/home/developer\""),
"_REMOTE_USER_HOME should be /home/developer",
);
assert!(
config.dockerfile.contains("_CONTAINER_USER_HOME=\"/root\""),
"_CONTAINER_USER_HOME should always be /root",
);
}
/// Fix 3: _REMOTE_USER defaults to root when remoteUser is not set.
#[tokio::test]
async fn feature_install_user_env_vars_default_root() {
let config = DevcontainerResolver::resolve(&fixture_path("local-features"))
.await
.unwrap();
// local-features has remoteUser: "vscode"
assert!(
config.dockerfile.contains("_REMOTE_USER=\"vscode\""),
"_REMOTE_USER should be set to vscode, got:\n{}",
config.dockerfile,
);
assert!(
config
.dockerfile
.contains("_REMOTE_USER_HOME=\"/home/vscode\""),
"_REMOTE_USER_HOME should be /home/vscode",
);
}
/// Gap 2+3: Feature ordering affects both containerEnv and lifecycle hook
/// collection. python-feature installsAfter node-feature, so node's env/hooks
/// come first.
#[tokio::test]
async fn feature_ordering_preserved_in_env_and_hooks() {
let config = DevcontainerResolver::resolve(&fixture_path("local-features"))
.await
.unwrap();
// In the Dockerfile, node-feature layers come before python-feature layers
let node_layer_pos = config.dockerfile.find("node-feature").unwrap();
let python_layer_pos = config.dockerfile.find("python-feature").unwrap();
assert!(
node_layer_pos < python_layer_pos,
"node-feature (pos {node_layer_pos}) should be installed before python-feature (pos {python_layer_pos})"
);
}