diff --git a/src/Agent.Sdk/Knob/AgentKnobs.cs b/src/Agent.Sdk/Knob/AgentKnobs.cs
index 70341346bb..30cc73b846 100644
--- a/src/Agent.Sdk/Knob/AgentKnobs.cs
+++ b/src/Agent.Sdk/Knob/AgentKnobs.cs
@@ -568,6 +568,13 @@ public class AgentKnobs
new EnvironmentKnobSource("AGENT_DISABLE_NODE6_TASKS"),
new BuiltInDefaultKnobSource("false"));
+ public static readonly Knob EnableEOLNodeVersionPolicy = new Knob(
+ nameof(EnableEOLNodeVersionPolicy),
+ "When enabled, tasks that specify end-of-life Node.js versions (6, 10, 16) will run using a supported Node.js version available on the agent (Node 20.1 or Node 24), ignoring the EOL Node.js version(s) in respective task. An error is thrown if no supported version is available.",
+ new PipelineFeatureSource("AGENT_RESTRICT_EOL_NODE_VERSIONS"),
+ new EnvironmentKnobSource("AGENT_RESTRICT_EOL_NODE_VERSIONS"),
+ new BuiltInDefaultKnobSource("false"));
+
public static readonly Knob DisableTeePluginRemoval = new Knob(
nameof(DisableTeePluginRemoval),
"Disables removing TEE plugin after using it during checkout.",
@@ -929,5 +936,12 @@ public class AgentKnobs
new PipelineFeatureSource("EnableDockerExecDiagnostics"),
new EnvironmentKnobSource("AGENT_ENABLE_DOCKER_EXEC_DIAGNOSTICS"),
new BuiltInDefaultKnobSource("false"));
+
+ public static readonly Knob UseNodeVersionStrategy = new Knob(
+ nameof(UseNodeVersionStrategy),
+ "If true, use the strategy pattern for Node.js version selection (both host and container). This provides centralized node selection logic with EOL policy enforcement. Set to false to use legacy node selection logic.",
+ new PipelineFeatureSource("UseNodeVersionStrategy"),
+ new EnvironmentKnobSource("AGENT_USE_NODE_STRATEGY"),
+ new BuiltInDefaultKnobSource("false"));
}
}
diff --git a/src/Test/L0/NodeHandlerCollections.cs b/src/Test/L0/NodeHandlerCollections.cs
new file mode 100644
index 0000000000..e89c0942d1
--- /dev/null
+++ b/src/Test/L0/NodeHandlerCollections.cs
@@ -0,0 +1,17 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Xunit;
+
+namespace Microsoft.VisualStudio.Services.Agent.Tests
+{
+ ///
+ /// Single collection for ALL NodeHandler tests (legacy and unified).
+ /// This ensures sequential execution to prevent environment variable conflicts.
+ ///
+ [CollectionDefinition("Unified NodeHandler Tests")]
+ public class UnifiedNodeHandlerTestFixture : ICollectionFixture
+ {
+ // This class is never instantiated, it's just a collection marker
+ }
+}
\ No newline at end of file
diff --git a/src/Test/L0/NodeHandlerL0.AllSpecs.cs b/src/Test/L0/NodeHandlerL0.AllSpecs.cs
new file mode 100644
index 0000000000..2741126b76
--- /dev/null
+++ b/src/Test/L0/NodeHandlerL0.AllSpecs.cs
@@ -0,0 +1,32 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Linq;
+using Xunit;
+
+namespace Microsoft.VisualStudio.Services.Agent.Tests
+{
+ ///
+ /// Unified test runner for ALL NodeHandler test specifications.
+ /// Executes every scenario defined in NodeHandlerTestSpecs.AllScenarios.
+ ///
+ [Trait("Level", "L0")]
+ [Trait("Category", "NodeHandler")]
+ [Collection("Unified NodeHandler Tests")]
+ public sealed class NodeHandlerL0AllSpecs : NodeHandlerTestBase
+ {
+ [Theory]
+ [MemberData(nameof(GetAllNodeHandlerScenarios))]
+ public void NodeHandler_AllScenarios(TestScenario scenario)
+ {
+ RunScenarioAndAssert(scenario);
+ }
+
+ public static object[][] GetAllNodeHandlerScenarios()
+ {
+ return NodeHandlerTestSpecs.AllScenarios
+ .Select(scenario => new object[] { scenario })
+ .ToArray();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Test/L0/NodeHandlerL0.TestSpecifications.cs b/src/Test/L0/NodeHandlerL0.TestSpecifications.cs
new file mode 100644
index 0000000000..4eae563c0d
--- /dev/null
+++ b/src/Test/L0/NodeHandlerL0.TestSpecifications.cs
@@ -0,0 +1,802 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Microsoft.TeamFoundation.DistributedTask.WebApi;
+using Microsoft.VisualStudio.Services.Agent.Worker;
+
+namespace Microsoft.VisualStudio.Services.Agent.Tests
+{
+ public static class NodeHandlerTestSpecs
+ {
+ public static readonly TestScenario[] AllScenarios = new[]
+ {
+
+ // ========================================================================================
+ // GROUP 1: NODE6 SCENARIOS (Node6HandlerData - EOL)
+ // ========================================================================================
+ new TestScenario(
+ name: "Node6_DefaultBehavior",
+ description: "Node6 handler works when in default behavior (EOL policy disabled)",
+ handlerData: typeof(NodeHandlerData),
+ knobs: new() {},
+ expectedNode: "node",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node6_DefaultBehavior_EOLPolicyDisabled",
+ description: "Node6 handler works when EOL policy is disabled",
+ handlerData: typeof(NodeHandlerData),
+ knobs: new() { ["AGENT_RESTRICT_EOL_NODE_VERSIONS"] = "false" },
+ expectedNode: "node",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node6_EOLPolicyEnabled_UpgradesToNode24",
+ description: "Node6 handler with EOL policy: legacy allows Node6, strategy-based upgrades to Node24",
+ handlerData: typeof(NodeHandlerData),
+ knobs: new() { ["AGENT_RESTRICT_EOL_NODE_VERSIONS"] = "true" },
+ legacyExpectedNode: "node",
+ strategyExpectedNode: "node24",
+ shouldMatchBetweenLegacyAndStrategy: false
+ ),
+
+ new TestScenario(
+ name: "Node6_WithGlobalUseNode10Knob",
+ description: "Node6 handler with global Node10 knob: legacy uses Node10, strategy-based ignores deprecated knob and uses Node6",
+ handlerData: typeof(NodeHandlerData),
+ knobs: new() { ["AGENT_USE_NODE10"] = "true" },
+ legacyExpectedNode: "node10",
+ strategyExpectedNode: "node",
+ shouldMatchBetweenLegacyAndStrategy: false
+ ),
+
+ new TestScenario(
+ name: "Node6_WithGlobalUseNode20Knob",
+ description: "Global Node20 knob overrides Node6 handler data",
+ handlerData: typeof(NodeHandlerData),
+ knobs: new() { ["AGENT_USE_NODE20_1"] = "true" },
+ expectedNode: "node20_1",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node6_WithGlobalUseNode24Knob",
+ description: "Global Node24 knob overrides Node6 handler data",
+ handlerData: typeof(NodeHandlerData),
+ knobs: new() { ["AGENT_USE_NODE24"] = "true" },
+ expectedNode: "node24",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node6_PriorityTest_UseNode24OverridesUseNode20",
+ description: "Node24 global knob takes priority over Node20 global knob with Node6 handler",
+ handlerData: typeof(NodeHandlerData),
+ knobs: new()
+ {
+ ["AGENT_USE_NODE20_1"] = "true",
+ ["AGENT_USE_NODE24"] = "true"
+ },
+ expectedNode: "node24",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node6_PriorityTest_UseNode20OverridesUseNode10",
+ description: "Node20 global knob takes priority over Node10 global knob with Node6 handler",
+ handlerData: typeof(NodeHandlerData),
+ knobs: new()
+ {
+ ["AGENT_USE_NODE10"] = "true",
+ ["AGENT_USE_NODE20_1"] = "true"
+ },
+ expectedNode: "node20_1",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node6_MultipleKnobs_GlobalWins",
+ description: "Global Node24 knob takes highest priority when multiple knobs are set with Node6 handler",
+ handlerData: typeof(NodeHandlerData),
+ knobs: new()
+ {
+ ["AGENT_USE_NODE10"] = "true",
+ ["AGENT_USE_NODE20_1"] = "true",
+ ["AGENT_USE_NODE24"] = "true"
+ },
+ expectedNode: "node24",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node6_AllGlobalKnobsDisabled_UsesHandler",
+ description: "Node6 handler uses handler data when all global knobs are disabled",
+ handlerData: typeof(NodeHandlerData),
+ knobs: new()
+ {
+ ["AGENT_USE_NODE10"] = "false",
+ ["AGENT_USE_NODE20_1"] = "false",
+ ["AGENT_USE_NODE24"] = "false"
+ },
+ expectedNode: "node",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node6_EOLPolicy_Node24GlibcError_FallsBackToNode20",
+ description: "Node6 handler with EOL policy and Node24 glibc error: legacy allows Node6, strategy-based falls back to Node20",
+ handlerData: typeof(NodeHandlerData),
+ knobs: new() { ["AGENT_RESTRICT_EOL_NODE_VERSIONS"] = "true" },
+ node24GlibcError: true,
+ legacyExpectedNode: "node",
+ strategyExpectedNode: "node20_1",
+ shouldMatchBetweenLegacyAndStrategy: false
+ ),
+
+ new TestScenario(
+ name: "Node6_EOLPolicy_BothNode24AndNode20GlibcErrors_ThrowsError",
+ description: "Node6 handler with EOL policy and both newer versions having glibc errors: legacy allows Node6, strategy-based throws error",
+ handlerData: typeof(NodeHandlerData),
+ knobs: new() { ["AGENT_RESTRICT_EOL_NODE_VERSIONS"] = "true" },
+ node24GlibcError: true,
+ node20GlibcError: true,
+ legacyExpectedNode: "node",
+ expectedErrorType: typeof(NotSupportedException),
+ strategyExpectedError: "No compatible Node.js version available for host execution. Handler type: NodeHandlerData. This may occur if all available versions are blocked by EOL policy. Please update your pipeline to use Node20 or Node24 tasks. To temporarily disable EOL policy: Set AGENT_RESTRICT_EOL_NODE_VERSIONS=false",
+ shouldMatchBetweenLegacyAndStrategy: false
+ ),
+
+ // ========================================================================================
+ // GROUP 2: NODE10 SCENARIOS (Node10HandlerData - EOL)
+ // ========================================================================================
+
+ new TestScenario(
+ name: "Node10_DefaultBehavior",
+ description: "Node10 handler uses Node10",
+ handlerData: typeof(Node10HandlerData),
+ knobs: new() {},
+ expectedNode: "node10",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node10_DefaultBehavior_EOLPolicyDisabled",
+ description: "Node10 handler uses Node10 when EOL policy is disabled",
+ handlerData: typeof(Node10HandlerData),
+ knobs: new() { ["AGENT_RESTRICT_EOL_NODE_VERSIONS"] = "false" },
+ expectedNode: "node10",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node10_EOLPolicyEnabled_UpgradesToNode24",
+ description: "Node10 handler with EOL policy: legacy allows Node10, strategy-based upgrades to Node24",
+ handlerData: typeof(Node10HandlerData),
+ knobs: new() { ["AGENT_RESTRICT_EOL_NODE_VERSIONS"] = "true" },
+ legacyExpectedNode: "node10",
+ strategyExpectedNode: "node24",
+ shouldMatchBetweenLegacyAndStrategy: false
+ ),
+
+ new TestScenario(
+ name: "Node10_WithGlobalUseNode10Knob",
+ description: "Global Node10 knob reinforces Node10 handler data",
+ handlerData: typeof(Node10HandlerData),
+ knobs: new() { ["AGENT_USE_NODE10"] = "true" },
+ expectedNode: "node10",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node10_WithGlobalUseNode20Knob",
+ description: "Global Node20 knob overrides Node10 handler data",
+ handlerData: typeof(Node10HandlerData),
+ knobs: new() { ["AGENT_USE_NODE20_1"] = "true" },
+ expectedNode: "node20_1",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node10_WithGlobalUseNode24Knob",
+ description: "Global Node24 knob overrides Node10 handler data",
+ handlerData: typeof(Node10HandlerData),
+ knobs: new() { ["AGENT_USE_NODE24"] = "true" },
+ expectedNode: "node24",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node10_PriorityTest_UseNode24OverridesUseNode20",
+ description: "Node24 global knob takes priority over Node20 global knob with Node10 handler",
+ handlerData: typeof(Node10HandlerData),
+ knobs: new()
+ {
+ ["AGENT_USE_NODE20_1"] = "true",
+ ["AGENT_USE_NODE24"] = "true"
+ },
+ expectedNode: "node24",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node10_PriorityTest_UseNode20OverridesUseNode10",
+ description: "Node20 global knob takes priority over Node10 global knob with Node10 handler",
+ handlerData: typeof(Node10HandlerData),
+ knobs: new()
+ {
+ ["AGENT_USE_NODE10"] = "true",
+ ["AGENT_USE_NODE20_1"] = "true"
+ },
+ expectedNode: "node20_1",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node10_MultipleKnobs_GlobalWins",
+ description: "Global Node24 knob takes highest priority when multiple knobs are set with Node10 handler",
+ handlerData: typeof(Node10HandlerData),
+ knobs: new()
+ {
+ ["AGENT_USE_NODE10"] = "true",
+ ["AGENT_USE_NODE20_1"] = "true",
+ ["AGENT_USE_NODE24"] = "true"
+ },
+ expectedNode: "node24",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node10_AllGlobalKnobsDisabled_UsesHandler",
+ description: "Node10 handler uses handler data when all global knobs are disabled",
+ handlerData: typeof(Node10HandlerData),
+ knobs: new()
+ {
+ ["AGENT_USE_NODE10"] = "false",
+ ["AGENT_USE_NODE20_1"] = "false",
+ ["AGENT_USE_NODE24"] = "false"
+ },
+ expectedNode: "node10",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node10_EOLPolicy_Node24GlibcError_FallsBackToNode20",
+ description: "Node10 handler with EOL policy and Node24 glibc error: legacy allows Node10, strategy-based falls back to Node20",
+ handlerData: typeof(Node10HandlerData),
+ knobs: new() { ["AGENT_RESTRICT_EOL_NODE_VERSIONS"] = "true" },
+ node24GlibcError: true,
+ legacyExpectedNode: "node10",
+ strategyExpectedNode: "node20_1",
+ shouldMatchBetweenLegacyAndStrategy: false
+ ),
+
+ new TestScenario(
+ name: "Node10_EOLPolicy_BothNode24AndNode20GlibcErrors_ThrowsError",
+ description: "Node10 handler with EOL policy and both newer versions having glibc errors: legacy allows Node10, strategy-based throws error",
+ handlerData: typeof(Node10HandlerData),
+ knobs: new() { ["AGENT_RESTRICT_EOL_NODE_VERSIONS"] = "true" },
+ node24GlibcError: true,
+ node20GlibcError: true,
+ legacyExpectedNode: "node10",
+ expectedErrorType: typeof(NotSupportedException),
+ strategyExpectedError: "No compatible Node.js version available for host execution. Handler type: Node10HandlerData. This may occur if all available versions are blocked by EOL policy. Please update your pipeline to use Node20 or Node24 tasks. To temporarily disable EOL policy: Set AGENT_RESTRICT_EOL_NODE_VERSIONS=false",
+ shouldMatchBetweenLegacyAndStrategy: false
+ ),
+
+ // ========================================================================================
+ // GROUP 3: NODE16 SCENARIOS (Node16HandlerData)
+ // ========================================================================================
+
+ new TestScenario(
+ name: "Node16_DefaultBehavior_EOLPolicyDisabled",
+ description: "Node16 handler uses Node16 when EOL policy is disabled",
+ handlerData: typeof(Node16HandlerData),
+ knobs: new() { ["AGENT_RESTRICT_EOL_NODE_VERSIONS"] = "false" },
+ expectedNode: "node16",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node16_DefaultEOLPolicy_AllowsNode16",
+ description: "Node16 handler uses Node16 when EOL policy is default (disabled)",
+ handlerData: typeof(Node16HandlerData),
+ knobs: new() { },
+ expectedNode: "node16",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node16_EOLPolicyEnabled_UpgradesToNode24",
+ description: "Node16 handler with EOL policy: legacy allows Node16, strategy-based upgrades to Node24",
+ handlerData: typeof(Node16HandlerData),
+ knobs: new() { ["AGENT_RESTRICT_EOL_NODE_VERSIONS"] = "true" },
+ legacyExpectedNode: "node16",
+ strategyExpectedNode: "node24",
+ shouldMatchBetweenLegacyAndStrategy: false
+ ),
+
+ new TestScenario(
+ name: "Node16_InContainer_EOLPolicyDisabled",
+ description: "Node16 works in containers when EOL policy is disabled",
+ handlerData: typeof(Node16HandlerData),
+ knobs: new() { ["AGENT_RESTRICT_EOL_NODE_VERSIONS"] = "false" },
+ expectedNode: "node16",
+ inContainer: true,
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node16_InContainer_EOLPolicyEnabled_UpgradesToNode24",
+ description: "Node16 in container with EOL policy: legacy allows Node16, strategy-based upgrades to Node24",
+ handlerData: typeof(Node16HandlerData),
+ knobs: new() { ["AGENT_RESTRICT_EOL_NODE_VERSIONS"] = "true" },
+ legacyExpectedNode: "node16",
+ strategyExpectedNode: "node24",
+ inContainer: true,
+ shouldMatchBetweenLegacyAndStrategy: false
+ ),
+
+ new TestScenario(
+ name: "Node16_EOLPolicy_Node24GlibcError_FallsBackToNode20",
+ description: "Node16 handler with EOL policy and Node24 glibc error: legacy allows Node16, strategy-based falls back to Node20",
+ handlerData: typeof(Node16HandlerData),
+ knobs: new() { ["AGENT_RESTRICT_EOL_NODE_VERSIONS"] = "true" },
+ node24GlibcError: true,
+ legacyExpectedNode: "node16",
+ strategyExpectedNode: "node20_1",
+ shouldMatchBetweenLegacyAndStrategy: false
+ ),
+
+ new TestScenario(
+ name: "Node16_EOLPolicy_BothNode24AndNode20GlibcErrors_ThrowsError",
+ description: "Node16 handler with EOL policy and both newer versions having glibc errors: legacy allows Node16, strategy-based throws error",
+ handlerData: typeof(Node16HandlerData),
+ knobs: new() { ["AGENT_RESTRICT_EOL_NODE_VERSIONS"] = "true" },
+ node24GlibcError: true,
+ node20GlibcError: true,
+ legacyExpectedNode: "node16",
+ expectedErrorType: typeof(NotSupportedException),
+ strategyExpectedError: "No compatible Node.js version available for host execution. Handler type: Node16HandlerData. This may occur if all available versions are blocked by EOL policy. Please update your pipeline to use Node20 or Node24 tasks. To temporarily disable EOL policy: Set AGENT_RESTRICT_EOL_NODE_VERSIONS=false",
+ shouldMatchBetweenLegacyAndStrategy: false
+ ),
+
+ new TestScenario(
+ name: "Node16_InContainer_EOLPolicy_Node24GlibcError_FallsBackToNode20",
+ description: "Node16 handler in container with EOL policy and Node24 glibc error: legacy allows Node16, strategy-based falls back to Node20",
+ handlerData: typeof(Node16HandlerData),
+ knobs: new() { ["AGENT_RESTRICT_EOL_NODE_VERSIONS"] = "true" },
+ node24GlibcError: true,
+ inContainer: true,
+ legacyExpectedNode: "node16",
+ strategyExpectedNode: "node20_1",
+ shouldMatchBetweenLegacyAndStrategy: false
+ ),
+
+ new TestScenario(
+ name: "Node16_InContainer_EOLPolicy_BothNode24AndNode20GlibcErrors_ThrowsError",
+ description: "Node16 handler in container with EOL policy and both newer versions having glibc errors: legacy allows Node16, strategy-based throws error",
+ handlerData: typeof(Node16HandlerData),
+ knobs: new() { ["AGENT_RESTRICT_EOL_NODE_VERSIONS"] = "true" },
+ node24GlibcError: true,
+ node20GlibcError: true,
+ inContainer: true,
+ legacyExpectedNode: "node16",
+ expectedErrorType: typeof(NotSupportedException),
+ strategyExpectedError: "No compatible Node.js version available for host execution. Handler type: Node16HandlerData. This may occur if all available versions are blocked by EOL policy. Please update your pipeline to use Node20 or Node24 tasks. To temporarily disable EOL policy: Set AGENT_RESTRICT_EOL_NODE_VERSIONS=false",
+ shouldMatchBetweenLegacyAndStrategy: false
+ ),
+
+ // ========================================================================================
+ // GROUP 4: NODE20 SCENARIOS (Node20_1HandlerData)
+ // ========================================================================================
+ new TestScenario(
+ name: "Node20_DefaultBehavior_WithHandler",
+ description: "Node20 handler uses Node20 by default",
+ handlerData: typeof(Node20_1HandlerData),
+ knobs: new() { },
+ expectedNode: "node20_1",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node20_WithGlobalUseNode20Knob",
+ description: "Global Node20 knob forces Node20 regardless of handler type",
+ handlerData: typeof(Node20_1HandlerData),
+ knobs: new() { ["AGENT_USE_NODE20_1"] = "true" },
+ expectedNode: "node20_1",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node20_GlibcError_EOLPolicy_UpgradesToNode24",
+ description: "Node20 with glibc error and EOL policy: legacy falls back to Node16, strategy-based upgrades to Node24",
+ handlerData: typeof(Node20_1HandlerData),
+ knobs: new() { ["AGENT_RESTRICT_EOL_NODE_VERSIONS"] = "true" },
+ node20GlibcError: true,
+ legacyExpectedNode: "node16",
+ strategyExpectedNode: "node24",
+ shouldMatchBetweenLegacyAndStrategy: false
+ ),
+
+ new TestScenario(
+ name: "Node20_WithGlobalUseNode24Knob",
+ description: "Global Node24 knob overrides Node20 handler data",
+ handlerData: typeof(Node20_1HandlerData),
+ knobs: new() { ["AGENT_USE_NODE24"] = "true" },
+ expectedNode: "node24",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node20_WithUseNode10Knob",
+ description: "Node20 handler ignores deprecated Node10 knob in strategy-based approach",
+ handlerData: typeof(Node20_1HandlerData),
+ knobs: new() { ["AGENT_USE_NODE10"] = "true" },
+ legacyExpectedNode: "node10",
+ strategyExpectedNode: "node20_1",
+ shouldMatchBetweenLegacyAndStrategy: false
+ ),
+
+ new TestScenario(
+ name: "Node20_MultipleKnobs_GlobalWins",
+ description: "Global Node24 knob takes highest priority when multiple knobs are set with Node20 handler",
+ handlerData: typeof(Node20_1HandlerData),
+ knobs: new()
+ {
+ ["AGENT_USE_NODE10"] = "true",
+ ["AGENT_USE_NODE20_1"] = "true",
+ ["AGENT_USE_NODE24"] = "true"
+ },
+ expectedNode: "node24",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node20_GlibcError_Node24GlibcError_EOLPolicy_ThrowsError",
+ description: "Node20 and Node24 with glibc error and EOL policy enabled throws error (cannot fallback to Node16), legacy picks Node16",
+ handlerData: typeof(Node20_1HandlerData),
+ knobs: new() { ["AGENT_RESTRICT_EOL_NODE_VERSIONS"] = "true" },
+ node20GlibcError: true,
+ node24GlibcError: true,
+ legacyExpectedNode: "node16",
+ expectedErrorType: typeof(NotSupportedException),
+ strategyExpectedError: "No compatible Node.js version available for host execution. Handler type: Node20_1HandlerData. This may occur if all available versions are blocked by EOL policy. Please update your pipeline to use Node20 or Node24 tasks. To temporarily disable EOL policy: Set AGENT_RESTRICT_EOL_NODE_VERSIONS=false",
+ shouldMatchBetweenLegacyAndStrategy: false
+ ),
+
+ new TestScenario(
+ name: "Node20_InContainer_DefaultBehavior",
+ description: "Node20 handler works correctly in container environments",
+ handlerData: typeof(Node20_1HandlerData),
+ knobs: new() { },
+ expectedNode: "node20_1",
+ inContainer: true,
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node20_InContainer_WithGlobalUseNode24Knob",
+ description: "Global Node24 knob overrides Node20 handler data in container",
+ handlerData: typeof(Node20_1HandlerData),
+ knobs: new() { ["AGENT_USE_NODE24"] = "true" },
+ expectedNode: "node24",
+ inContainer: true,
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node20_InContainer_GlibcError_FallsBackToNode16",
+ description: "Node20 in container with glibc error falls back to Node16 when EOL policy is disabled",
+ handlerData: typeof(Node20_1HandlerData),
+ knobs: new() { ["AGENT_RESTRICT_EOL_NODE_VERSIONS"] = "false" },
+ node20GlibcError: true,
+ expectedNode: "node16",
+ inContainer: true,
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+
+ new TestScenario(
+ name: "Node20_PriorityTest_UseNode20OverridesUseNode10",
+ description: "Node20 global knob takes priority over Node10 global knob",
+ handlerData: typeof(Node20_1HandlerData),
+ knobs: new()
+ {
+ ["AGENT_USE_NODE10"] = "true",
+ ["AGENT_USE_NODE20_1"] = "true"
+ },
+ expectedNode: "node20_1",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node20_PriorityTest_UseNode24OverridesUseNode20",
+ description: "Node24 global knob takes priority over Node20 global knob",
+ handlerData: typeof(Node20_1HandlerData),
+ knobs: new()
+ {
+ ["AGENT_USE_NODE20_1"] = "true",
+ ["AGENT_USE_NODE24"] = "true"
+ },
+ expectedNode: "node24",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ // ========================================================================================
+ // GROUP 5: CONTAINER-SPECIFIC EOL SCENARIOS
+ // ========================================================================================
+
+ new TestScenario(
+ name: "Node24_InContainer_GlibcError_EOLPolicy_FallsBackToNode20",
+ description: "Node24 in container with glibc error falls back to Node20 when EOL policy prevents Node16",
+ handlerData: typeof(Node24HandlerData),
+ knobs: new()
+ {
+ ["AGENT_USE_NODE24_WITH_HANDLER_DATA"] = "true",
+ ["AGENT_RESTRICT_EOL_NODE_VERSIONS"] = "true"
+ },
+ node24GlibcError: true,
+ expectedNode: "node20_1",
+ inContainer: true,
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node24_InContainer_BothGlibcErrors_EOLPolicy_ThrowsError",
+ description: "Node24 in container with all glibc errors and EOL policy throws error (strategy-based) or falls back to Node16 (legacy)",
+ handlerData: typeof(Node24HandlerData),
+ knobs: new()
+ {
+ ["AGENT_USE_NODE24_WITH_HANDLER_DATA"] = "true",
+ ["AGENT_RESTRICT_EOL_NODE_VERSIONS"] = "true"
+ },
+ node24GlibcError: true,
+ node20GlibcError: true,
+ legacyExpectedNode: "node16",
+ expectedErrorType: typeof(NotSupportedException),
+ strategyExpectedError: "No compatible Node.js version available for host execution. Handler type: Node24HandlerData. This may occur if all available versions are blocked by EOL policy. Please update your pipeline to use Node20 or Node24 tasks. To temporarily disable EOL policy: Set AGENT_RESTRICT_EOL_NODE_VERSIONS=false", // this is wrong --- this TEST NEEDS FIXING
+ inContainer: true,
+ shouldMatchBetweenLegacyAndStrategy: false
+ ),
+
+ new TestScenario(
+ name: "Node20_InContainer_GlibcError_EOLPolicy_UpgradesToNode24",
+ description: "Node20 in container with glibc error and EOL policy upgrades to Node24 (strategy-based) or falls back to Node16 (legacy)",
+ handlerData: typeof(Node20_1HandlerData),
+ knobs: new() { ["AGENT_RESTRICT_EOL_NODE_VERSIONS"] = "true" },
+ node20GlibcError: true,
+ legacyExpectedNode: "node16",
+ strategyExpectedNode: "node24",
+ inContainer: true,
+ shouldMatchBetweenLegacyAndStrategy: false
+ ),
+
+ new TestScenario(
+ name: "Node20_AllGlobalKnobsDisabled_UsesHandler",
+ description: "Node20 handler uses handler data when all global knobs are disabled",
+ handlerData: typeof(Node20_1HandlerData),
+ knobs: new()
+ {
+ ["AGENT_USE_NODE10"] = "false",
+ ["AGENT_USE_NODE20_1"] = "false",
+ ["AGENT_USE_NODE24"] = "false"
+ },
+ expectedNode: "node20_1",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+
+ // ========================================================================================
+ // GROUP 6: NODE24 SCENARIOS (Node24HandlerData)
+ // ========================================================================================
+
+ new TestScenario(
+ name: "Node24_DefaultBehavior_WithKnobEnabled",
+ description: "Node24 handler uses Node24 when handler-specific knob is enabled",
+ handlerData: typeof(Node24HandlerData),
+ knobs: new() { ["AGENT_USE_NODE24_WITH_HANDLER_DATA"] = "true" },
+ expectedNode: "node24",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node24_WithHandlerDataKnobDisabled_FallsBackToNode20",
+ description: "Node24 handler falls back to Node20 when AGENT_USE_NODE24_WITH_HANDLER_DATA=false",
+ handlerData: typeof(Node24HandlerData),
+ knobs: new() { ["AGENT_USE_NODE24_WITH_HANDLER_DATA"] = "false" },
+ expectedNode: "node20_1",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node24_WithGlobalUseNode24Knob",
+ description: "Global Node24 knob overrides handler-specific knob setting",
+ handlerData: typeof(Node24HandlerData),
+ knobs: new() { ["AGENT_USE_NODE24"] = "true" },
+ expectedNode: "node24",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node24_WithUseNode10Knob",
+ description: "Node24 handler ignores deprecated Node10 knob in strategy-based approach",
+ handlerData: typeof(Node24HandlerData),
+ knobs: new()
+ {
+ ["AGENT_USE_NODE24_WITH_HANDLER_DATA"] = "true",
+ ["AGENT_USE_NODE10"] = "true"
+ },
+ legacyExpectedNode: "node10",
+ strategyExpectedNode: "node24",
+ shouldMatchBetweenLegacyAndStrategy: false
+ ),
+
+ new TestScenario(
+ name: "Node24_WithUseNode20Knob",
+ description: "Node24 handler ignores deprecated Node20 knob in strategy-based approach",
+ handlerData: typeof(Node24HandlerData),
+ knobs: new()
+ {
+ ["AGENT_USE_NODE24_WITH_HANDLER_DATA"] = "true",
+ ["AGENT_USE_NODE20_1"] = "true"
+ },
+ shouldMatchBetweenLegacyAndStrategy: false,
+ legacyExpectedNode: "node20_1",
+ strategyExpectedNode: "node24"
+ ),
+
+ new TestScenario(
+ name: "Node24_GlibcError_FallsBackToNode20",
+ description: "Node24 with glibc compatibility error falls back to Node20",
+ handlerData: typeof(Node24HandlerData),
+ knobs: new() { ["AGENT_USE_NODE24_WITH_HANDLER_DATA"] = "true" },
+ node24GlibcError: true,
+ expectedNode: "node20_1",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node24_GlibcError_Node20GlibcError_FallsBackToNode16",
+ description: "Node24 with both Node24 and Node20 glibc errors falls back to Node16",
+ handlerData: typeof(Node24HandlerData),
+ knobs: new() { ["AGENT_USE_NODE24_WITH_HANDLER_DATA"] = "true" },
+ node24GlibcError: true,
+ node20GlibcError: true,
+ expectedNode: "node16",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node24_GlibcError_Node20GlibcError_EOLPolicy_ThrowsError",
+ description: "Node24 with all glibc errors and EOL policy throws error (strategy-based) or falls back to Node16 (legacy)",
+ handlerData: typeof(Node24HandlerData),
+ knobs: new()
+ {
+ ["AGENT_USE_NODE24_WITH_HANDLER_DATA"] = "true",
+ ["AGENT_RESTRICT_EOL_NODE_VERSIONS"] = "true"
+ },
+ node24GlibcError: true,
+ node20GlibcError: true,
+ legacyExpectedNode: "node16",
+ expectedErrorType: typeof(NotSupportedException),
+ strategyExpectedError: "No compatible Node.js version available for host execution. Handler type: Node24HandlerData. This may occur if all available versions are blocked by EOL policy. Please update your pipeline to use Node20 or Node24 tasks. To temporarily disable EOL policy: Set AGENT_RESTRICT_EOL_NODE_VERSIONS=false",
+ shouldMatchBetweenLegacyAndStrategy: false
+ ),
+
+ new TestScenario(
+ name: "Node24_PriorityTest_UseNode24OverridesUseNode20",
+ description: "Node24 global knob takes priority over Node20 global knob",
+ handlerData: typeof(Node24HandlerData),
+ knobs: new()
+ {
+ ["AGENT_USE_NODE20_1"] = "true",
+ ["AGENT_USE_NODE24"] = "true"
+ },
+ expectedNode: "node24",
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ new TestScenario(
+ name: "Node24_InContainer_DefaultBehavior",
+ description: "Node24 handler works correctly in container environments",
+ handlerData: typeof(Node24HandlerData),
+ knobs: new() { ["AGENT_USE_NODE24_WITH_HANDLER_DATA"] = "true" },
+ expectedNode: "node24",
+ inContainer: true,
+ shouldMatchBetweenLegacyAndStrategy: true
+ ),
+
+ // ========================================================================================
+ // GROUP 7: EDGE CASES AND ERROR SCENARIOS
+ // ========================================================================================
+
+
+ new TestScenario(
+ name: "Node16_EOLPolicy_WithUseNode10Knob_UpgradesToNode24",
+ description: "Node16 handler with deprecated Node10 knob upgrades to Node24 when EOL policy is enabled (strategy-based) or uses Node10 (legacy)",
+ handlerData: typeof(Node16HandlerData),
+ knobs: new()
+ {
+ ["AGENT_USE_NODE10"] = "true",
+ ["AGENT_RESTRICT_EOL_NODE_VERSIONS"] = "true"
+ },
+ legacyExpectedNode: "node10",
+ strategyExpectedNode: "node24",
+ shouldMatchBetweenLegacyAndStrategy: false
+ ),
+
+
+ };
+
+ }
+
+ ///
+ /// Test scenario specification.
+ ///
+ public class TestScenario
+ {
+ // Identification
+ public string Name { get; set; }
+ public string Description { get; set; }
+
+ // Test inputs - Handler Configuration
+ public Type HandlerDataType { get; set; }
+
+ public Dictionary Knobs { get; set; } = new();
+ public bool Node20GlibcError { get; set; }
+ public bool Node24GlibcError { get; set; }
+ public bool InContainer { get; set; }
+ public string CustomNodePath { get; set; }
+
+ // Expected results (for equivalent scenarios)
+ public string ExpectedNode { get; set; }
+
+ // Expected results (for divergent scenarios)
+ public string LegacyExpectedNode { get; set; }
+ public string StrategyExpectedNode { get; set; }
+ public string StrategyExpectedError { get; set; }
+ public Type ExpectedErrorType { get; set; }
+
+ public bool shouldMatchBetweenLegacyAndStrategy { get; set; } = true;
+
+ public TestScenario(
+ string name,
+ string description,
+ Type handlerData,
+ Dictionary knobs = null,
+ string expectedNode = null,
+ string legacyExpectedNode = null,
+ string strategyExpectedNode = null,
+ string strategyExpectedError = null,
+ Type expectedErrorType = null,
+ bool shouldMatchBetweenLegacyAndStrategy = true,
+ bool node20GlibcError = false,
+ bool node24GlibcError = false,
+ bool inContainer = false,
+ string customNodePath = null
+ )
+ {
+ Name = name;
+ Description = description;
+ HandlerDataType = handlerData ?? throw new ArgumentNullException(nameof(handlerData));
+
+ Knobs = knobs ?? new Dictionary();
+ ExpectedNode = expectedNode;
+ LegacyExpectedNode = legacyExpectedNode ?? expectedNode;
+ StrategyExpectedNode = strategyExpectedNode ?? expectedNode;
+ StrategyExpectedError = strategyExpectedError;
+ ExpectedErrorType = expectedErrorType;
+ shouldMatchBetweenLegacyAndStrategy = shouldMatchBetweenLegacyAndStrategy;
+ Node20GlibcError = node20GlibcError;
+ Node24GlibcError = node24GlibcError;
+ InContainer = inContainer;
+ CustomNodePath = customNodePath;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Test/L0/NodeHandlerL0.cs b/src/Test/L0/NodeHandlerL0.cs
index 9c39e37117..12204b1b86 100644
--- a/src/Test/L0/NodeHandlerL0.cs
+++ b/src/Test/L0/NodeHandlerL0.cs
@@ -15,6 +15,7 @@
namespace Microsoft.VisualStudio.Services.Agent.Tests
{
+ [Collection("Unified NodeHandler Tests")]
public sealed class NodeHandlerL0
{
private Mock nodeHandlerHalper;
diff --git a/src/Test/L0/NodeHandlerTestBase.cs b/src/Test/L0/NodeHandlerTestBase.cs
new file mode 100644
index 0000000000..b262b5ca34
--- /dev/null
+++ b/src/Test/L0/NodeHandlerTestBase.cs
@@ -0,0 +1,288 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using Microsoft.TeamFoundation.DistributedTask.WebApi;
+using Microsoft.VisualStudio.Services.Agent.Util;
+using Microsoft.VisualStudio.Services.Agent.Worker;
+using Microsoft.VisualStudio.Services.Agent.Worker.Handlers;
+using Moq;
+using Xunit;
+using Agent.Sdk;
+
+namespace Microsoft.VisualStudio.Services.Agent.Tests
+{
+ public abstract class NodeHandlerTestBase : IDisposable
+ {
+ protected Mock NodeHandlerHelper { get; private set; }
+ private bool disposed = false;
+
+ protected NodeHandlerTestBase()
+ {
+ NodeHandlerHelper = GetMockedNodeHandlerHelper();
+ ResetEnvironment();
+ }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (!disposed)
+ {
+ if (disposing)
+ {
+ ResetEnvironment();
+ }
+ disposed = true;
+ }
+ }
+
+ protected void RunScenarioAndAssert(TestScenario scenario)
+ {
+ RunScenarioAndAssert(scenario, useStrategy: false);
+ }
+
+ protected void RunScenarioAndAssert(TestScenario scenario, bool useStrategy)
+ {
+ ResetEnvironment();
+
+ foreach (var knob in scenario.Knobs)
+ {
+ Environment.SetEnvironmentVariable(knob.Key, knob.Value);
+ }
+
+ Environment.SetEnvironmentVariable("AGENT_USE_NODE_STRATEGY", useStrategy ? "true" : "false");
+
+ try
+ {
+ using (TestHostContext thc = new TestHostContext(this, scenario.Name))
+ {
+ thc.SetSingleton(new WorkerCommandManager() as IWorkerCommandManager);
+ thc.SetSingleton(new ExtensionManager() as IExtensionManager);
+
+ ConfigureNodeHandlerHelper(scenario);
+
+ NodeHandler nodeHandler = new NodeHandler(NodeHandlerHelper.Object);
+ nodeHandler.Initialize(thc);
+
+ var executionContextMock = CreateTestExecutionContext(thc, scenario);
+ nodeHandler.ExecutionContext = executionContextMock.Object;
+ nodeHandler.Data = CreateHandlerData(scenario.HandlerDataType);
+
+ var expectations = GetScenarioExpectations(scenario, useStrategy);
+
+ try
+ {
+ string actualLocation = nodeHandler.GetNodeLocation(
+ node20ResultsInGlibCError: scenario.Node20GlibcError,
+ node24ResultsInGlibCError: scenario.Node24GlibcError,
+ inContainer: scenario.InContainer);
+ string expectedLocation = GetExpectedNodeLocation(expectations.ExpectedNode, scenario, thc);
+ Assert.Equal(expectedLocation, actualLocation);
+ }
+ catch (Exception ex)
+ {
+ Assert.NotNull(ex);
+ Assert.IsType(scenario.ExpectedErrorType, ex);
+
+ if (!string.IsNullOrEmpty(expectations.ExpectedError))
+ {
+ Assert.Contains(expectations.ExpectedError, ex.Message);
+ }
+ }
+ }
+ }
+ finally
+ {
+ ResetEnvironment();
+ }
+ }
+
+ private void ConfigureNodeHandlerHelper(TestScenario scenario)
+ {
+ NodeHandlerHelper.Reset();
+
+ NodeHandlerHelper
+ .Setup(x => x.IsNodeFolderExist(It.IsAny(), It.IsAny()))
+ .Returns(true);
+
+ NodeHandlerHelper
+ .Setup(x => x.GetNodeFolderPath(It.IsAny(), It.IsAny()))
+ .Returns((string nodeFolderName, IHostContext hostContext) => Path.Combine(
+ hostContext.GetDirectory(WellKnownDirectory.Externals),
+ nodeFolderName,
+ "bin",
+ $"node{IOUtil.ExeExtension}"));
+ }
+
+ private string GetExpectedNodeLocation(string expectedNode, TestScenario scenario, TestHostContext thc)
+ {
+ if (!string.IsNullOrWhiteSpace(scenario.CustomNodePath))
+ {
+ return scenario.CustomNodePath;
+ }
+
+ return Path.Combine(
+ thc.GetDirectory(WellKnownDirectory.Externals),
+ expectedNode,
+ "bin",
+ $"node{IOUtil.ExeExtension}");
+ }
+
+ protected ScenarioExpectations GetScenarioExpectations(TestScenario scenario, bool useStrategy)
+ {
+ return new ScenarioExpectations
+ {
+ ExpectedNode = scenario.LegacyExpectedNode,
+ // ExpectSuccess = scenario.LegacyExpectSuccess,
+ ExpectedError = null
+ };
+ }
+
+ protected BaseNodeHandlerData CreateHandlerData(Type handlerDataType)
+ {
+ if (handlerDataType == typeof(NodeHandlerData))
+ return new NodeHandlerData();
+ else if (handlerDataType == typeof(Node10HandlerData))
+ return new Node10HandlerData();
+ else if (handlerDataType == typeof(Node16HandlerData))
+ return new Node16HandlerData();
+ else if (handlerDataType == typeof(Node20_1HandlerData))
+ return new Node20_1HandlerData();
+ else if (handlerDataType == typeof(Node24HandlerData))
+ return new Node24HandlerData();
+ else
+ throw new ArgumentException($"Unknown handler data type: {handlerDataType}");
+ }
+
+ protected Mock CreateTestExecutionContext(TestHostContext tc, Dictionary knobs)
+ {
+ var executionContext = new Mock();
+ var variables = new Dictionary();
+
+ foreach (var knob in knobs)
+ {
+ variables[knob.Key] = new VariableValue(knob.Value);
+ }
+
+ List warnings;
+ executionContext
+ .Setup(x => x.Variables)
+ .Returns(new Variables(tc, copy: variables, warnings: out warnings));
+
+ executionContext
+ .Setup(x => x.GetScopedEnvironment())
+ .Returns(new SystemEnvironment());
+
+ executionContext
+ .Setup(x => x.GetVariableValueOrDefault(It.IsAny()))
+ .Returns((string variableName) =>
+ {
+ if (variables.TryGetValue(variableName, out VariableValue value))
+ {
+ return value.Value;
+ }
+ return Environment.GetEnvironmentVariable(variableName);
+ });
+
+ return executionContext;
+ }
+
+ protected Mock CreateTestExecutionContext(TestHostContext tc, TestScenario scenario)
+ {
+ var executionContext = CreateTestExecutionContext(tc, scenario.Knobs);
+
+ if (!string.IsNullOrWhiteSpace(scenario.CustomNodePath))
+ {
+ var stepTarget = CreateStepTargetObject(scenario);
+ executionContext
+ .Setup(x => x.StepTarget())
+ .Returns(stepTarget);
+ }
+ else
+ {
+ executionContext
+ .Setup(x => x.StepTarget())
+ .Returns((ExecutionTargetInfo)null);
+ }
+
+ return executionContext;
+ }
+
+ private ExecutionTargetInfo CreateStepTargetObject(TestScenario scenario)
+ {
+ if (scenario.InContainer)
+ {
+ return new ContainerInfo()
+ {
+ CustomNodePath = scenario.CustomNodePath
+ };
+ }
+ else
+ {
+ return new HostInfo()
+ {
+ CustomNodePath = scenario.CustomNodePath
+ };
+ }
+ }
+
+ private Mock GetMockedNodeHandlerHelper()
+ {
+ var nodeHandlerHelper = new Mock();
+
+ nodeHandlerHelper
+ .Setup(x => x.IsNodeFolderExist(It.IsAny(), It.IsAny()))
+ .Returns(true);
+
+ nodeHandlerHelper
+ .Setup(x => x.GetNodeFolderPath(It.IsAny(), It.IsAny()))
+ .Returns((string nodeFolderName, IHostContext hostContext) => Path.Combine(
+ hostContext.GetDirectory(WellKnownDirectory.Externals),
+ nodeFolderName,
+ "bin",
+ $"node{IOUtil.ExeExtension}"));
+
+ return nodeHandlerHelper;
+ }
+
+ protected void ResetEnvironment()
+ {
+ // Core Node.js strategy knobs
+ Environment.SetEnvironmentVariable("AGENT_USE_NODE10", null);
+ Environment.SetEnvironmentVariable("AGENT_USE_NODE20_1", null);
+ Environment.SetEnvironmentVariable("AGENT_USE_NODE24", null);
+ Environment.SetEnvironmentVariable("AGENT_USE_NODE24_WITH_HANDLER_DATA", null);
+ Environment.SetEnvironmentVariable("AGENT_USE_NODE", null);
+
+ // EOL and strategy control
+ Environment.SetEnvironmentVariable("AGENT_RESTRICT_EOL_NODE_VERSIONS", null);
+ Environment.SetEnvironmentVariable("AGENT_USE_NODE_STRATEGY", null);
+
+ // System-specific knobs
+ Environment.SetEnvironmentVariable("AGENT_USE_NODE20_IN_UNSUPPORTED_SYSTEM", null);
+ Environment.SetEnvironmentVariable("AGENT_USE_NODE24_IN_UNSUPPORTED_SYSTEM", null);
+
+ }
+ }
+
+ public class TestResult
+ {
+ public bool Success { get; set; }
+ public string NodePath { get; set; }
+ public Exception Exception { get; set; }
+ }
+
+ public class ScenarioExpectations
+ {
+ public string ExpectedNode { get; set; }
+ // public bool ExpectSuccess { get; set; }
+ public string ExpectedError { get; set; }
+ }
+}
\ No newline at end of file