mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-05 08:10:39 +00:00
Add e2e tests for 5 devcontainer spec gaps using local features
Create a local-features fixture with 3 local feature directories that exercise all gap fixes through the full DevcontainerResolver pipeline: - dependsOn auto-injection (base-utils not in features map, injected) - feature containerEnv merge (3 features contribute env vars) - feature lifecycle hooks (appended after devcontainer.json commands) - local path feature references (all features use ./ paths) - install ordering (dependsOn/installsAfter edges respected) Also fix a concurrency bug: resolve_features now uses a unique temp dir per invocation instead of a shared /tmp/devcontainer-features/ that caused parallel test corruption. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9748d2e3fe
commit
ba2b6faef3
9 changed files with 195 additions and 1 deletions
|
|
@ -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}"))
|
||||
})?;
|
||||
|
|
|
|||
|
|
@ -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})"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"id": "base-utils",
|
||||
"version": "1.0.0",
|
||||
"containerEnv": {
|
||||
"BASE_UTILS_INSTALLED": "true"
|
||||
},
|
||||
"onCreateCommand": "echo base-utils-setup"
|
||||
}
|
||||
2
crates/arc-devcontainer/tests/fixtures/local-features/.devcontainer/base-utils/install.sh
vendored
Normal file
2
crates/arc-devcontainer/tests/fixtures/local-features/.devcontainer/base-utils/install.sh
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
#!/bin/sh
|
||||
echo "Installing base-utils"
|
||||
13
crates/arc-devcontainer/tests/fixtures/local-features/.devcontainer/devcontainer.json
vendored
Normal file
13
crates/arc-devcontainer/tests/fixtures/local-features/.devcontainer/devcontainer.json
vendored
Normal file
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
2
crates/arc-devcontainer/tests/fixtures/local-features/.devcontainer/node-feature/install.sh
vendored
Normal file
2
crates/arc-devcontainer/tests/fixtures/local-features/.devcontainer/node-feature/install.sh
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
#!/bin/sh
|
||||
echo "Installing node ${VERSION}"
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"id": "python-feature",
|
||||
"version": "1.0.0",
|
||||
"installsAfter": ["./node-feature"],
|
||||
"containerEnv": {
|
||||
"PYTHON_INSTALLED": "true"
|
||||
},
|
||||
"postCreateCommand": "echo python-post-create"
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
#!/bin/sh
|
||||
echo "Installing python"
|
||||
Loading…
Add table
Reference in a new issue