Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ internal static class CloudAssetServiceExtensions
internal static IServiceCollection AddCloudAssetServices(this IServiceCollection services)
=> services
.AddSingleton<ICloudAssetStore, CloudAssetStore>()
.AddSingleton<ICloudAssetDownloader, FileCloudAssetDownloader>()
.AddSingleton<CloudAssetOrchestrator>()
.AddSingleton<IPreScopeInitializer>(sp => sp.GetRequiredService<CloudAssetOrchestrator>())
.AddHostedService(sp => sp.GetRequiredService<CloudAssetOrchestrator>());
Comment on lines 9 to 15
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AddCloudAssetServices registers FileCloudAssetDownloader as the only ICloudAssetDownloader, but CloudSettings.StubFilePath defaults to an empty string and src/AdaptiveRemote/appsettings.json has no CloudSettings section. In non-Development environments this makes GetActiveAsync return null and CloudAssetOrchestrator throws, preventing the app from starting. Consider making downloader selection conditional (stub downloader only when StubFilePath is configured) and/or providing a non-empty default/fallback downloader so Production startup isn't broken.

Copilot uses AI. Check for mistakes.

internal static IServiceCollection AddScopedCloudAsset<T>(
this IServiceCollection services, string name)
this IServiceCollection services, ICloudAsset<T> asset)
where T : class
=> services.AddScoped(sp => sp.GetRequiredService<ICloudAssetStore>().Get<T>(name));
=> services
.AddSingleton<ICloudAsset>(asset)
.AddScoped(sp => sp.GetRequiredService<ICloudAssetStore>().Get<T>(asset.Name));
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ internal static IServiceCollection AddRemoteServices(this IServiceCollection ser
=> services
.AddApplicationLifecycleServices()
.AddCloudAssetServices()
.AddScopedCloudAsset<CompiledLayout>("layout")
.AddScopedCloudAsset(new JsonCloudAsset<CompiledLayout>(
name: "layout",
streamUrl: "/notifications/layouts/stream",
eventName: "layout-ready",
resourcePath: "/layouts/compiled",
jsonContext: LayoutContractsJsonContext.Default))
.AddScopedLifecycleService<LifecycleCommandService>()
.AddScoped<IRemoteDefinitionService, RemoteLayoutDefinitionService>()
.AddScoped<IDynamicStylesheetProvider, LayoutStylesheetProvider>()
Expand Down
8 changes: 8 additions & 0 deletions src/AdaptiveRemote.App/Logging/MessageLogger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,14 @@ public MessageLogger(ILogger logger)

// 1600–1699: CognitoTokenService

// 1700–1799: CloudAssetOrchestrator

[LoggerMessage(EventId = 1700, Level = LogLevel.Information, Message = "Downloading asset '{AssetName}'")]
public partial void CloudAssetOrchestrator_Downloading(string assetName);

[LoggerMessage(EventId = 1701, Level = LogLevel.Error, Message = "Failed to initialize cloud assets")]
public partial void CloudAssetOrchestrator_Failed(Exception error);

[LoggerMessage(EventId = 1600, Level = LogLevel.Information, Message = "Acquiring Cognito access token via Client Credentials flow")]
public partial void CognitoTokenService_AcquiringToken();

Expand Down
19 changes: 19 additions & 0 deletions src/AdaptiveRemote.App/Services/CloudAssets/BasicCloudAsset.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace AdaptiveRemote.Services.CloudAssets;

internal abstract class BasicCloudAsset<T> : ICloudAsset<T>
{
public string Name { get; }
public string StreamUrl { get; }
public string EventName { get; }
public string ResourcePath { get; }

protected BasicCloudAsset(string name, string streamUrl, string eventName, string resourcePath)
{
Name = name;
StreamUrl = streamUrl;
EventName = eventName;
ResourcePath = resourcePath;
}

public abstract Task<object> DeserializeAsync(Stream stream, CancellationToken ct);
}
Original file line number Diff line number Diff line change
@@ -1,71 +1,53 @@
using AdaptiveRemote.Contracts;
using AdaptiveRemote.Logging;
using AdaptiveRemote.Services.Lifecycle;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace AdaptiveRemote.Services.CloudAssets;

internal class CloudAssetOrchestrator : BackgroundService, IPreScopeInitializer
{
private readonly IEnumerable<ICloudAsset> _assets;
private readonly ICloudAssetDownloader _downloader;
private readonly ICloudAssetStore _store;
private readonly MessageLogger _log;
private readonly TaskCompletionSource _initCompleted = new();

public CloudAssetOrchestrator(ICloudAssetStore store)
public CloudAssetOrchestrator(
IEnumerable<ICloudAsset> assets,
ICloudAssetDownloader downloader,
ICloudAssetStore store,
ILogger<CloudAssetOrchestrator> logger)
{
_assets = assets;
_downloader = downloader;
_store = store;
_log = new MessageLogger(logger);
}

protected override Task ExecuteAsync(CancellationToken stoppingToken)
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
CompiledLayout layout = new(
Id: Guid.Empty,
RawLayoutId: Guid.Empty,
UserId: "stub",
IsActive: true,
Version: 1,
Elements:
[
new LayoutGroupDefinitionDto("DPAD",
[
new CommandDefinitionDto(CommandType.TiVo, "Up", "Up", null, "Sent Up", "Down", "Up"),
new CommandDefinitionDto(CommandType.TiVo, "Down", "Down", null, "Sent Down", "Up", "Down"),
new CommandDefinitionDto(CommandType.TiVo, "Left", "Left", null, "Sent Left", "Right", "Left"),
new CommandDefinitionDto(CommandType.TiVo, "Right", "Right", null, "Sent Right", "Left", "Right"),
new CommandDefinitionDto(CommandType.TiVo, "Select", "Select", null, "Sent Select", null, "Select"),
new CommandDefinitionDto(CommandType.TiVo, "Back", "Back", null, "Sent Back", null, "Back"),
new CommandDefinitionDto(CommandType.IR, "Power", "Power", null, "Sent Power", "Power", "Power"),
new CommandDefinitionDto(CommandType.IR, "PowerOn", "PowerOn", null, "Sent PowerOn", "PowerOff", "PowerOn"),
new CommandDefinitionDto(CommandType.IR, "PowerOff", "PowerOff", null, "Sent PowerOff", "PowerOn", "PowerOff"),
]),
new LayoutGroupDefinitionDto("WELL",
[
new CommandDefinitionDto(CommandType.TiVo, "TiVo", "TiVo", null, "Sent TiVo", null, "TiVo"),
new CommandDefinitionDto(CommandType.TiVo, "Netflix", "Netflix", null, "Sent Netflix", null, "Netflix"),
new CommandDefinitionDto(CommandType.TiVo, "Guide", "Guide", null, "Sent Guide", null, "Guide"),
]),
new LayoutGroupDefinitionDto("PLAYBACK",
[
new CommandDefinitionDto(CommandType.TiVo, "Play", "Play", null, "Sent Play", "Pause", "Play"),
new CommandDefinitionDto(CommandType.TiVo, "Pause", "Pause", null, "Sent Pause", "Play", "Pause"),
new CommandDefinitionDto(CommandType.TiVo, "Record", "Record", null, "Sent Record", null, "Record"),
new CommandDefinitionDto(CommandType.TiVo, "Skip", "Skip", null, "Sent Skip", "Replay", "Skip"),
new CommandDefinitionDto(CommandType.TiVo, "Replay", "Replay", null, "Sent Replay", "Skip", "Replay"),
]),
new LayoutGroupDefinitionDto("CHANNELANDVOLUME",
[
new CommandDefinitionDto(CommandType.TiVo, "ChannelUp", "Up", null, "Sent Channel Up", "ChannelDown", "ChannelUp"),
new CommandDefinitionDto(CommandType.TiVo, "ChannelDown", "Down", null, "Sent Channel Down", "ChannelUp", "ChannelDown"),
new CommandDefinitionDto(CommandType.IR, "VolumeUp", "Up", null, "Sent Volume Up", "VolumeDown", "VolumeUp"),
new CommandDefinitionDto(CommandType.IR, "VolumeDown", "Down", null, "Sent Volume Down", "VolumeUp", "VolumeDown"),
new CommandDefinitionDto(CommandType.IR, "Mute", "Mute", null, "Sent Mute", "Mute", "Mute"),
]),
],
CssDefinitions: "",
CompiledAt: DateTimeOffset.UtcNow);

_store.SetLayout(layout);
_initCompleted.SetResult();

return Task.CompletedTask;
try
{
foreach (ICloudAsset asset in _assets)
{
_log.CloudAssetOrchestrator_Downloading(asset.Name);
Stream stream = await _downloader.GetActiveAsync(asset.ResourcePath, stoppingToken)
?? throw new InvalidOperationException($"Failed to download asset '{asset.Name}'.");
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exception thrown when a download returns null ("Failed to download asset '{asset.Name}'.") doesn’t include enough context to diagnose configuration problems (e.g., which resourcePath was requested, whether this is the stub downloader, or what stub path was used). Consider including at least asset.ResourcePath and relevant downloader/config details in the error so failures are actionable from logs.

Suggested change
?? throw new InvalidOperationException($"Failed to download asset '{asset.Name}'.");
?? throw new InvalidOperationException(
$"Failed to download asset '{asset.Name}' for resource path '{asset.ResourcePath}'. Downloader: '{_downloader.GetType().FullName}'.");

Copilot uses AI. Check for mistakes.
await using (stream)
{
object value = await asset.DeserializeAsync(stream, stoppingToken);
_store.Set(asset.Name, value);
}
}
_initCompleted.SetResult();
}
catch (Exception ex)
{
_log.CloudAssetOrchestrator_Failed(ex);
_initCompleted.TrySetException(ex);
throw;
}
Comment on lines +28 to +50
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CloudAssetOrchestrator.ExecuteAsync now contains the core initialization logic (download → parse → store) and is a startup gate via IPreScopeInitializer, but there are no unit tests covering success and failure paths (e.g., null stream, parse failure, cancellation). Adding tests would help prevent regressions since failures here block the app from reaching Ready phase.

Copilot uses AI. Check for mistakes.
}

public Task WaitAsync(ILifecycleActivity activity, CancellationToken ct)
Expand Down

This file was deleted.

7 changes: 2 additions & 5 deletions src/AdaptiveRemote.App/Services/CloudAssets/CloudSettings.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
namespace AdaptiveRemote.Services.CloudAssets;

/// <summary>
/// Shared connection and auth settings for all cloud asset services.
/// Shared settings for all cloud asset services.
/// </summary>
internal class CloudSettings
{
public string BackendBaseUrl { get; set; } = "";
public string CognitoTokenEndpointUrl { get; set; } = "";
public string ClientId { get; set; } = "";
public string ClientSecret { get; set; } = "";
public int IdleCooldownSeconds { get; set; } = 30;
public int SseMaxConsecutiveFailures { get; set; } = 10;
public string CachePath { get; set; } = @"%LocalAppData%\AdaptiveRemote\CloudAssets";
public string StubFilePath { get; set; } = "dev/layout.json";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using AdaptiveRemote.Services;
using Microsoft.Extensions.Options;

namespace AdaptiveRemote.Services.CloudAssets;

internal sealed class FileCloudAssetDownloader : ICloudAssetDownloader
{
private readonly CloudSettings _settings;
private readonly IFileSystem _fileSystem;

public FileCloudAssetDownloader(IOptions<CloudSettings> options, IFileSystem fileSystem)
{
_settings = options.Value;
_fileSystem = fileSystem;
}

public Task<Stream?> GetActiveAsync(string resourcePath, CancellationToken ct)
{
string path = Environment.ExpandEnvironmentVariables(_settings.StubFilePath);
if (!_fileSystem.FileExists(path))
{
return Task.FromResult<Stream?>(null);
}
return Task.FromResult<Stream?>(_fileSystem.OpenRead(path));
}

public Task<Stream?> GetByIdAsync(string resourcePath, Guid id, CancellationToken ct)
=> Task.FromResult<Stream?>(null);
Comment on lines +19 to +28
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetActiveAsync uses StubFilePath verbatim (after env var expansion). With a relative path like dev/layout.json, the lookup depends on the process working directory, which can differ between dotnet run, test hosts, and deployed installs. Consider resolving relative paths against a stable base (e.g., AppContext.BaseDirectory or content root) and failing fast with a clear exception when StubFilePath is empty/whitespace.

Suggested change
string path = Environment.ExpandEnvironmentVariables(_settings.StubFilePath);
if (!_fileSystem.FileExists(path))
{
return Task.FromResult<Stream?>(null);
}
return Task.FromResult<Stream?>(_fileSystem.OpenRead(path));
}
public Task<Stream?> GetByIdAsync(string resourcePath, Guid id, CancellationToken ct)
=> Task.FromResult<Stream?>(null);
string path = GetResolvedStubFilePath();
if (!_fileSystem.FileExists(path))
{
return Task.FromResult<Stream?>(null);
}
return Task.FromResult<Stream?>(_fileSystem.OpenRead(path));
}
public Task<Stream?> GetByIdAsync(string resourcePath, Guid id, CancellationToken ct)
=> Task.FromResult<Stream?>(null);
private string GetResolvedStubFilePath()
{
if (string.IsNullOrWhiteSpace(_settings.StubFilePath))
{
throw new InvalidOperationException("CloudSettings.StubFilePath must be configured with a non-empty file path.");
}
string expandedPath = Environment.ExpandEnvironmentVariables(_settings.StubFilePath);
if (string.IsNullOrWhiteSpace(expandedPath))
{
throw new InvalidOperationException("CloudSettings.StubFilePath resolved to an empty file path after environment variable expansion.");
}
return Path.IsPathRooted(expandedPath)
? Path.GetFullPath(expandedPath)
: Path.GetFullPath(expandedPath, AppContext.BaseDirectory);
}

Copilot uses AI. Check for mistakes.
}
4 changes: 2 additions & 2 deletions src/AdaptiveRemote.App/Services/CloudAssets/ICloudAsset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ internal interface ICloudAsset
string ResourcePath { get; }

/// <summary>
/// Parses downloaded or cached bytes into the asset's runtime type.
/// Deserializes downloaded or cached bytes into the asset's runtime type.
/// </summary>
Task<object> ParseAsync(Stream stream, CancellationToken ct);
Task<object> DeserializeAsync(Stream stream, CancellationToken ct);
}

/// <summary>
Expand Down
20 changes: 20 additions & 0 deletions src/AdaptiveRemote.App/Services/CloudAssets/JsonCloudAsset.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System.Text.Json;
using System.Text.Json.Serialization;

namespace AdaptiveRemote.Services.CloudAssets;

internal sealed class JsonCloudAsset<T>(
string name,
string streamUrl,
string eventName,
string resourcePath,
JsonSerializerContext jsonContext)
: BasicCloudAsset<T>(name, streamUrl, eventName, resourcePath)
{
public override async Task<object> DeserializeAsync(Stream stream, CancellationToken ct)
{
object? result = await JsonSerializer.DeserializeAsync(stream, typeof(T), jsonContext, ct);
return result ?? throw new InvalidOperationException(
$"Deserialized null for asset '{Name}'.");
}
}
3 changes: 3 additions & 0 deletions src/AdaptiveRemote.Contracts/CommandType.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Text.Json.Serialization;

namespace AdaptiveRemote.Contracts;

// Identifies the runtime command type. The client uses this to instantiate the correct
Expand All @@ -6,4 +8,5 @@ namespace AdaptiveRemote.Contracts;
// TiVo — CommandId = Name.ToUpperInvariant() (existing convention)
// IR — payload programmed via remote, stored in ProgrammaticSettings
// Others — keyed by Name
[JsonConverter(typeof(JsonStringEnumConverter<CommandType>))]
public enum CommandType { Lifecycle, TiVo, IR }
5 changes: 5 additions & 0 deletions src/AdaptiveRemote.Headless/AdaptiveRemote.Headless.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<Content Update="appsettings.Development.json" CopyToOutputDirectory="PreserveNewest" />
<Content Update="dev\layout.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Playwright" />
</ItemGroup>
Expand Down
5 changes: 5 additions & 0 deletions src/AdaptiveRemote.Headless/appsettings.Development.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"CloudSettings": {
"StubFilePath": "dev/layout.json"
}
}
58 changes: 58 additions & 0 deletions src/AdaptiveRemote.Headless/dev/layout.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{
"id": "00000000-0000-0000-0000-000000000000",
"rawLayoutId": "00000000-0000-0000-0000-000000000000",
"userId": "stub",
"isActive": true,
"version": 1,
"elements": [
{
"$type": "group",
"cssId": "DPAD",
"children": [
{ "$type": "command", "type": "TiVo", "name": "Up", "label": "Up", "glyph": null, "speakPhrase": "Sent Up", "reverse": "Down", "cssId": "Up" },
{ "$type": "command", "type": "TiVo", "name": "Down", "label": "Down", "glyph": null, "speakPhrase": "Sent Down", "reverse": "Up", "cssId": "Down" },
{ "$type": "command", "type": "TiVo", "name": "Left", "label": "Left", "glyph": null, "speakPhrase": "Sent Left", "reverse": "Right", "cssId": "Left" },
{ "$type": "command", "type": "TiVo", "name": "Right", "label": "Right", "glyph": null, "speakPhrase": "Sent Right", "reverse": "Left", "cssId": "Right" },
{ "$type": "command", "type": "TiVo", "name": "Select", "label": "Select", "glyph": null, "speakPhrase": "Sent Select", "reverse": null, "cssId": "Select" },
{ "$type": "command", "type": "TiVo", "name": "Back", "label": "Back", "glyph": null, "speakPhrase": "Sent Back", "reverse": null, "cssId": "Back" },
{ "$type": "command", "type": "IR", "name": "Power", "label": "Power", "glyph": null, "speakPhrase": "Sent Power", "reverse": "Power", "cssId": "Power" },
{ "$type": "command", "type": "IR", "name": "PowerOn", "label": "PowerOn", "glyph": null, "speakPhrase": "Sent PowerOn", "reverse": "PowerOff", "cssId": "PowerOn" },
{ "$type": "command", "type": "IR", "name": "PowerOff", "label": "PowerOff", "glyph": null, "speakPhrase": "Sent PowerOff", "reverse": "PowerOn", "cssId": "PowerOff" }
]
},
{
"$type": "group",
"cssId": "WELL",
"children": [
{ "$type": "command", "type": "TiVo", "name": "TiVo", "label": "TiVo", "glyph": null, "speakPhrase": "Sent TiVo", "reverse": null, "cssId": "TiVo" },
{ "$type": "command", "type": "TiVo", "name": "Netflix", "label": "Netflix", "glyph": null, "speakPhrase": "Sent Netflix", "reverse": null, "cssId": "Netflix" },
{ "$type": "command", "type": "TiVo", "name": "Guide", "label": "Guide", "glyph": null, "speakPhrase": "Sent Guide", "reverse": null, "cssId": "Guide" },
{ "$type": "command", "type": "TiVo", "name": "Info", "label": "Info", "glyph": null, "speakPhrase": "Sent Info", "reverse": null, "cssId": "Info" }
]
},
{
"$type": "group",
"cssId": "PLAYBACK",
"children": [
{ "$type": "command", "type": "TiVo", "name": "Play", "label": "Play", "glyph": null, "speakPhrase": "Sent Play", "reverse": "Pause", "cssId": "Play" },
{ "$type": "command", "type": "TiVo", "name": "Pause", "label": "Pause", "glyph": null, "speakPhrase": "Sent Pause", "reverse": "Play", "cssId": "Pause" },
{ "$type": "command", "type": "TiVo", "name": "Record", "label": "Record", "glyph": null, "speakPhrase": "Sent Record", "reverse": null, "cssId": "Record" },
{ "$type": "command", "type": "TiVo", "name": "Skip", "label": "Skip", "glyph": null, "speakPhrase": "Sent Skip", "reverse": "Replay", "cssId": "Skip" },
{ "$type": "command", "type": "TiVo", "name": "Replay", "label": "Replay", "glyph": null, "speakPhrase": "Sent Replay", "reverse": "Skip", "cssId": "Replay" }
]
},
{
"$type": "group",
"cssId": "CHANNELANDVOLUME",
"children": [
{ "$type": "command", "type": "TiVo", "name": "ChannelUp", "label": "Up", "glyph": null, "speakPhrase": "Sent Channel Up", "reverse": "ChannelDown", "cssId": "ChannelUp" },
{ "$type": "command", "type": "TiVo", "name": "ChannelDown", "label": "Down", "glyph": null, "speakPhrase": "Sent Channel Down", "reverse": "ChannelUp", "cssId": "ChannelDown" },
{ "$type": "command", "type": "IR", "name": "VolumeUp", "label": "Up", "glyph": null, "speakPhrase": "Sent Volume Up", "reverse": "VolumeDown", "cssId": "VolumeUp" },
{ "$type": "command", "type": "IR", "name": "VolumeDown", "label": "Down", "glyph": null, "speakPhrase": "Sent Volume Down", "reverse": "VolumeUp", "cssId": "VolumeDown" },
{ "$type": "command", "type": "IR", "name": "Mute", "label": "Mute", "glyph": null, "speakPhrase": "Sent Mute", "reverse": "Mute", "cssId": "Mute" }
]
}
],
"cssDefinitions": "",
"compiledAt": "2026-04-19T00:00:00+00:00"
}
1 change: 1 addition & 0 deletions src/AdaptiveRemote/AdaptiveRemote.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<ItemGroup>
<Content Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
<Content Update="appsettings.Development.json" CopyToOutputDirectory="PreserveNewest" />
<Content Update="dev\layout.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

<ItemGroup>
Expand Down
3 changes: 3 additions & 0 deletions src/AdaptiveRemote/appsettings.Development.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,8 @@
"clientSecret": "",
"scope": ""
}
},
"CloudSettings": {
"StubFilePath": "dev/layout.json"
}
Comment on lines +11 to 13
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This setting is only present in appsettings.Development.json, but the WPF/console hosts use Host.CreateDefaultBuilder (driven by DOTNET_ENVIRONMENT). The WPF/console E2E host settings shown in test hooks don’t set DOTNET_ENVIRONMENT=Development, so this CloudSettings:StubFilePath will likely be ignored and the app will fail to load assets at startup. Consider either adding CloudSettings to appsettings.json as a safe default for now, or ensuring the test hosts set the correct environment variable.

Copilot uses AI. Check for mistakes.
}
Loading