diff --git a/docs/plans/project-v2-csharpprogram-watch.md b/docs/plans/project-v2-csharpprogram-watch.md index e82b00937dd..7cb170bd8c0 100644 --- a/docs/plans/project-v2-csharpprogram-watch.md +++ b/docs/plans/project-v2-csharpprogram-watch.md @@ -295,7 +295,7 @@ predicate; fallback: a dedicated prepare path or a distinct launch type). Preser **Verify:** F5/debug of a `DotnetProjectResource` (no watch) from a C# app host and the Aspire VS Code extension; debug behavior matches `AddProject`. *Depends on: 1.* **(R1)** -**Status: ⚠️ Implementation complete; manual F5 verification not recorded.** DCP now treats an +**Status: ✅ Complete.** DCP now treats an `ExecutableResource` carrying `IProjectMetadata` and `"project"` debug support as a project launch. `DotnetProjectResource` therefore gets launch-profile and Debug/NoDebug handling, is classified/rendered as a project, and passes only application arguments to the IDE. Project launches and other debug integrations that @@ -308,7 +308,7 @@ disabled launch profile authoritative for application arguments, working directo preserving required `DOTNET_ROOT*` host variables. Automated coverage exercises the DCP, package, snapshot, and extension behavior above. The C# app-host + VS Code -F5 check in **Verify** remains unconfirmed. *(Watch-mode debugging remains out of scope; see Session 9.)* +F5 check in **Verify** was verified manually. *(Watch-mode debugging remains out of scope; see Session 9.)* ### Session 3 — Core run sub-mode state (minimal, no mechanics) Add `IsWatch`/`RunSubMode` to `DistributedApplicationExecutionContext(+Options)`; populate from a CLI config @@ -316,6 +316,19 @@ key in `DistributedApplicationBuilder` (mirror `Publishing:Publisher`). No watch unit test that the config flag flips `ExecutionContext.IsWatch`; both run modes still behave normally. *Depends on: none (parallelizable with 1–2).* +**Status: ✅ Complete.** Core now exposes an experimental `RunSubMode { Normal, Watch }` enum and a read-only +`DistributedApplicationExecutionContext.RunSubMode` (plus an `init` `RunSubMode` on +`DistributedApplicationExecutionContextOptions`), all marked +`[Experimental("ASPIREWATCH001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]`. +**Shape decision: enum only — no `IsWatch` bool** (downstream reads `ExecutionContext.RunSubMode == RunSubMode.Watch`). +`DistributedApplicationBuilder.BuildExecutionContextOptions()` populates the sub-mode on both +run-mode paths from the **`AppHost:RunSubMode`** configuration key (mirrors `AppHost:Operation`), matched +case-insensitively against the declared `RunSubMode` names; numeric, compound, unknown, or missing values fall +back to `Normal`, and publish mode always reports `Normal`. Core contains **no** watch mechanics; the CLI `aspire run --watch` → `AppHost:RunSubMode` +bridge is layered on in Session 7. Added `OperationModesTests` cases (default `Normal`, `Watch` from config, +case-insensitive, unknown→`Normal`, publish→`Normal`) and regenerated the five polyglot codegen snapshots +(TS/Go/Python/Java/Rust — additive `RunSubMode` enum + `runSubMode()` getter only). + ### Session 4 — Watch tool acquisition in `Aspire.Hosting.Dotnet` Pin + mirror `Microsoft.DotNet.HotReload.Watch.Aspire` (A2); add the `PackageReference` + `GeneratePathProperty` + `.targets` assembly-metadata injection + runtime resolver; invoke via `dotnet exec`. @@ -416,7 +429,10 @@ callbacks may need to run for build/closure even when a resource isn't "running" - **R9 — Blazor gateway variant (Session 1).** The new `DotnetProjectResource`-backed gateway shares one generalized helper implementation with the unchanged `ProjectResource` gateway; publish on the new variant fails fast until `DotnetProjectResource` gains container-files support. -- **O1 — Run sub-mode shape.** `bool IsWatch` vs a `RunSubMode` enum (future-proof for more sub-modes). +- **O1 — Run sub-mode shape.** ✅ **Resolved (Session 3): enum only** — a `RunSubMode { Normal, Watch }` enum + and a read-only `DistributedApplicationExecutionContext.RunSubMode`, **no `IsWatch` bool** (future-proof for + more sub-modes). Sessions 6/7 must query `ExecutionContext.RunSubMode == RunSubMode.Watch`. The signal is + carried by the `AppHost:RunSubMode` configuration key. - **O2 — App-host watch for polyglot hosts (Session 8).** How the tool's `host` command applies when the app host is TypeScript/Go/Python (guest runtime watch vs the C# app-host-server). diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs index 02f00deeda0..f0136a885d4 100644 --- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs +++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs @@ -8,6 +8,7 @@ #pragma warning disable ASPIRECONTAINERRUNTIME001 #pragma warning disable ASPIREFILESYSTEM001 #pragma warning disable ASPIREUSERSECRETS001 +#pragma warning disable ASPIREWATCH001 using System.Diagnostics; using System.Reflection; @@ -141,7 +142,7 @@ private DistributedApplicationExecutionContextOptions BuildExecutionContextOptio return _innerBuilder.Configuration["Publishing:Publisher"] switch { { } publisher => new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Publish, publisher), - _ => new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) + _ => new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) { RunSubMode = ParseRunSubMode() } }; } @@ -154,12 +155,22 @@ private DistributedApplicationExecutionContextOptions BuildExecutionContextOptio return operation switch { - DistributedApplicationOperation.Run => new DistributedApplicationExecutionContextOptions(operation), + DistributedApplicationOperation.Run => new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) { RunSubMode = ParseRunSubMode() }, DistributedApplicationOperation.Publish => new DistributedApplicationExecutionContextOptions(operation, _innerBuilder.Configuration["Publishing:Publisher"] ?? "manifest"), _ => throw new DistributedApplicationException("Invalid operation specified. Valid operations are 'publish' or 'run'.") }; } + private RunSubMode ParseRunSubMode() + { + // Match only the declared enum names, case-insensitively. Enum.TryParse is intentionally avoided + // because it also succeeds for inputs that are not declared members, which would bypass the + // documented unknown-value -> Normal fallback and leak an unsupported sub-mode to integrations. + return string.Equals(_innerBuilder.Configuration["AppHost:RunSubMode"], nameof(RunSubMode.Watch), StringComparison.OrdinalIgnoreCase) + ? RunSubMode.Watch + : RunSubMode.Normal; + } + /// /// Initializes a new instance of the class with the specified options. /// diff --git a/src/Aspire.Hosting/DistributedApplicationExecutionContext.cs b/src/Aspire.Hosting/DistributedApplicationExecutionContext.cs index a5a526a40a0..33c9b085cdb 100644 --- a/src/Aspire.Hosting/DistributedApplicationExecutionContext.cs +++ b/src/Aspire.Hosting/DistributedApplicationExecutionContext.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; + namespace Aspire.Hosting; /// @@ -48,6 +50,9 @@ public DistributedApplicationExecutionContext(DistributedApplicationOperation op public DistributedApplicationExecutionContext(DistributedApplicationExecutionContextOptions options) : this(options.Operation, options.PublisherName ?? "manifest") { _options = options; +#pragma warning disable ASPIREWATCH001 // RunSubMode is experimental; core populates it from the options. + RunSubMode = options.Operation == DistributedApplicationOperation.Run ? options.RunSubMode : RunSubMode.Normal; +#pragma warning restore ASPIREWATCH001 } /// @@ -55,6 +60,13 @@ public DistributedApplicationExecutionContext(DistributedApplicationExecutionCon /// public DistributedApplicationOperation Operation { get; } + /// + /// The run sub-mode the AppHost is running under. Only meaningful when is + /// ; otherwise . + /// + [Experimental("ASPIREWATCH001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public RunSubMode RunSubMode { get; } + /// /// The for the AppHost. /// diff --git a/src/Aspire.Hosting/DistributedApplicationExecutionContextOptions.cs b/src/Aspire.Hosting/DistributedApplicationExecutionContextOptions.cs index 05aae04db4a..3bbbcabb77f 100644 --- a/src/Aspire.Hosting/DistributedApplicationExecutionContextOptions.cs +++ b/src/Aspire.Hosting/DistributedApplicationExecutionContextOptions.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; + namespace Aspire.Hosting; /// @@ -54,4 +56,11 @@ public IServiceProvider? ServiceProvider /// The name of the publisher if running in publish mode. /// public string? PublisherName { get; } + + /// + /// The run sub-mode the AppHost is running under. Only meaningful when is + /// ; defaults to . + /// + [Experimental("ASPIREWATCH001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] + public RunSubMode RunSubMode { get; init; } = RunSubMode.Normal; } diff --git a/src/Aspire.Hosting/RunSubMode.cs b/src/Aspire.Hosting/RunSubMode.cs new file mode 100644 index 00000000000..bf9e903754f --- /dev/null +++ b/src/Aspire.Hosting/RunSubMode.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Aspire.Hosting; + +/// +/// Describes the run sub-mode the AppHost is running under (when +/// is +/// ). +/// +/// +/// The run sub-mode is populated from configuration by the AppHost builder and surfaced through +/// . +/// It lets integrations vary how their resources are launched without changing the core hosting behavior. +/// In mode the sub-mode is always . +/// +[Experimental("ASPIREWATCH001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")] +public enum RunSubMode +{ + /// + /// The AppHost is running normally. Resources are launched using their standard run behavior. + /// + Normal, + + /// + /// The AppHost is running in watch sub-mode. Integrations that support watch + /// can launch their resources so that source changes are hot-reloaded. + /// + Watch, +} diff --git a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go index 2feef942404..cd891e69fab 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go +++ b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go @@ -59,6 +59,14 @@ const ( DistributedApplicationOperationPublish DistributedApplicationOperation = "Publish" ) +// RunSubMode represents RunSubMode. +type RunSubMode string + +const ( + RunSubModeNormal RunSubMode = "Normal" + RunSubModeWatch RunSubMode = "Watch" +) + // OtlpProtocol represents OtlpProtocol. type OtlpProtocol string @@ -10431,6 +10439,7 @@ type DistributedApplicationExecutionContext interface { IsRunMode() (bool, error) Operation() (DistributedApplicationOperation, error) PublisherName() (string, error) + RunSubMode() (RunSubMode, error) ServiceProvider() ServiceProvider Services() ServiceProvider SetPublisherName(value string) DistributedApplicationExecutionContext @@ -10507,6 +10516,21 @@ func (s *distributedApplicationExecutionContext) PublisherName() (string, error) return decodeAs[string](result) } +// RunSubMode the run sub-mode the AppHost is running under. Only meaningful when `Operation` is `Run`; otherwise `Normal`. +func (s *distributedApplicationExecutionContext) RunSubMode() (RunSubMode, error) { + if s.err != nil { var zero RunSubMode; return zero, s.err } + ctx := context.Background() + reqArgs := map[string]any{ + "context": s.handle.ToJSON(), + } + result, err := s.client.invokeCapability(ctx, "Aspire.Hosting/DistributedApplicationExecutionContext.runSubMode", reqArgs) + if err != nil { + var zero RunSubMode + return zero, err + } + return decodeAs[RunSubMode](result) +} + // ServiceProvider the `IServiceProvider` for the AppHost. func (s *distributedApplicationExecutionContext) ServiceProvider() ServiceProvider { if s.err != nil { return &serviceProvider{resourceBuilderBase: newErroredResourceBuilder(s.err, s.client)} } diff --git a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java index 369281528bc..af3f0efff26 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java +++ b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java @@ -7947,6 +7947,14 @@ public DistributedApplicationOperation operation() { return DistributedApplicationOperation.fromValue((String) result); } + /** The run sub-mode the AppHost is running under. Only meaningful when `Operation` is `Run`; otherwise `Normal`. */ + public RunSubMode runSubMode() { + Map reqArgs = new HashMap<>(); + reqArgs.put("context", AspireClient.serializeValue(getHandle())); + var result = getClient().invokeCapability("Aspire.Hosting/DistributedApplicationExecutionContext.runSubMode", reqArgs); + return RunSubMode.fromValue((String) result); + } + /** The `IServiceProvider` for the AppHost. */ public IServiceProvider serviceProvider() { Map reqArgs = new HashMap<>(); @@ -21663,6 +21671,35 @@ public void addForEndpoint(EndpointReference endpoint, AspireUnion url, String d } +// ===== RunSubMode.java ===== +// RunSubMode.java - GENERATED CODE - DO NOT EDIT + +package aspire; + +import java.util.*; +import java.util.function.*; + +/** RunSubMode enum. */ +public enum RunSubMode implements WireValueEnum { + NORMAL("Normal"), + WATCH("Watch"); + + private final String value; + + RunSubMode(String value) { + this.value = value; + } + + public String getValue() { return value; } + + public static RunSubMode fromValue(String value) { + for (RunSubMode e : values()) { + if (e.value.equals(value)) return e; + } + throw new IllegalArgumentException("Unknown value: " + value); + } +} + // ===== TestCallbackContext.java ===== // TestCallbackContext.java - GENERATED CODE - DO NOT EDIT @@ -29281,6 +29318,7 @@ public WithVolumeOptions isReadOnly(Boolean value) { .aspire/modules/ResourceUrlAnnotation.java .aspire/modules/ResourceUrlsCallbackContext.java .aspire/modules/ResourceUrlsEditor.java +.aspire/modules/RunSubMode.java .aspire/modules/TestCallbackContext.java .aspire/modules/TestCollectionContext.java .aspire/modules/TestConfigDto.java diff --git a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py index c56ddd029af..a1a79e65d9c 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py +++ b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py @@ -1544,6 +1544,8 @@ def _validate_dict_types(args: typing.Any, arg_types: typing.Any) -> bool: ResourceCommandVisibility = typing.Literal["None", "UI", "Api"] +RunSubMode = typing.Literal["Normal", "Watch"] + TestPersistenceMode = typing.Literal["None", "Volume", "Bind"] TestResourceStatus = typing.Literal["Pending", "Running", "Stopped", "Failed"] @@ -4409,6 +4411,15 @@ def operation(self) -> DistributedApplicationOperation: ) return typing.cast(DistributedApplicationOperation, result) + @_cached_property + def run_sub_mode(self) -> RunSubMode: + """The run sub-mode the AppHost is running under. Only meaningful when `Operation` is `Run`; otherwise `Normal`.""" + result = self._client.invoke_capability( + 'Aspire.Hosting/DistributedApplicationExecutionContext.runSubMode', + {'context': self._handle} + ) + return typing.cast(RunSubMode, result) + @_cached_property def service_provider(self) -> AbstractServiceProvider: """The `IServiceProvider` for the AppHost.""" diff --git a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs index ee8ed076aa6..25175b774da 100644 --- a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs +++ b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs @@ -102,6 +102,25 @@ impl std::fmt::Display for DistributedApplicationOperation { } } +/// RunSubMode +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum RunSubMode { + #[default] + #[serde(rename = "Normal")] + Normal, + #[serde(rename = "Watch")] + Watch, +} + +impl std::fmt::Display for RunSubMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Normal => write!(f, "Normal"), + Self::Watch => write!(f, "Watch"), + } + } +} + /// OtlpProtocol #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum OtlpProtocol { @@ -6438,6 +6457,14 @@ impl DistributedApplicationExecutionContext { Ok(serde_json::from_value(result)?) } + /// The run sub-mode the AppHost is running under. Only meaningful when `Operation` is `Run`; otherwise `Normal`. + pub fn run_sub_mode(&self) -> Result> { + let mut args: HashMap = HashMap::new(); + args.insert("context".to_string(), self.handle.to_json()); + let result = self.client.invoke_capability("Aspire.Hosting/DistributedApplicationExecutionContext.runSubMode", args)?; + Ok(serde_json::from_value(result)?) + } + /// The `IServiceProvider` for the AppHost. pub fn service_provider(&self) -> Result> { let mut args: HashMap = HashMap::new(); diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts index 727460ff55b..1aed016cc3e 100644 --- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts +++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts @@ -719,6 +719,21 @@ export enum ResourceCommandVisibility { Api = "Api", } +/** + * Describes the run sub-mode the AppHost is running under (when `Operation` is `Run`). + * + * The run sub-mode is populated from configuration by the AppHost builder and surfaced through + * `RunSubMode`. + * It lets integrations vary how their resources are launched without changing the core hosting behavior. + * In `Publish` mode the sub-mode is always `Normal`. + */ +export enum RunSubMode { + /** The AppHost is running normally. Resources are launched using their standard run behavior. */ + Normal = "Normal", + /** The AppHost is running in watch sub-mode. Integrations that support watch can launch their resources so that source changes are hot-reloaded. */ + Watch = "Watch", +} + /** Test persistence mode enum. */ export enum TestPersistenceMode { None = "None", @@ -3644,6 +3659,8 @@ export interface DistributedApplicationExecutionContext { }; /** The operation currently being performed by the AppHost. */ operation(): Promise; + /** The run sub-mode the AppHost is running under. Only meaningful when `Operation` is `Run`; otherwise `Normal`. */ + runSubMode(): Promise; /** The `IServiceProvider` for the AppHost. */ serviceProvider(): ServiceProviderPromise; /** The `IServiceProvider` for the AppHost. */ @@ -3657,6 +3674,8 @@ export interface DistributedApplicationExecutionContext { export interface DistributedApplicationExecutionContextPromise extends PromiseLike { /** The operation currently being performed by the AppHost. */ operation(): Promise; + /** The run sub-mode the AppHost is running under. Only meaningful when `Operation` is `Run`; otherwise `Normal`. */ + runSubMode(): Promise; /** The `IServiceProvider` for the AppHost. */ serviceProvider(): ServiceProviderPromise; /** The `IServiceProvider` for the AppHost. */ @@ -3700,6 +3719,13 @@ class DistributedApplicationExecutionContextImpl implements DistributedApplicati ); } + async runSubMode(): Promise { + return await this._client.invokeCapability( + 'Aspire.Hosting/DistributedApplicationExecutionContext.runSubMode', + { context: this._handle } + ); + } + serviceProvider(): ServiceProviderPromise { const promise = (async () => { const handle = await this._client.invokeCapability( @@ -3757,6 +3783,10 @@ class DistributedApplicationExecutionContextPromiseImpl implements DistributedAp return this._promise.then(obj => obj.operation()); } + runSubMode(): Promise { + return this._promise.then(obj => obj.runSubMode()); + } + serviceProvider(): ServiceProviderPromise { return new ServiceProviderPromiseImpl(this._promise.then(obj => obj.serviceProvider()), this._client, false); } diff --git a/tests/Aspire.Hosting.Tests/OperationModesTests.cs b/tests/Aspire.Hosting.Tests/OperationModesTests.cs index 09e2bdb5e50..8946f9e9a76 100644 --- a/tests/Aspire.Hosting.Tests/OperationModesTests.cs +++ b/tests/Aspire.Hosting.Tests/OperationModesTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. #pragma warning disable ASPIREPIPELINES001 +#pragma warning disable ASPIREWATCH001 using Aspire.Hosting.Utils; using Microsoft.AspNetCore.InternalTesting; @@ -139,4 +140,113 @@ public void VerifyExplicitPublishModeInvocation() .WithTestAndResourceLogging(outputHelper); Assert.Equal(DistributedApplicationOperation.Publish, builder.ExecutionContext.Operation); } + + [Fact] + public void RunSubModeDefaultsToNormalInRunMode() + { + // Without any run sub-mode configuration the AppHost runs in the Normal sub-mode. + + using var builder = TestDistributedApplicationBuilder + .Create() + .WithTestAndResourceLogging(outputHelper); + + Assert.True(builder.ExecutionContext.IsRunMode); + Assert.Equal(RunSubMode.Normal, builder.ExecutionContext.RunSubMode); + } + + [Fact] + public void RunSubModeIsWatchWhenConfigured() + { + // The "AppHost:RunSubMode" configuration key selects the run sub-mode, mirroring "AppHost:Operation". + + using var builder = TestDistributedApplicationBuilder + .Create(["AppHost:RunSubMode=Watch"]) + .WithTestAndResourceLogging(outputHelper); + + Assert.True(builder.ExecutionContext.IsRunMode); + Assert.Equal(RunSubMode.Watch, builder.ExecutionContext.RunSubMode); + } + + [Fact] + public void RunSubModeParsingIsCaseInsensitive() + { + // The value is parsed case-insensitively so callers do not have to match the enum casing exactly. + + using var builder = TestDistributedApplicationBuilder + .Create(["AppHost:RunSubMode=watch"]) + .WithTestAndResourceLogging(outputHelper); + + Assert.Equal(RunSubMode.Watch, builder.ExecutionContext.RunSubMode); + } + + [Fact] + public void RunSubModeFallsBackToNormalForUnknownValue() + { + // An unrecognized value must never fail the run; it falls back to Normal. + + using var builder = TestDistributedApplicationBuilder + .Create(["AppHost:RunSubMode=bogus"]) + .WithTestAndResourceLogging(outputHelper); + + Assert.True(builder.ExecutionContext.IsRunMode); + Assert.Equal(RunSubMode.Normal, builder.ExecutionContext.RunSubMode); + } + + [Fact] + public void RunSubModeIsNormalInPublishModeEvenWhenConfigured() + { + // The run sub-mode is only meaningful in run mode; publish mode always reports Normal. + + using var builder = TestDistributedApplicationBuilder + .Create(["--operation", "publish", "--publisher", "manifest", "--output-path", "test-output-path", "AppHost:RunSubMode=Watch"]) + .WithTestAndResourceLogging(outputHelper); + + Assert.True(builder.ExecutionContext.IsPublishMode); + Assert.Equal(RunSubMode.Normal, builder.ExecutionContext.RunSubMode); + } + + [Fact] + public void RunSubModeFallsBackToNormalForNumericValue() + { + // A numeric string is not a declared sub-mode name and must fall back to Normal. This guards against + // Enum.TryParse's behavior of accepting any numeric value (for example "42" => (RunSubMode)42). + + using var builder = TestDistributedApplicationBuilder + .Create(["AppHost:RunSubMode=42"]) + .WithTestAndResourceLogging(outputHelper); + + Assert.True(builder.ExecutionContext.IsRunMode); + Assert.Equal(RunSubMode.Normal, builder.ExecutionContext.RunSubMode); + } + + [Fact] + public void RunSubModeFallsBackToNormalForCompoundValue() + { + // A comma-separated value is not a declared sub-mode name and must fall back to Normal. This guards + // against Enum.TryParse accepting "Normal,Watch" as Watch even though RunSubMode is not [Flags]. + + using var builder = TestDistributedApplicationBuilder + .Create(["AppHost:RunSubMode=Normal,Watch"]) + .WithTestAndResourceLogging(outputHelper); + + Assert.True(builder.ExecutionContext.IsRunMode); + Assert.Equal(RunSubMode.Normal, builder.ExecutionContext.RunSubMode); + } + + [Fact] + public void RunSubModeIsNormalWhenExecutionContextConstructedForPublish() + { + // The run sub-mode only applies to run mode. Even if a caller constructs options with a Watch + // sub-mode and a Publish operation, the execution context must report Normal (publish never watches). + + var options = new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Publish) + { + RunSubMode = RunSubMode.Watch + }; + + var context = new DistributedApplicationExecutionContext(options); + + Assert.True(context.IsPublishMode); + Assert.Equal(RunSubMode.Normal, context.RunSubMode); + } }