diff --git a/crates/arc-devcontainer/DEVCONTAINER-COMPATIBILITY.md b/crates/arc-devcontainer/DEVCONTAINER-COMPATIBILITY.md index 63482c366..a898e4a54 100644 --- a/crates/arc-devcontainer/DEVCONTAINER-COMPATIBILITY.md +++ b/crates/arc-devcontainer/DEVCONTAINER-COMPATIBILITY.md @@ -8,17 +8,17 @@ Compatibility of `arc-devcontainer` with the [devcontainer.json reference](https | Property | Status | Notes | |---|---|---| -| `name` | No | Parsed by serde (ignored via `#[serde(default)]`); not exposed in `DevcontainerConfig` | -| `forwardPorts` | Yes | Numeric ports extracted into `DevcontainerConfig::forwarded_ports`; string formats ignored | +| `name` | No | Silently ignored by serde (unknown fields are skipped); not exposed in `DevcontainerConfig` | +| `forwardPorts` | Yes | Numeric and string formats (e.g., `"8080:80"`, `"9090"`) extracted into `DevcontainerConfig::forwarded_ports`; merged with compose ports in compose mode | | `portsAttributes` | No | Not parsed | | `otherPortsAttributes` | No | Not parsed | | `updateRemoteUserUID` | No | Not parsed | | `containerEnv` | Yes | Baked into generated Dockerfile as `ENV` directives; also exposed in `DevcontainerConfig::container_env` | | `remoteEnv` | Yes | Merged into `DevcontainerConfig::environment` with variable substitution | -| `containerUser` | Partial | Parsed in `DevcontainerJson::container_user` but not exposed in `DevcontainerConfig` | +| `containerUser` | No | Parsed but unused; not exposed in `DevcontainerConfig` | | `remoteUser` | Yes | Exposed as `DevcontainerConfig::remote_user` | | `userEnvProbe` | No | Not parsed | -| `overrideCommand` | Partial | Parsed in `DevcontainerJson::override_command` but not acted on | +| `overrideCommand` | No | Parsed but unused | | `shutdownAction` | No | Not parsed | ## Image @@ -34,7 +34,7 @@ Compatibility of `arc-devcontainer` with the [devcontainer.json reference](https | `build.dockerfile` | Yes | Resolved relative to devcontainer.json; content read and used as base Dockerfile | | `build.context` | Yes | Resolved with variable substitution; passed as `DevcontainerConfig::build_context` | | `build.args` | Yes | Parsed and exposed in `DevcontainerConfig::build_args` for passing to `docker build --build-arg` | -| `build.target` | No | Not parsed | +| `build.target` | Yes | Parsed with variable substitution; exposed as `DevcontainerConfig::build_target` for passing to `docker build --target` | | `build.cacheFrom` | No | Not parsed | | `build.options` | No | Not parsed | @@ -46,9 +46,9 @@ Compatibility of `arc-devcontainer` with the [devcontainer.json reference](https | `service` | Yes | Required when `dockerComposeFile` is set; used to extract service config | | `runServices` | No | Not parsed; all services assumed | | `shutdownAction` | No | Not parsed | -| `overrideCommand` | Partial | Parsed but not acted on in compose mode | +| `overrideCommand` | No | Parsed but unused | | `workspaceFolder` | Yes | Defaults to `/workspaces/{repo-name}` | -| `workspaceMount` | Partial | Parsed in `DevcontainerJson::workspace_mount` but not used | +| `workspaceMount` | No | Parsed but unused | ## Features diff --git a/crates/arc-devcontainer/INTEGRATION.md b/crates/arc-devcontainer/INTEGRATION.md index 849dba361..2aec58188 100644 --- a/crates/arc-devcontainer/INTEGRATION.md +++ b/crates/arc-devcontainer/INTEGRATION.md @@ -11,6 +11,7 @@ pub struct DevcontainerConfig { pub dockerfile: String, // Generated Dockerfile content pub build_context: PathBuf, // Directory for docker build pub build_args: HashMap, // docker build --build-arg flags + pub build_target: Option, // docker build --target pub initialize_commands: Vec, // Host-side pre-build commands pub on_create_commands: Vec, // Container after first creation pub post_create_commands: Vec, // Container post-creation setup @@ -36,9 +37,9 @@ pub struct DevcontainerConfig { ### Environment Variables -`config.environment` contains the merged `remoteEnv` values (with variables already substituted). `config.container_env` contains `containerEnv` values (also baked into the generated Dockerfile as `ENV` directives). +`config.environment` contains the `remoteEnv` values (with variables already substituted). These are runtime-only environment variables, not baked into the Dockerfile. `config.container_env` contains `containerEnv` values (baked into the generated Dockerfile as `ENV` directives). -- Pass `config.environment` as environment variables when creating the sandbox. +- Pass `config.environment` as runtime environment variables when starting the sandbox. - `containerEnv` values are already in the Dockerfile; `config.container_env` is available for reference. ### Workspace Folder @@ -187,4 +188,4 @@ async fn create_sandbox_from_devcontainer(repo_path: &Path) -> Result { - **No `updateContentCommand`**: This lifecycle hook is not parsed. - **No `postAttachCommand`**: Not parsed. Attach-time hooks would need to run on each user session connection. - **`${containerEnv:VAR}` not supported**: Variable substitution only covers host-side variables. Container-side env vars require a running container. -- **Port forwarding is numeric only**: String port formats (e.g., `"label:3000"`) in `forwardPorts` are filtered out; only numeric values are extracted. +- **Port forwarding**: Both numeric and string port formats (e.g., `"8080:80"`, `"9090"`) are supported in `forwardPorts`. In compose mode, `forwardPorts` are merged with compose service ports. diff --git a/crates/arc-devcontainer/src/dockerfile.rs b/crates/arc-devcontainer/src/dockerfile.rs index 311df5d03..6779c2b73 100644 --- a/crates/arc-devcontainer/src/dockerfile.rs +++ b/crates/arc-devcontainer/src/dockerfile.rs @@ -7,7 +7,6 @@ pub fn generate( base_dockerfile: &str, feature_layers: &[FeatureLayer], container_env: &Option>, - remote_env: &Option>, remote_user: Option<&str>, ) -> String { let mut sections: Vec = Vec::new(); @@ -19,27 +18,17 @@ pub fn generate( sections.push(layer.dockerfile_snippet.clone()); } - // containerEnv first, then remoteEnv (remoteEnv overrides on key collision) - let mut merged_env: HashMap<&String, &String> = HashMap::new(); if let Some(env) = container_env { - for (k, v) in env { - merged_env.insert(k, v); + if !env.is_empty() { + let mut keys: Vec<&String> = env.keys().collect(); + keys.sort(); + let env_lines: Vec = keys + .iter() + .map(|k| format!("ENV {}={}", k, env[*k])) + .collect(); + sections.push(env_lines.join("\n")); } } - if let Some(env) = remote_env { - for (k, v) in env { - merged_env.insert(k, v); - } - } - if !merged_env.is_empty() { - let mut keys: Vec<&&String> = merged_env.keys().collect(); - keys.sort(); - let env_lines: Vec = keys - .iter() - .map(|k| format!("ENV {}={}", k, merged_env[*k])) - .collect(); - sections.push(env_lines.join("\n")); - } if let Some(user) = remote_user { sections.push(format!("USER {}", user)); @@ -64,7 +53,7 @@ mod tests { #[test] fn base_image_only() { - let result = generate("FROM ubuntu:22.04", &[], &None, &None, None); + let result = generate("FROM ubuntu:22.04", &[], &None, None); assert_eq!( result, "# Generated by arc-devcontainer\n\nFROM ubuntu:22.04\n" @@ -74,7 +63,7 @@ mod tests { #[test] fn base_dockerfile_preserved_as_is() { let base = "FROM ubuntu:22.04\nRUN apt-get update\nRUN apt-get install -y curl"; - let result = generate(base, &[], &None, &None, None); + let result = generate(base, &[], &None, None); assert_eq!( result, "# Generated by arc-devcontainer\n\nFROM ubuntu:22.04\nRUN apt-get update\nRUN apt-get install -y curl\n" @@ -87,7 +76,7 @@ mod tests { make_layer("node", "node-1", "RUN install-node.sh"), make_layer("python", "python-1", "RUN install-python.sh"), ]; - let result = generate("FROM ubuntu:22.04", &layers, &None, &None, None); + let result = generate("FROM ubuntu:22.04", &layers, &None, None); assert_eq!( result, "# Generated by arc-devcontainer\n\n\ @@ -103,7 +92,7 @@ mod tests { env.insert("ZEBRA".to_string(), "stripes".to_string()); env.insert("APPLE".to_string(), "red".to_string()); env.insert("MANGO".to_string(), "yellow".to_string()); - let result = generate("FROM alpine", &[], &None, &Some(env), None); + let result = generate("FROM alpine", &[], &Some(env), None); assert_eq!( result, "# Generated by arc-devcontainer\n\n\ @@ -116,7 +105,7 @@ mod tests { #[test] fn with_remote_user() { - let result = generate("FROM alpine", &[], &None, &None, Some("vscode")); + let result = generate("FROM alpine", &[], &None, Some("vscode")); assert_eq!( result, "# Generated by arc-devcontainer\n\n\ @@ -131,7 +120,7 @@ mod tests { let mut env = HashMap::new(); env.insert("PATH".to_string(), "/usr/local/bin".to_string()); env.insert("HOME".to_string(), "/home/vscode".to_string()); - let result = generate("FROM ubuntu:22.04", &layers, &None, &Some(env), Some("vscode")); + let result = generate("FROM ubuntu:22.04", &layers, &Some(env), Some("vscode")); assert_eq!( result, "# Generated by arc-devcontainer\n\n\ @@ -145,7 +134,7 @@ mod tests { #[test] fn empty_feature_layers_no_extra_blank_lines() { - let result = generate("FROM alpine", &[], &None, &None, Some("dev")); + let result = generate("FROM alpine", &[], &None, Some("dev")); assert_eq!( result, "# Generated by arc-devcontainer\n\n\ @@ -157,7 +146,7 @@ mod tests { #[test] fn empty_env_map_treated_as_none() { let env = HashMap::new(); - let result = generate("FROM alpine", &[], &None, &Some(env), None); + let result = generate("FROM alpine", &[], &Some(env), None); assert_eq!( result, "# Generated by arc-devcontainer\n\nFROM alpine\n" @@ -173,7 +162,7 @@ mod tests { \n\ FROM ubuntu:22.04\n\ COPY --from=builder /app/bin /usr/local/bin"; - let result = generate(base, &[], &None, &None, None); + let result = generate(base, &[], &None, None); assert!(result.starts_with("# Generated by arc-devcontainer\n\n")); assert!(result.contains("FROM ubuntu:22.04 AS builder")); assert!(result.contains("COPY --from=builder /app/bin /usr/local/bin")); @@ -184,7 +173,7 @@ mod tests { fn container_env_only() { let mut cenv = HashMap::new(); cenv.insert("DEBIAN_FRONTEND".to_string(), "noninteractive".to_string()); - let result = generate("FROM alpine", &[], &Some(cenv), &None, None); + let result = generate("FROM alpine", &[], &Some(cenv), None); assert_eq!( result, "# Generated by arc-devcontainer\n\n\ @@ -194,17 +183,14 @@ mod tests { } #[test] - fn container_env_and_remote_env_merged() { + fn container_env_with_multiple_keys() { let mut cenv = HashMap::new(); - cenv.insert("SHARED".to_string(), "from_container".to_string()); - cenv.insert("ONLY_CONTAINER".to_string(), "yes".to_string()); - let mut renv = HashMap::new(); - renv.insert("SHARED".to_string(), "from_remote".to_string()); - renv.insert("ONLY_REMOTE".to_string(), "yes".to_string()); - let result = generate("FROM alpine", &[], &Some(cenv), &Some(renv), None); - // remoteEnv overrides containerEnv on collision - assert!(result.contains("ENV SHARED=from_remote")); - assert!(result.contains("ENV ONLY_CONTAINER=yes")); - assert!(result.contains("ENV ONLY_REMOTE=yes")); + cenv.insert("ALPHA".to_string(), "first".to_string()); + cenv.insert("BETA".to_string(), "second".to_string()); + cenv.insert("GAMMA".to_string(), "third".to_string()); + let result = generate("FROM alpine", &[], &Some(cenv), None); + assert!(result.contains("ENV ALPHA=first")); + assert!(result.contains("ENV BETA=second")); + assert!(result.contains("ENV GAMMA=third")); } } diff --git a/crates/arc-devcontainer/src/lib.rs b/crates/arc-devcontainer/src/lib.rs index 27fd5f8d3..0c242366c 100644 --- a/crates/arc-devcontainer/src/lib.rs +++ b/crates/arc-devcontainer/src/lib.rs @@ -27,6 +27,8 @@ pub struct DevcontainerConfig { pub build_context: PathBuf, /// Build arguments (docker build --build-arg) pub build_args: HashMap, + /// Multi-stage build target (docker build --target) + pub build_target: Option, /// Run on host before build pub initialize_commands: Vec, /// Run in container after first creation (before updateContentCommand) @@ -173,6 +175,7 @@ impl DevcontainerResolver { dockerfile, build_context: compose_base_dir.to_path_buf(), build_args: HashMap::new(), + build_target: None, initialize_commands: Self::collect_commands(&devcontainer.initialize_command, &vars), on_create_commands: Self::collect_commands( &devcontainer.on_create_command, @@ -193,14 +196,22 @@ impl DevcontainerResolver { .clone() .or(compose_config.user), workspace_folder, - forwarded_ports: compose_config.ports, + forwarded_ports: { + let mut ports = compose_config.ports; + for port in Self::parse_forward_ports(&devcontainer.forward_ports) { + if !ports.contains(&port) { + ports.push(port); + } + } + ports + }, compose_files: compose_paths, compose_service: Some(service_name), }); } // Image or Dockerfile mode - let (base_dockerfile, build_context, build_args) = if let Some(build) = + let (base_dockerfile, build_context, build_args, build_target) = if let Some(build) = &devcontainer.build { let context_dir = build @@ -223,13 +234,17 @@ impl DevcontainerResolver { .iter() .map(|(k, v)| (k.clone(), variables::substitute(v, &vars))) .collect(); - (content, context_dir, args) + let target = build + .target + .as_ref() + .map(|t| variables::substitute(t, &vars)); + (content, context_dir, args, target) } else { let image = devcontainer .image .as_deref() .unwrap_or("mcr.microsoft.com/devcontainers/base:ubuntu"); - (format!("FROM {image}"), base_dir.to_path_buf(), HashMap::new()) + (format!("FROM {image}"), base_dir.to_path_buf(), HashMap::new(), None) }; // Features @@ -244,7 +259,6 @@ impl DevcontainerResolver { &base_dockerfile, &feature_layers, &devcontainer.container_env, - &devcontainer.remote_env, devcontainer.remote_user.as_deref(), ); @@ -255,19 +269,13 @@ impl DevcontainerResolver { } } - let forwarded_ports = devcontainer - .forward_ports - .iter() - .filter_map(|p| match p { - serde_json::Value::Number(n) => n.as_u64().map(|n| n as u16), - _ => None, - }) - .collect(); + let forwarded_ports = Self::parse_forward_ports(&devcontainer.forward_ports); Ok(DevcontainerConfig { dockerfile: dockerfile_content, build_context, build_args, + build_target, initialize_commands: Self::collect_commands(&devcontainer.initialize_command, &vars), on_create_commands: Self::collect_commands(&devcontainer.on_create_command, &vars), post_create_commands: Self::collect_commands(&devcontainer.post_create_command, &vars), @@ -374,4 +382,22 @@ impl DevcontainerResolver { } } } + + fn parse_forward_ports(ports: &[serde_json::Value]) -> Vec { + ports + .iter() + .filter_map(|p| match p { + serde_json::Value::Number(n) => n.as_u64().map(|n| n as u16), + serde_json::Value::String(s) => { + let s = s.split('/').next().unwrap_or(s); // strip protocol + if let Some((_host, container)) = s.split_once(':') { + container.parse::().ok() + } else { + s.parse::().ok() + } + } + _ => None, + }) + .collect() + } } diff --git a/crates/arc-devcontainer/src/types.rs b/crates/arc-devcontainer/src/types.rs index 0e7e83157..5609486be 100644 --- a/crates/arc-devcontainer/src/types.rs +++ b/crates/arc-devcontainer/src/types.rs @@ -73,6 +73,9 @@ pub struct BuildConfig { /// Build arguments #[serde(default)] pub args: HashMap, + + /// Multi-stage build target + pub target: Option, } /// A reference to one or more Docker Compose files. @@ -189,7 +192,8 @@ mod tests { "build": { "dockerfile": "Dockerfile", "context": "..", - "args": {"VARIANT": "3.9"} + "args": {"VARIANT": "3.9"}, + "target": "dev" } }"#; let config: DevcontainerJson = serde_json::from_str(json).unwrap(); @@ -197,6 +201,7 @@ mod tests { assert_eq!(build.dockerfile.as_deref(), Some("Dockerfile")); assert_eq!(build.context.as_deref(), Some("..")); assert_eq!(build.args.get("VARIANT").map(String::as_str), Some("3.9")); + assert_eq!(build.target.as_deref(), Some("dev")); } #[test] diff --git a/crates/arc-devcontainer/tests/e2e.rs b/crates/arc-devcontainer/tests/e2e.rs index 67741864f..369a48780 100644 --- a/crates/arc-devcontainer/tests/e2e.rs +++ b/crates/arc-devcontainer/tests/e2e.rs @@ -36,9 +36,8 @@ async fn realistic_python_project() { Some("1") ); - // Gap 3: remoteEnv overrides containerEnv on key collision (PYTHONUNBUFFERED) - // In the Dockerfile, remoteEnv value "yes" should win over containerEnv value "1" - assert!(config.dockerfile.contains("ENV PYTHONUNBUFFERED=yes")); + // 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), @@ -92,11 +91,12 @@ async fn realistic_compose_project() { // Gap 4: image from base compose file (override doesn't change image) assert!(config.dockerfile.contains("FROM node:20-bookworm")); - // Gap 4: ports merged from both files (base: 3000, 9229; override: 4000) + // 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_eq!(config.forwarded_ports.len(), 3); + assert!(config.forwarded_ports.contains(&8080)); + assert_eq!(config.forwarded_ports.len(), 4); // Gap 4: environment merged from both compose files + remoteEnv assert_eq!( @@ -207,6 +207,84 @@ async fn build_args_empty_in_image_and_compose_modes() { 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() { diff --git a/crates/arc-devcontainer/tests/fixtures/compose-mode/.devcontainer/devcontainer.json b/crates/arc-devcontainer/tests/fixtures/compose-mode/.devcontainer/devcontainer.json index ab1450dc0..9e7be272c 100644 --- a/crates/arc-devcontainer/tests/fixtures/compose-mode/.devcontainer/devcontainer.json +++ b/crates/arc-devcontainer/tests/fixtures/compose-mode/.devcontainer/devcontainer.json @@ -3,6 +3,7 @@ "service": "app", "workspaceFolder": "/workspace", "remoteUser": "node", + "forwardPorts": [3000, 5173], "postCreateCommand": "npm install", "remoteEnv": { "NODE_ENV": "development" diff --git a/crates/arc-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/devcontainer.json b/crates/arc-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/devcontainer.json index 0e4fc0533..4de5408de 100644 --- a/crates/arc-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/devcontainer.json +++ b/crates/arc-devcontainer/tests/fixtures/dockerfile-mode/.devcontainer/devcontainer.json @@ -3,7 +3,8 @@ "build": { "dockerfile": "Dockerfile", "context": "..", - "args": {"NODE_VERSION": "20"} + "args": {"NODE_VERSION": "20"}, + "target": "dev" }, "remoteUser": "developer", "postCreateCommand": "npm install", diff --git a/crates/arc-devcontainer/tests/fixtures/image-only/.devcontainer/devcontainer.json b/crates/arc-devcontainer/tests/fixtures/image-only/.devcontainer/devcontainer.json index b12afca57..061bd2055 100644 --- a/crates/arc-devcontainer/tests/fixtures/image-only/.devcontainer/devcontainer.json +++ b/crates/arc-devcontainer/tests/fixtures/image-only/.devcontainer/devcontainer.json @@ -1,6 +1,6 @@ { "image": "mcr.microsoft.com/devcontainers/base:ubuntu", - "forwardPorts": [3000, 8080], + "forwardPorts": [3000, "8080:80", "9090"], "remoteUser": "vscode", "remoteEnv": { "EDITOR": "code" diff --git a/crates/arc-devcontainer/tests/fixtures/realistic-compose/.devcontainer/devcontainer.json b/crates/arc-devcontainer/tests/fixtures/realistic-compose/.devcontainer/devcontainer.json index 4d4fce058..4ac94f01d 100644 --- a/crates/arc-devcontainer/tests/fixtures/realistic-compose/.devcontainer/devcontainer.json +++ b/crates/arc-devcontainer/tests/fixtures/realistic-compose/.devcontainer/devcontainer.json @@ -3,6 +3,7 @@ "service": "app", "workspaceFolder": "/workspace", "remoteUser": "node", + "forwardPorts": [8080], "containerEnv": { "TERM": "xterm-256color", "EDITOR": "vim" diff --git a/crates/arc-devcontainer/tests/integration.rs b/crates/arc-devcontainer/tests/integration.rs index 3c37cb0ea..003e5f3e6 100644 --- a/crates/arc-devcontainer/tests/integration.rs +++ b/crates/arc-devcontainer/tests/integration.rs @@ -15,7 +15,7 @@ async fn resolve_image_only() { assert!(config.dockerfile.contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu")); assert_eq!(config.remote_user.as_deref(), Some("vscode")); - assert_eq!(config.forwarded_ports, vec![3000, 8080]); + assert_eq!(config.forwarded_ports, vec![3000, 80, 9090]); assert_eq!(config.environment.get("EDITOR").map(String::as_str), Some("code")); assert_eq!(config.workspace_folder, "/workspaces/image-only"); assert!(config.compose_files.is_empty()); @@ -53,6 +53,9 @@ async fn resolve_dockerfile_mode() { // build.args assert_eq!(config.build_args.get("NODE_VERSION").map(String::as_str), Some("20")); + + // build.target + assert_eq!(config.build_target.as_deref(), Some("dev")); } #[tokio::test] @@ -68,8 +71,8 @@ async fn resolve_compose_mode() { assert_eq!(config.compose_files.len(), 1); assert_eq!(config.compose_service.as_deref(), Some("app")); - // Ports come from compose + remoteEnv merged - assert_eq!(config.forwarded_ports, vec![3000, 9229]); + // Ports come from compose + forwardPorts merged + assert_eq!(config.forwarded_ports, vec![3000, 9229, 5173]); // Environment merged from compose + remoteEnv assert_eq!(config.environment.get("NODE_ENV").map(String::as_str), Some("development"));