diff --git a/crates/arc-devcontainer/src/features.rs b/crates/arc-devcontainer/src/features.rs index b32f5afd1..51770b66c 100644 --- a/crates/arc-devcontainer/src/features.rs +++ b/crates/arc-devcontainer/src/features.rs @@ -540,7 +540,15 @@ pub async fn resolve_features( return Ok(ResolvedFeatures::default()); } - let tmp_dir = std::env::temp_dir().join("devcontainer-features"); + let unique_id = format!( + "devcontainer-features-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let tmp_dir = std::env::temp_dir().join(unique_id); tokio::fs::create_dir_all(&tmp_dir).await.map_err(|e| { DevcontainerError::Feature(format!("failed to create temp dir: {e}")) })?; diff --git a/crates/arc-devcontainer/tests/e2e.rs b/crates/arc-devcontainer/tests/e2e.rs index 369a48780..753c5465b 100644 --- a/crates/arc-devcontainer/tests/e2e.rs +++ b/crates/arc-devcontainer/tests/e2e.rs @@ -316,3 +316,132 @@ async fn container_env_empty_when_not_specified() { .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")); +} + +/// 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})" + ); +} diff --git a/crates/arc-devcontainer/tests/fixtures/local-features/.devcontainer/base-utils/devcontainer-feature.json b/crates/arc-devcontainer/tests/fixtures/local-features/.devcontainer/base-utils/devcontainer-feature.json new file mode 100644 index 000000000..0141f558d --- /dev/null +++ b/crates/arc-devcontainer/tests/fixtures/local-features/.devcontainer/base-utils/devcontainer-feature.json @@ -0,0 +1,8 @@ +{ + "id": "base-utils", + "version": "1.0.0", + "containerEnv": { + "BASE_UTILS_INSTALLED": "true" + }, + "onCreateCommand": "echo base-utils-setup" +} diff --git a/crates/arc-devcontainer/tests/fixtures/local-features/.devcontainer/base-utils/install.sh b/crates/arc-devcontainer/tests/fixtures/local-features/.devcontainer/base-utils/install.sh new file mode 100644 index 000000000..0dec9c182 --- /dev/null +++ b/crates/arc-devcontainer/tests/fixtures/local-features/.devcontainer/base-utils/install.sh @@ -0,0 +1,2 @@ +#!/bin/sh +echo "Installing base-utils" diff --git a/crates/arc-devcontainer/tests/fixtures/local-features/.devcontainer/devcontainer.json b/crates/arc-devcontainer/tests/fixtures/local-features/.devcontainer/devcontainer.json new file mode 100644 index 000000000..918b7b59c --- /dev/null +++ b/crates/arc-devcontainer/tests/fixtures/local-features/.devcontainer/devcontainer.json @@ -0,0 +1,13 @@ +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "./node-feature": {"version": "20"}, + "./python-feature": {} + }, + "remoteUser": "vscode", + "containerEnv": { + "DEVCONTAINER": "true" + }, + "onCreateCommand": "echo devcontainer-setup", + "postCreateCommand": "echo devcontainer-post-create" +} diff --git a/crates/arc-devcontainer/tests/fixtures/local-features/.devcontainer/node-feature/devcontainer-feature.json b/crates/arc-devcontainer/tests/fixtures/local-features/.devcontainer/node-feature/devcontainer-feature.json new file mode 100644 index 000000000..fa130bd8f --- /dev/null +++ b/crates/arc-devcontainer/tests/fixtures/local-features/.devcontainer/node-feature/devcontainer-feature.json @@ -0,0 +1,21 @@ +{ + "id": "node-feature", + "version": "1.0.0", + "options": { + "version": { + "type": "string", + "default": "lts", + "description": "Node.js version" + } + }, + "dependsOn": { + "./base-utils": {} + }, + "installsAfter": [], + "containerEnv": { + "NODE_INSTALLED": "true", + "NODE_PATH": "/usr/local/lib/node_modules" + }, + "onCreateCommand": "echo node-setup", + "postStartCommand": "echo node-started" +} diff --git a/crates/arc-devcontainer/tests/fixtures/local-features/.devcontainer/node-feature/install.sh b/crates/arc-devcontainer/tests/fixtures/local-features/.devcontainer/node-feature/install.sh new file mode 100644 index 000000000..cc0121c4b --- /dev/null +++ b/crates/arc-devcontainer/tests/fixtures/local-features/.devcontainer/node-feature/install.sh @@ -0,0 +1,2 @@ +#!/bin/sh +echo "Installing node ${VERSION}" diff --git a/crates/arc-devcontainer/tests/fixtures/local-features/.devcontainer/python-feature/devcontainer-feature.json b/crates/arc-devcontainer/tests/fixtures/local-features/.devcontainer/python-feature/devcontainer-feature.json new file mode 100644 index 000000000..b4063b4e6 --- /dev/null +++ b/crates/arc-devcontainer/tests/fixtures/local-features/.devcontainer/python-feature/devcontainer-feature.json @@ -0,0 +1,9 @@ +{ + "id": "python-feature", + "version": "1.0.0", + "installsAfter": ["./node-feature"], + "containerEnv": { + "PYTHON_INSTALLED": "true" + }, + "postCreateCommand": "echo python-post-create" +} diff --git a/crates/arc-devcontainer/tests/fixtures/local-features/.devcontainer/python-feature/install.sh b/crates/arc-devcontainer/tests/fixtures/local-features/.devcontainer/python-feature/install.sh new file mode 100644 index 000000000..944a488fc --- /dev/null +++ b/crates/arc-devcontainer/tests/fixtures/local-features/.devcontainer/python-feature/install.sh @@ -0,0 +1,2 @@ +#!/bin/sh +echo "Installing python"