diff --git a/docs/configuration/configuration-reference.md b/docs/configuration/configuration-reference.md
index 9ec2f38..78c6703 100644
--- a/docs/configuration/configuration-reference.md
+++ b/docs/configuration/configuration-reference.md
@@ -26,6 +26,7 @@ Xping SDK supports multiple configuration methods with the following priority or
| `FlushInterval` | TimeSpan | `30s` | `XPING_FLUSHINTERVAL` | Auto-flush interval |
| `Environment` | string | `Local` | `XPING_ENVIRONMENT` | Environment name |
| `AutoDetectCIEnvironment` | bool | `true` | `XPING_AUTODETECTCIENVIRONMENT` | Auto-detect CI/CD |
+| `CiEnvironmentName` | string | `CI` | `XPING_CIENVIRONMENTNAME` | Label used for auto-detected CI executions |
| `Enabled` | bool | `true` | `XPING_ENABLED` | SDK enabled/disabled |
| `CaptureStackTraces` | bool | `true` | `XPING_CAPTURESTACKTRACES` | Include stack traces |
| `EnableCompression` | bool | `true` | `XPING_ENABLECOMPRESSION` | Compress uploads |
@@ -414,11 +415,12 @@ export XPING_ENVIRONMENT="Staging"
The SDK determines the environment name using the following priority (highest to lowest):
1. **`XPING_ENVIRONMENT` environment variable** - Explicit Xping-specific setting (highest priority)
-2. **Auto-detected CI** - Returns `"CI"` when `AutoDetectCIEnvironment=true` and running in a detected CI/CD platform
+2. **Auto-detected CI** - Returns `CiEnvironmentName` (default `"CI"`) when `AutoDetectCIEnvironment=true` and running in a detected CI/CD platform
3. **`Environment` configuration property** - Value set programmatically or in configuration files
-4. **Default** - Returns `"Local"` when none of the above are set
+4. **Framework environment variables** - `ASPNETCORE_ENVIRONMENT`, then `DOTNET_ENVIRONMENT`
+5. **Default** - Returns `"Local"` when none of the above are set
-**Example:** If you won't specify `Environment` and `AutoDetectCIEnvironment=false`, Xping will use `"Local"` as the environment name. However, setting `XPING_ENVIRONMENT=Staging` will override this and use `"Staging"` instead.
+**Example:** If you don't specify `Environment` and `AutoDetectCIEnvironment=false`, Xping will use `DOTNET_ENVIRONMENT`/`ASPNETCORE_ENVIRONMENT` when available, and otherwise fall back to `"Local"`. Setting `XPING_ENVIRONMENT=Staging` still overrides everything and uses `"Staging"` instead.
---
@@ -428,7 +430,7 @@ The SDK determines the environment name using the following priority (highest to
**Default:** `true`
**Environment Variable:** `XPING_AUTODETECTCIENVIRONMENT`
-Automatically detect when running in CI/CD environments and set `Environment` to `"CI"`. Also captures CI-specific metadata (build numbers, commit SHAs, etc.).
+Automatically detect when running in CI/CD environments and set `Environment` to `CiEnvironmentName` (default `"CI"`). Also captures CI-specific metadata (build numbers, commit SHAs, branch names, etc.).
**Supported CI/CD platforms:**
- GitHub Actions
@@ -462,6 +464,31 @@ export XPING_AUTODETECTCIENVIRONMENT="false"
---
+### CiEnvironmentName
+
+**Type:** `string`
+**Default:** `"CI"`
+**Environment Variable:** `XPING_CIENVIRONMENTNAME`
+
+Overrides the label used when CI/CD is auto-detected. This is useful when you want CI executions grouped under a more specific environment name such as `"BuildPipeline"` or `"PullRequestValidation"` without disabling auto-detection.
+
+**Example:**
+
+```json
+{
+ "Xping": {
+ "AutoDetectCIEnvironment": true,
+ "CiEnvironmentName": "BuildPipeline"
+ }
+}
+```
+
+```bash
+export XPING_CIENVIRONMENTNAME="BuildPipeline"
+```
+
+---
+
## Feature Flags
### Enabled
diff --git a/docs/getting-started/ci-cd-setup.md b/docs/getting-started/ci-cd-setup.md
index 34e760d..1453251 100644
--- a/docs/getting-started/ci-cd-setup.md
+++ b/docs/getting-started/ci-cd-setup.md
@@ -6,7 +6,7 @@ Learn how to integrate Xping SDK into your CI/CD pipelines for continuous test r
## Overview
-Xping SDK automatically detects CI/CD environments and captures relevant metadata like build numbers, commit SHAs, and branch names. This enables you to:
+Xping SDK automatically detects CI/CD environments and captures relevant metadata like build numbers, commit SHAs, and branch names. It also marks non-CI executions as local developer-machine runs. This enables you to:
- **Track test reliability across builds**
- **Detect flaky tests in your pipeline**
@@ -87,6 +87,7 @@ Xping automatically captures:
- `GITHUB_RUN_NUMBER` - Sequential run number
- `GITHUB_SHA` - Commit SHA
- `GITHUB_REF` - Branch or tag ref
+- `GITHUB_HEAD_REF` / `GITHUB_REF_NAME` - Normalized into `CI.Branch`
- `GITHUB_REPOSITORY` - Repository name
- `GITHUB_ACTOR` - User who triggered the workflow
@@ -216,7 +217,7 @@ Xping automatically captures:
- `CI_PIPELINE_ID` - Unique pipeline ID
- `CI_JOB_ID` - Job ID
- `CI_COMMIT_SHA` - Commit SHA
-- `CI_COMMIT_REF_NAME` - Branch or tag name
+- `CI_COMMIT_BRANCH` / `CI_COMMIT_REF_NAME` - Normalized into `CI.Branch`
- `CI_PROJECT_PATH` - Repository path
- `GITLAB_USER_LOGIN` - User who triggered the pipeline
@@ -547,6 +548,15 @@ Track different branches in different Xping projects:
run: dotnet test
```
+### Custom CI Environment Label
+
+If you want auto-detected CI runs grouped under a label other than the default `CI`, set `XPING_CIENVIRONMENTNAME`:
+
+```yaml
+env:
+ XPING_CIENVIRONMENTNAME: "BuildPipeline"
+```
+
---
## Verification Checklist
diff --git a/src/Xping.Sdk.Core/Configuration/XpingConfiguration.cs b/src/Xping.Sdk.Core/Configuration/XpingConfiguration.cs
index a82ed81..a164355 100644
--- a/src/Xping.Sdk.Core/Configuration/XpingConfiguration.cs
+++ b/src/Xping.Sdk.Core/Configuration/XpingConfiguration.cs
@@ -17,6 +17,13 @@ public sealed class XpingConfiguration
///
public const string DefaultEnvironment = "Local";
+ ///
+ /// Represents the default value for when CI/CD is auto-detected and no explicit override is configured.
+ ///
+ public const string DefaultCiEnvironment = "CI";
+
+ private string? _environment;
+
///
/// Gets or sets the Xping API endpoint URL.
///
@@ -57,14 +64,25 @@ public sealed class XpingConfiguration
///
/// Gets or sets the environment name (e.g., "Local", "CI", "Staging", "Production").
+ /// When left unset, the SDK falls back to unless a CI or framework-specific
+ /// environment variable takes precedence during environment detection.
///
- public string Environment { get; set; } = DefaultEnvironment;
+ public string Environment
+ {
+ get => string.IsNullOrWhiteSpace(_environment) ? DefaultEnvironment : _environment!;
+ set => _environment = value;
+ }
///
/// Gets or sets a value indicating whether to automatically detect CI/CD environments.
///
public bool AutoDetectCIEnvironment { get; set; } = true;
+ ///
+ /// Gets or sets the environment name to use when CI/CD is auto-detected.
+ ///
+ public string CiEnvironmentName { get; set; } = DefaultCiEnvironment;
+
///
/// Gets or sets a value indicating whether the SDK is enabled.
///
@@ -213,4 +231,6 @@ public bool IsValid()
{
return Validate().Count == 0;
}
+
+ internal bool HasExplicitEnvironment => !string.IsNullOrWhiteSpace(_environment);
}
diff --git a/src/Xping.Sdk.Core/Configuration/XpingConfigurationBuilder.cs b/src/Xping.Sdk.Core/Configuration/XpingConfigurationBuilder.cs
index b4cff7c..28ae120 100644
--- a/src/Xping.Sdk.Core/Configuration/XpingConfigurationBuilder.cs
+++ b/src/Xping.Sdk.Core/Configuration/XpingConfigurationBuilder.cs
@@ -89,6 +89,17 @@ public XpingConfigurationBuilder WithAutoDetectCIEnvironment(bool autoDetect)
return this;
}
+ ///
+ /// Sets the environment name to use when CI/CD is auto-detected.
+ ///
+ /// The CI/CD environment name.
+ /// The builder instance for method chaining.
+ public XpingConfigurationBuilder WithCiEnvironmentName(string ciEnvironmentName)
+ {
+ _configuration.CiEnvironmentName = ciEnvironmentName;
+ return this;
+ }
+
///
/// Sets whether the SDK is enabled.
///
diff --git a/src/Xping.Sdk.Core/Extensions/XpingServiceCollectionExtensions.cs b/src/Xping.Sdk.Core/Extensions/XpingServiceCollectionExtensions.cs
index 9734131..30d81c8 100644
--- a/src/Xping.Sdk.Core/Extensions/XpingServiceCollectionExtensions.cs
+++ b/src/Xping.Sdk.Core/Extensions/XpingServiceCollectionExtensions.cs
@@ -492,6 +492,9 @@ private static void BindEnvironmentVariablesWithPrefix(XpingConfiguration config
&& bool.TryParse(autoDetect, out var ad))
config.AutoDetectCIEnvironment = ad;
+ if (GetEnv("CIENVIRONMENTNAME") is { } ciEnvironmentName)
+ config.CiEnvironmentName = ciEnvironmentName;
+
// Feature Flags
if (GetEnv("ENABLED") is { } enabled && bool.TryParse(enabled, out var e))
config.Enabled = e;
@@ -555,8 +558,13 @@ private static void CopyConfiguration(XpingConfiguration source, XpingConfigurat
target.ProjectId = source.ProjectId;
target.BatchSize = source.BatchSize;
target.FlushInterval = source.FlushInterval;
- target.Environment = source.Environment;
+ if (source.HasExplicitEnvironment)
+ {
+ target.Environment = source.Environment;
+ }
+
target.AutoDetectCIEnvironment = source.AutoDetectCIEnvironment;
+ target.CiEnvironmentName = source.CiEnvironmentName;
target.Enabled = source.Enabled;
target.CaptureStackTraces = source.CaptureStackTraces;
target.EnableCompression = source.EnableCompression;
diff --git a/src/Xping.Sdk.Core/Services/Environment/IEnvironmentDetector.cs b/src/Xping.Sdk.Core/Services/Environment/IEnvironmentDetector.cs
index 8cadf9d..6bfaf5e 100644
--- a/src/Xping.Sdk.Core/Services/Environment/IEnvironmentDetector.cs
+++ b/src/Xping.Sdk.Core/Services/Environment/IEnvironmentDetector.cs
@@ -52,7 +52,8 @@ public interface IEnvironmentDetector
///
/// Gets the environment name based on configuration and auto-detection rules.
/// Priority:
- /// XPING_ENVIRONMENT > AutoDetectCI > Options.Environment > ASPNETCORE_ENVIRONMENT/DOTNET_ENVIRONMENT > "Local"
+ /// XPING_ENVIRONMENT > AutoDetectCI > explicitly configured Options.Environment >
+ /// ASPNETCORE_ENVIRONMENT/DOTNET_ENVIRONMENT > "Local"
///
///
/// The environment name is used in confidence calculations, which are performed both globally across all executions
diff --git a/src/Xping.Sdk.Core/Services/Environment/Internals/EnvironmentDetector.cs b/src/Xping.Sdk.Core/Services/Environment/Internals/EnvironmentDetector.cs
index f699874..7fb974a 100644
--- a/src/Xping.Sdk.Core/Services/Environment/Internals/EnvironmentDetector.cs
+++ b/src/Xping.Sdk.Core/Services/Environment/Internals/EnvironmentDetector.cs
@@ -347,11 +347,13 @@ private string DetectEnvironmentName(XpingConfiguration configuration)
// Priority 2: Auto-detect CI if enabled
if (configuration.AutoDetectCIEnvironment && _ciPlatform.Value.HasValue)
{
- return "CI";
+ return string.IsNullOrWhiteSpace(configuration.CiEnvironmentName)
+ ? XpingConfiguration.DefaultCiEnvironment
+ : configuration.CiEnvironmentName;
}
- // Priority 3: Use configuration property
- if (!string.IsNullOrWhiteSpace(configuration.Environment))
+ // Priority 3: Use explicitly configured environment property
+ if (configuration.HasExplicitEnvironment)
{
return configuration.Environment;
}
@@ -369,8 +371,8 @@ private string DetectEnvironmentName(XpingConfiguration configuration)
return dotnetEnv!;
}
- // Priority 5: Default to "Local"
- return XpingConfiguration.DefaultEnvironment;
+ // Priority 5: Default to configured/default local environment
+ return configuration.Environment;
}
private bool DetectIsContainer()
@@ -415,6 +417,14 @@ private Dictionary CollectCustomProperties(string operatingSyste
{
Dictionary properties = new Dictionary();
+ properties["ExecutionContext"] = ciPlatform.HasValue
+ ? XpingConfiguration.DefaultCiEnvironment
+ : XpingConfiguration.DefaultEnvironment;
+ if (!ciPlatform.HasValue && !isContainer)
+ {
+ properties["IsDeveloperMachine"] = "true";
+ }
+
// Add container information
if (isContainer)
{
@@ -468,6 +478,12 @@ private Dictionary CollectCustomProperties(string operatingSyste
AddIfNotNull(properties, "CI.RunId", GetEnvironmentVariable("GITHUB_RUN_ID"));
AddIfNotNull(properties, "CI.RunNumber", GetEnvironmentVariable("GITHUB_RUN_NUMBER"));
AddIfNotNull(properties, "CI.Ref", GetEnvironmentVariable("GITHUB_REF"));
+ AddIfNotNull(properties, "CI.Branch", GetFirstNonEmptyValue(
+ GetEnvironmentVariable("GITHUB_HEAD_REF"),
+ GetEnvironmentVariable("GITHUB_REF_NAME"),
+ ExtractBranchName(GetEnvironmentVariable("GITHUB_REF"))));
+ AddIfNotNull(properties, "CI.HeadBranch", GetEnvironmentVariable("GITHUB_HEAD_REF"));
+ AddIfNotNull(properties, "CI.BaseBranch", GetEnvironmentVariable("GITHUB_BASE_REF"));
AddIfNotNull(properties, "CI.SHA", GetEnvironmentVariable("GITHUB_SHA"));
AddIfNotNull(properties, "CI.Actor", GetEnvironmentVariable("GITHUB_ACTOR"));
AddIfNotNull(properties, "CI.Workflow", GetEnvironmentVariable("GITHUB_WORKFLOW"));
@@ -478,6 +494,9 @@ private Dictionary CollectCustomProperties(string operatingSyste
AddIfNotNull(properties, "CI.BuildNumber", GetEnvironmentVariable("BUILD_BUILDNUMBER"));
AddIfNotNull(properties, "CI.Repository", GetEnvironmentVariable("BUILD_REPOSITORY_NAME"));
AddIfNotNull(properties, "CI.SourceBranch", GetEnvironmentVariable("BUILD_SOURCEBRANCH"));
+ AddIfNotNull(properties, "CI.Branch", GetFirstNonEmptyValue(
+ GetEnvironmentVariable("BUILD_SOURCEBRANCHNAME"),
+ ExtractBranchName(GetEnvironmentVariable("BUILD_SOURCEBRANCH"))));
AddIfNotNull(properties, "CI.SourceVersion", GetEnvironmentVariable("BUILD_SOURCEVERSION"));
AddIfNotNull(properties, "CI.RequestedFor", GetEnvironmentVariable("BUILD_REQUESTEDFOR"));
break;
@@ -488,6 +507,7 @@ private Dictionary CollectCustomProperties(string operatingSyste
AddIfNotNull(properties, "CI.BuildUrl", GetEnvironmentVariable("BUILD_URL"));
AddIfNotNull(properties, "CI.GitCommit", GetEnvironmentVariable("GIT_COMMIT"));
AddIfNotNull(properties, "CI.GitBranch", GetEnvironmentVariable("GIT_BRANCH"));
+ AddIfNotNull(properties, "CI.Branch", GetEnvironmentVariable("GIT_BRANCH"));
break;
case CIPlatform.GitLabCI:
@@ -496,7 +516,11 @@ private Dictionary CollectCustomProperties(string operatingSyste
AddIfNotNull(properties, "CI.ProjectPath", GetEnvironmentVariable("CI_PROJECT_PATH"));
AddIfNotNull(properties, "CI.CommitSHA", GetEnvironmentVariable("CI_COMMIT_SHA"));
AddIfNotNull(properties, "CI.CommitBranch", GetEnvironmentVariable("CI_COMMIT_BRANCH"));
+ AddIfNotNull(properties, "CI.Branch", GetFirstNonEmptyValue(
+ GetEnvironmentVariable("CI_COMMIT_BRANCH"),
+ GetEnvironmentVariable("CI_COMMIT_REF_NAME")));
AddIfNotNull(properties, "CI.CommitAuthor", GetEnvironmentVariable("CI_COMMIT_AUTHOR"));
+ AddIfNotNull(properties, "CI.Actor", GetEnvironmentVariable("GITLAB_USER_LOGIN"));
break;
case CIPlatform.CircleCI:
@@ -556,6 +580,32 @@ private static void AddIfNotNull(Dictionary dictionary, string k
}
}
+ private static string? GetFirstNonEmptyValue(params string?[] values)
+ {
+ foreach (string? value in values)
+ {
+ if (!string.IsNullOrWhiteSpace(value))
+ {
+ return value;
+ }
+ }
+
+ return null;
+ }
+
+ private static string? ExtractBranchName(string? gitRef)
+ {
+ if (string.IsNullOrWhiteSpace(gitRef))
+ {
+ return null;
+ }
+
+ const string headsPrefix = "refs/heads/";
+ return gitRef!.StartsWith(headsPrefix, StringComparison.OrdinalIgnoreCase)
+ ? gitRef.Substring(headsPrefix.Length, gitRef.Length - headsPrefix.Length)
+ : gitRef;
+ }
+
private static string? GetEnvironmentVariable(string variable)
{
try
diff --git a/tests/Xping.Sdk.Core.Tests/Configuration/XpingConfigurationBuilderTests.cs b/tests/Xping.Sdk.Core.Tests/Configuration/XpingConfigurationBuilderTests.cs
index 48107bc..7b3047f 100644
--- a/tests/Xping.Sdk.Core.Tests/Configuration/XpingConfigurationBuilderTests.cs
+++ b/tests/Xping.Sdk.Core.Tests/Configuration/XpingConfigurationBuilderTests.cs
@@ -39,6 +39,7 @@ public void BuilderShouldAllowSettingAllProperties()
.WithFlushInterval(TimeSpan.FromMinutes(1))
.WithEnvironment("Production")
.WithAutoDetectCIEnvironment(false)
+ .WithCiEnvironmentName("Pipeline")
.WithEnabled(false)
.WithCaptureStackTraces(false)
.WithEnableCompression(false)
@@ -58,6 +59,7 @@ public void BuilderShouldAllowSettingAllProperties()
Assert.Equal(TimeSpan.FromMinutes(1), config.FlushInterval);
Assert.Equal("Production", config.Environment);
Assert.False(config.AutoDetectCIEnvironment);
+ Assert.Equal("Pipeline", config.CiEnvironmentName);
Assert.False(config.Enabled);
Assert.False(config.CaptureStackTraces);
Assert.False(config.EnableCompression);
diff --git a/tests/Xping.Sdk.Core.Tests/Configuration/XpingConfigurationTests.cs b/tests/Xping.Sdk.Core.Tests/Configuration/XpingConfigurationTests.cs
index 1025752..88b8882 100644
--- a/tests/Xping.Sdk.Core.Tests/Configuration/XpingConfigurationTests.cs
+++ b/tests/Xping.Sdk.Core.Tests/Configuration/XpingConfigurationTests.cs
@@ -23,6 +23,7 @@ public void DefaultConfigurationShouldHaveCorrectValues()
Assert.Equal(TimeSpan.FromSeconds(30), config.FlushInterval);
Assert.Equal("Local", config.Environment);
Assert.True(config.AutoDetectCIEnvironment);
+ Assert.Equal("CI", config.CiEnvironmentName);
Assert.True(config.Enabled);
Assert.True(config.CaptureStackTraces);
Assert.True(config.EnableCompression);
diff --git a/tests/Xping.Sdk.Core.Tests/Extensions/XpingServiceCollectionExtensionsTests.cs b/tests/Xping.Sdk.Core.Tests/Extensions/XpingServiceCollectionExtensionsTests.cs
index 689aae2..300a4d3 100644
--- a/tests/Xping.Sdk.Core.Tests/Extensions/XpingServiceCollectionExtensionsTests.cs
+++ b/tests/Xping.Sdk.Core.Tests/Extensions/XpingServiceCollectionExtensionsTests.cs
@@ -646,4 +646,19 @@ public void BindEnvVars_AUTODETECTCIENVIRONMENT_ShouldParseBool()
Assert.False(bound.AutoDetectCIEnvironment);
}
+
+ [Fact]
+ public void BindEnvVars_CIENVIRONMENTNAME_ShouldOverrideConfiguredValue()
+ {
+ using var _key = WithEnv("XPING_APIKEY", "k");
+ using var _proj = WithEnv("XPING_PROJECTID", "p");
+ using var _ = WithEnv("XPING_CIENVIRONMENTNAME", "BuildPipeline");
+
+ var services = new ServiceCollection();
+ services.AddXpingConfigurationFromConfiguration(InMemoryXpingConfig());
+ var bound = services.BuildServiceProvider()
+ .GetRequiredService>().Value;
+
+ Assert.Equal("BuildPipeline", bound.CiEnvironmentName);
+ }
}
diff --git a/tests/Xping.Sdk.Core.Tests/Services/Environment/EnvironmentDetectorTests.cs b/tests/Xping.Sdk.Core.Tests/Services/Environment/EnvironmentDetectorTests.cs
new file mode 100644
index 0000000..5fbd672
--- /dev/null
+++ b/tests/Xping.Sdk.Core.Tests/Services/Environment/EnvironmentDetectorTests.cs
@@ -0,0 +1,142 @@
+/*
+ * © 2026 Xping.io. All Rights Reserved.
+ * License: [MIT]
+ */
+
+using Microsoft.Extensions.Options;
+using Moq;
+using Xping.Sdk.Core.Configuration;
+using Xping.Sdk.Core.Models.Environments;
+using Xping.Sdk.Core.Services.Environment;
+using Xping.Sdk.Core.Services.Environment.Internals;
+using Xping.Sdk.Core.Services.Network;
+
+namespace Xping.Sdk.Core.Tests.Services.Environment;
+
+[Collection("Sequential")]
+public sealed class EnvironmentDetectorTests
+{
+ private static readonly string[] _environmentVariables =
+ [
+ "CI",
+ "GITHUB_ACTIONS",
+ "TF_BUILD",
+ "JENKINS_URL",
+ "GITLAB_CI",
+ "CIRCLECI",
+ "TRAVIS",
+ "TEAMCITY_VERSION",
+ "BITBUCKET_PIPELINE_UUID",
+ "APPVEYOR",
+ "XPING_ENVIRONMENT",
+ "ASPNETCORE_ENVIRONMENT",
+ "DOTNET_ENVIRONMENT",
+ ];
+
+ [Fact]
+ public async Task BuildEnvironmentInfoAsync_WithDotnetEnvironmentAndDefaultConfiguration_UsesDotnetEnvironment()
+ {
+ using var clearedCiVariables = ClearEnvironmentVariables(_environmentVariables);
+ using var dotnetEnvironment = new EnvRestorer("DOTNET_ENVIRONMENT", "Development");
+
+ IEnvironmentDetector detector = CreateDetector();
+
+ EnvironmentInfo info = await detector.BuildEnvironmentInfoAsync();
+
+ Assert.Equal("Development", info.EnvironmentName);
+ Assert.False(info.IsCIEnvironment);
+ }
+
+ [Fact]
+ public async Task BuildEnvironmentInfoAsync_WithLocalExecution_MarksDeveloperMachine()
+ {
+ using var clearedCiVariables = ClearEnvironmentVariables(_environmentVariables);
+
+ IEnvironmentDetector detector = CreateDetector();
+
+ EnvironmentInfo info = await detector.BuildEnvironmentInfoAsync();
+
+ Assert.False(info.IsCIEnvironment);
+ Assert.Equal("Local", info.EnvironmentName);
+ Assert.Equal("Local", info.CustomProperties["ExecutionContext"]);
+ Assert.Equal("true", info.CustomProperties["IsDeveloperMachine"]);
+ }
+
+ [Fact]
+ public async Task BuildEnvironmentInfoAsync_WithGitHubActions_CapturesNormalizedBranchAndConfiguredCiName()
+ {
+ using var githubActions = new EnvRestorer("GITHUB_ACTIONS", "true");
+ using var githubHeadRef = new EnvRestorer("GITHUB_HEAD_REF", "feature/refactor-environment");
+ using var githubRefName = new EnvRestorer("GITHUB_REF_NAME", "17/merge");
+ using var githubRef = new EnvRestorer("GITHUB_REF", "refs/pull/17/merge");
+ using var githubRepository = new EnvRestorer("GITHUB_REPOSITORY", "xping-dev/sdk-dotnet");
+ using var githubRunId = new EnvRestorer("GITHUB_RUN_ID", "42");
+ using var githubSha = new EnvRestorer("GITHUB_SHA", "abc123");
+ using var githubActor = new EnvRestorer("GITHUB_ACTOR", "octocat");
+
+ IEnvironmentDetector detector = CreateDetector(new XpingConfiguration
+ {
+ CiEnvironmentName = "BuildPipeline",
+ });
+
+ EnvironmentInfo info = await detector.BuildEnvironmentInfoAsync();
+
+ Assert.True(info.IsCIEnvironment);
+ Assert.Equal("BuildPipeline", info.EnvironmentName);
+ Assert.Equal("CI", info.CustomProperties["ExecutionContext"]);
+ Assert.Equal("GitHubActions", info.CustomProperties["CIPlatform"]);
+ Assert.Equal("feature/refactor-environment", info.CustomProperties["CI.Branch"]);
+ Assert.Equal("refs/pull/17/merge", info.CustomProperties["CI.Ref"]);
+ Assert.DoesNotContain("IsDeveloperMachine", info.CustomProperties.Keys);
+ }
+
+ private static EnvironmentDetector CreateDetector(XpingConfiguration? configuration = null)
+ {
+ XpingConfiguration resolvedConfiguration = configuration ?? new XpingConfiguration();
+ resolvedConfiguration.CollectNetworkMetrics = false;
+
+ return new EnvironmentDetector(
+ Options.Create(resolvedConfiguration),
+ Mock.Of());
+ }
+
+ private static CompositeDisposable ClearEnvironmentVariables(IEnumerable variableNames)
+ {
+ List restorers = [];
+ foreach (string variableName in variableNames)
+ {
+ restorers.Add(new EnvRestorer(variableName, null));
+ }
+
+ return new CompositeDisposable(restorers);
+ }
+
+ private sealed class EnvRestorer : IDisposable
+ {
+ private readonly string _name;
+ private readonly string? _originalValue;
+
+ public EnvRestorer(string name, string? value)
+ {
+ _name = name;
+ _originalValue = System.Environment.GetEnvironmentVariable(name);
+ System.Environment.SetEnvironmentVariable(name, value);
+ }
+
+ public void Dispose()
+ {
+ System.Environment.SetEnvironmentVariable(_name, _originalValue);
+ }
+ }
+
+ private sealed class CompositeDisposable(IEnumerable disposables) : IDisposable
+ {
+ public void Dispose()
+ {
+ foreach (IDisposable disposable in disposables.Reverse())
+ {
+ disposable.Dispose();
+ }
+ }
+ }
+}