diff --git a/.claude/commands/spec.md b/.claude/commands/spec.md index c236589c..48d66542 100644 --- a/.claude/commands/spec.md +++ b/.claude/commands/spec.md @@ -72,14 +72,18 @@ When the user signals they're ready: 1. Re-read the spec file with the Read tool. 2. Collect all `> **Review:** ...` markers and note any direct edits. -3. Address review comments **one at a time** in document order: - a. Present your analysis of the comment — the trade-offs, your - recommendation, and why. - b. **PAUSE — wait for the user's decision before editing.** - c. Update the spec to reflect the resolved decision; remove the - review marker. - d. Tell the user what changed, then move to the next comment. -4. After all comments are resolved, invite another review pass. +3. Present the **first unresolved** review comment only: + - State the comment and its location. + - Give your analysis — trade-offs, recommendation, and why. + **PAUSE — wait for the user's decision before doing anything else.** +4. Once the user decides: + a. Update the spec to reflect the decision; remove the review marker. + b. Tell the user what changed. + c. Present the **next** unresolved review comment (go to step 3). +5. After all comments are resolved, invite another review pass. + +**Never present more than one review comment at a time. Always pause for a +decision before moving to the next comment or making any edits.** Repeat Phase 3 until the user says the document is ready. @@ -119,6 +123,13 @@ When Phase 4 is complete: - Exit criteria as a checkbox list; for tasks that include new E2E tests, write those exit criteria as Gherkin-style acceptance scenarios (`Given / When / Then`) + + **Implementation ordering:** Order tasks so that primary consumers are + implemented before the dependencies they rely on. For each dependency that + isn't ready yet, introduce a stub that provides known-good static data + (e.g. hardcoded or serialized from the current implementation). Replace + stubs with real implementations one step at a time. Every task's exit + criteria must include: all existing unit and E2E tests pass. 3. If the spec has a `## Related Epics` section listing features to be spec'd separately, add those as placeholder entries in `## Tasks` as well — titled "Create epic: \" with a one-line scope description. diff --git a/.claude/commands/team-task.md b/.claude/commands/team-task.md index 198d6041..9ddb809a 100644 --- a/.claude/commands/team-task.md +++ b/.claude/commands/team-task.md @@ -20,12 +20,16 @@ this task from spec to merged PR. Keep your own context lean: - Direct sub-agents to read from and write to GitHub PR and Jira directly — do not relay large payloads through yourself -**Before starting**, capture the current working branch — this is the **base branch** -(PR target, Developer branches off this): +**Before starting**, capture the base branch — this is the PR target and the branch the +Developer will branch off. The skill may be invoked on an ephemeral harness branch, so +find the upstream remote branch at the current commit rather than using `git branch --show-current`: +```bash +# Find the remote branch this harness branch was created from +git branch -r --contains HEAD | grep -v 'HEAD\|claude/' | head -1 | sed 's|.*origin/||' | xargs echo ``` -git branch --show-current -``` + +If that returns nothing (no matching remote branch), fall back to `git branch --show-current`. --- @@ -126,6 +130,11 @@ Developer instructions: > **Task key:** TASK_KEY > **Base branch:** BASE_BRANCH > +> **Non-negotiable build rule:** After making any changes, you MUST run +> `scripts/validate-build.sh` before committing. Never run `dotnet build` directly. +> Never run `dotnet test`, `scripts/validate-tests.sh`, or any test command — a +> dedicated Tester agent handles all testing. +> > **Task brief:** > TASK_BRIEF > @@ -168,7 +177,7 @@ Developer instructions: > > **Step 4 — Build** > -> Do not run tests — that is the Tester agent's responsibility. Only run the build: +> Run the build validation script and nothing else: > > ``` > scripts/validate-build.sh @@ -177,6 +186,9 @@ Developer instructions: > The script stages new files and cleans before building — do not run `git add -A` > separately. Fix all warnings and errors and re-run until the build is clean. > +> **NEVER run `dotnet test`, `scripts/validate-tests.sh`, or any other test command.** +> A dedicated Tester agent handles all testing. +> > **Step 5 — Commit and push** > > Commit format: `feat: description [TASK_KEY]` @@ -250,8 +262,8 @@ Tester instructions: > > Check out the branch and fix each failure. You may re-run individual tests to > verify a specific fix (e.g. `dotnet test --filter "FullyQualifiedName~TEST_NAME"`), -> but do not run the full suite — that is the Tester agent's job. When done, confirm -> the build is still clean, then commit and push: +> but **NEVER run `scripts/validate-tests.sh` or the full test suite** — that is the +> Tester agent's job. When done, confirm the build is still clean, then commit and push: > > ``` > git checkout BRANCH_NAME @@ -338,12 +350,32 @@ Reviewer instructions: > headless E2E coverage > - `.editorconfig` compliance > -> For each issue found, post a comment **directly to the PR**: -> ``` -> gh pr review PR_URL --comment -b "path/to/File.cs:LINE — your comment" +> Collect all issues into a JSON array (schema below). If the array is non-empty, +> post them as a **single formal PR review** with line-anchored comments so each +> becomes a resolvable discussion thread: +> +> ```bash +> # Extract PR number from PR_URL (e.g. .../pull/171 → 171) +> PR_NUMBER= +> COMMIT_SHA=$(git rev-parse HEAD) +> +> # Write review payload to a temp file +> cat > /tmp/review.json << EOF +> { +> "commit_id": "$COMMIT_SHA", +> "event": "COMMENT", +> "body": "Automated review — see inline comments.", +> "comments": [ +> { "path": "relative/path/to/File.cs", "line": 42, "body": "your comment" } +> ] +> } +> EOF +> +> gh api repos/jodavis/adaptiveremote/pulls/$PR_NUMBER/reviews \ +> -X POST --input /tmp/review.json > ``` > -> After posting all comments, return a JSON array. Return only the JSON — no other text. +> Return a JSON array. Return only the JSON — no other text. > An empty array means no issues. > > ```json @@ -374,6 +406,11 @@ Developer instructions: > **Branch:** BRANCH_NAME > **PR URL:** PR_URL > +> **Non-negotiable build rule:** After making any changes, you MUST run +> `scripts/validate-build.sh` before committing. Never run `dotnet build` directly. +> Never run `dotnet test`, `scripts/validate-tests.sh`, or any test command — a +> dedicated Tester agent handles all testing. +> > **Task brief:** > TASK_BRIEF > @@ -395,13 +432,16 @@ Developer instructions: > gh pr review PR_URL --comment -b "File.cs:LINE — [your rebuttal]" > ``` > -> **Step 4** — Build, commit, and push: +> **Step 4** — Build, commit, and push. Run only the build script — no test commands: > ``` > scripts/validate-build.sh > git commit -m "review: address feedback [TASK_KEY]" > git push > ``` > +> **NEVER run `dotnet test`, `scripts/validate-tests.sh`, or any other test command.** +> A dedicated Tester agent handles all testing. +> > Return: `{ "status": "done", "branch": "BRANCH_NAME" }` After Developer finishes, update `REVIEWER_BASELINE` to the current HEAD commit: @@ -437,12 +477,27 @@ Then spawn **Tester and scoped Reviewer in parallel** and wait for both. > accessibility regressions, or clear spec non-compliance. Do not raise style, naming, > or minor cleanup issues. > -> For each issue, post a comment directly to the PR: -> ``` -> gh pr review PR_URL --comment -b "path/to/File.cs:LINE — your comment" +> Collect all issues into a JSON array (same schema as Phase 5). If non-empty, +> post them as a single formal PR review with line-anchored comments: +> +> ```bash +> PR_NUMBER= +> COMMIT_SHA=$(git rev-parse HEAD) +> cat > /tmp/review.json << EOF +> { +> "commit_id": "$COMMIT_SHA", +> "event": "COMMENT", +> "body": "Follow-up review — see inline comments.", +> "comments": [ +> { "path": "relative/path/to/File.cs", "line": 42, "body": "your comment" } +> ] +> } +> EOF +> gh api repos/jodavis/adaptiveremote/pulls/$PR_NUMBER/reviews \ +> -X POST --input /tmp/review.json > ``` > -> Return a JSON array (same schema as before). Return only the JSON — no other text. +> Return a JSON array. Return only the JSON — no other text. > An empty array means all previous comments are resolved and no new significant issues exist. **Routing after both complete:** diff --git a/CLAUDE.md b/CLAUDE.md index af48d4d6..d2e42168 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,12 +75,30 @@ mock verification. Group setup calls into `Expect_*` helper methods. ### E2E tests Prefer the Headless host for new E2E tests — cross-platform, no display required: +**IMPORTANT:** Before running E2E tests for the first time, you must set up Playwright browsers. + +**On developer machines (Windows/Mac/Linux with internet access):** ```bash +# Build the Headless host first (required to generate the Playwright installation script) dotnet build src/AdaptiveRemote.Headless/AdaptiveRemote.Headless.csproj + +# Install Playwright browsers (one-time setup) pwsh src/AdaptiveRemote.Headless/bin/Debug/net10.0/playwright.ps1 install chromium # if tests crash at startup with a JSON-RPC disconnect + +dotnet test --filter "FullyQualifiedName~Host.Headless" +``` + +**In Claude Code cloud sandbox environments** (where `cdn.playwright.dev` is blocked by network +policy): browsers are pre-installed at `/opt/pw-browsers` and the environment is configured to point +Playwright there automatically. No extra setup is required: +```bash dotnet test --filter "FullyQualifiedName~Host.Headless" ``` +If Headless E2E tests fail with JSON-RPC connection errors in a cloud sandbox environment, this indicates +the environment configuration is broken — stop and report the problem rather than working around it with +the setup script. The goal is to be alerted when the environment stops working, not to silently fall back. + ## Documentation ### `_spec_*.md` — pre-implementation design docs diff --git a/Directory.Packages.props b/Directory.Packages.props index 18035c3c..8636062c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,8 +18,8 @@ - - + + diff --git a/scripts/validate-tests.cmd b/scripts/validate-tests.cmd index aa738179..90617970 100644 --- a/scripts/validate-tests.cmd +++ b/scripts/validate-tests.cmd @@ -2,6 +2,6 @@ pushd %~dp0.. dotnet test --no-build "%~dp0validate-unit-tests.proj" if %ERRORLEVEL% neq 0 ( popd & exit /b %ERRORLEVEL% ) -dotnet test --no-build "%~dp0validate-e2e-tests.proj" +dotnet test --no-build "%~dp0validate-e2e-tests.proj" -m:1 if %ERRORLEVEL% neq 0 ( popd & exit /b %ERRORLEVEL% ) popd diff --git a/scripts/validate-tests.sh b/scripts/validate-tests.sh old mode 100644 new mode 100755 index 8894f734..5e39717d --- a/scripts/validate-tests.sh +++ b/scripts/validate-tests.sh @@ -5,4 +5,4 @@ cd "$SCRIPT_DIR/.." echo 'Testing unit test projects...' dotnet test --no-build "$SCRIPT_DIR/validate-unit-tests.proj" echo 'Testing E2E test projects...' -dotnet test --no-build "$SCRIPT_DIR/validate-e2e-tests.proj" +dotnet test --no-build "$SCRIPT_DIR/validate-e2e-tests.proj" -m:1 diff --git a/src/AdaptiveRemote.App/Components/BlazorAppScope.cs b/src/AdaptiveRemote.App/Components/BlazorAppScope.cs index 358c1815..7dc762b7 100644 --- a/src/AdaptiveRemote.App/Components/BlazorAppScope.cs +++ b/src/AdaptiveRemote.App/Components/BlazorAppScope.cs @@ -1,5 +1,7 @@ -using AdaptiveRemote.Services.Lifecycle; +using AdaptiveRemote.Services.Lifecycle; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.JSInterop; namespace AdaptiveRemote.Components; @@ -47,12 +49,11 @@ public Task InvokeInScopeAsync(Func w return workItem(_serviceProvider, cancellationToken); } - public Task RecycleAsync() + public async Task RecycleAsync() { _logger.LogInformation("Recycling Blazor application scope."); - // In a real implementation, this would refresh the browser which should result - // in a new scope - throw new NotImplementedException(); + IJSRuntime jsRuntime = _serviceProvider.GetRequiredService(); + await jsRuntime.InvokeVoidAsync("location.reload"); } } diff --git a/src/AdaptiveRemote.App/Components/Remote.razor b/src/AdaptiveRemote.App/Components/Remote.razor index df6c23c0..8214921e 100644 --- a/src/AdaptiveRemote.App/Components/Remote.razor +++ b/src/AdaptiveRemote.App/Components/Remote.razor @@ -1,5 +1,10 @@ @inject Services.IRemoteDefinitionService RemoteDefinitions +@inject Services.IDynamicStylesheetProvider Stylesheet +@if (Stylesheet.GetCss() is { } css) +{ + +} diff --git a/src/AdaptiveRemote.App/Configuration/CloudAssetServiceExtensions.cs b/src/AdaptiveRemote.App/Configuration/CloudAssetServiceExtensions.cs new file mode 100644 index 00000000..2f18b664 --- /dev/null +++ b/src/AdaptiveRemote.App/Configuration/CloudAssetServiceExtensions.cs @@ -0,0 +1,34 @@ +using AdaptiveRemote.Models.CloudAssets; +using AdaptiveRemote.Services; +using AdaptiveRemote.Services.CloudAssets; +using AdaptiveRemote.Services.IdleDetection; +using AdaptiveRemote.Services.Lifecycle; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace AdaptiveRemote.Configuration; + +internal static class CloudAssetServiceExtensions +{ + internal static IServiceCollection AddCloudAssetServices(this IServiceCollection services, IConfiguration configuration) + => services + .AddSingleton() + .AddSingleton() + .AddScopedLifecycleService() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton(sp => sp.GetRequiredService()) + .AddHostedService(sp => sp.GetRequiredService()) + .AddSingleton() + .AddSingleton(sp => sp.GetRequiredService()) + .AddHostedService(sp => sp.GetRequiredService()) + .Configure(configuration); + + internal static IServiceCollection AddScopedCloudAsset( + this IServiceCollection services, ICloudAsset asset) + where T : class + => services + .AddSingleton(asset) + .AddScoped(sp => sp.GetRequiredService().Get(asset.Name)); +} diff --git a/src/AdaptiveRemote.App/Configuration/ConversationHostBuilderExtensions.cs b/src/AdaptiveRemote.App/Configuration/ConversationHostBuilderExtensions.cs index 2ac09a0c..034ecef3 100644 --- a/src/AdaptiveRemote.App/Configuration/ConversationHostBuilderExtensions.cs +++ b/src/AdaptiveRemote.App/Configuration/ConversationHostBuilderExtensions.cs @@ -22,7 +22,8 @@ internal static IServiceCollection AddConversationServices(this IServiceCollecti .AddSingleton() .AddScoped(GetConversationViewModel) .AddSingleton() - .AddSingleton(); + .AddSingleton() + .AddScoped(); internal static IServiceCollection AddConversationServices(this IServiceCollection services, IConfiguration config) => services diff --git a/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs b/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs index f5dddb7f..453da4a5 100644 --- a/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs +++ b/src/AdaptiveRemote.App/Configuration/HostBuilderExtensions.cs @@ -1,5 +1,8 @@ -using AdaptiveRemote.Services; +using AdaptiveRemote.Contracts; +using AdaptiveRemote.Models.CloudAssets; +using AdaptiveRemote.Services; using AdaptiveRemote.Services.Commands; +using AdaptiveRemote.Services.Layout; using AdaptiveRemote.Services.Lifecycle; using AdaptiveRemote.Services.ProgrammaticSettings; using Microsoft.Extensions.Configuration; @@ -25,10 +28,15 @@ internal static IHostBuilder AddRemoteServices(this IHostBuilder builder) internal static IServiceCollection AddRemoteServices(this IServiceCollection services, IConfiguration configuration) => services .AddApplicationLifecycleServices() - .AddScopedLifecycleService() - .AddScoped() - .AddSingleton() - .Configure(configuration.GetSection(SettingsKeys.ProgrammaticSettings)); + .AddCloudAssetServices(configuration.GetSection(SettingsKeys.CloudSettings)) + .AddScopedCloudAsset(new JsonCloudAsset( + name: "layout", + streamUrl: "/notifications/layouts/stream", + eventName: "layout-ready", + resourcePath: "/layouts/compiled", + jsonContext: LayoutContractsJsonContext.Default)) + .AddCommandSystemServices() + .AddProgrammaticSettingsServices(configuration.GetSection(SettingsKeys.ProgrammaticSettings)); internal static IServiceCollection AddScopedLifecycleService(this IServiceCollection services) where ServiceType : class, IScopedLifecycle @@ -48,5 +56,19 @@ private static IServiceCollection AddApplicationLifecycleServices(this IServiceC .AddScoped() .AddScoped() .AddSingleton() - .AddSingleton(sp => (IApplicationScopeProvider)sp.GetRequiredService()); + .AddSingleton(sp => (IApplicationScopeProvider)sp.GetRequiredService()) + .AddSingleton() + .AddScopedLifecycleService() + .AddScoped(); + + private static IServiceCollection AddCommandSystemServices(this IServiceCollection services) + => services + .AddScoped() + .AddScoped() + .AddScoped(); + + private static IServiceCollection AddProgrammaticSettingsServices(this IServiceCollection services, IConfiguration configuration) + => services + .AddSingleton() + .Configure(configuration); } diff --git a/src/AdaptiveRemote.App/Configuration/SettingsKeys.cs b/src/AdaptiveRemote.App/Configuration/SettingsKeys.cs index 2fcf1cf3..e40da858 100644 --- a/src/AdaptiveRemote.App/Configuration/SettingsKeys.cs +++ b/src/AdaptiveRemote.App/Configuration/SettingsKeys.cs @@ -46,4 +46,9 @@ internal class SettingsKeys /// Configuration section for backend service settings. /// public const string Backend = "backend"; + + /// + /// Configuration section for backend service settings. + /// + public const string CloudSettings = "cloud"; } diff --git a/src/AdaptiveRemote.App/Logging/MessageLogger.cs b/src/AdaptiveRemote.App/Logging/MessageLogger.cs index 9e3b3224..121e44b3 100644 --- a/src/AdaptiveRemote.App/Logging/MessageLogger.cs +++ b/src/AdaptiveRemote.App/Logging/MessageLogger.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Runtime.Intrinsics.Arm; using Microsoft.Extensions.Logging; namespace AdaptiveRemote.Logging; @@ -42,8 +43,17 @@ public MessageLogger(ILogger logger) [LoggerMessage(EventId = 710, Level = LogLevel.Information, Message = "Waiting for application scope")] public partial void ApplicationLifecycle_WaitingForScope(); - [LoggerMessage(EventId = 711, Level = LogLevel.Information, Message = "Application scope released, shutting down")] - public partial void ApplicationLifecycle_ScopeReleased(); + [LoggerMessage(EventId = 712, Level = LogLevel.Information, Message = "Recycling application scope")] + public partial void ApplicationLifecycle_RecyclingScope(); + + [LoggerMessage(EventId = 713, Level = LogLevel.Information, Message = "Application scope ready")] + public partial void ApplicationLifecycle_ScopeReady(); + + [LoggerMessage(EventId = 714, Level = LogLevel.Information, Message = "Waiting for preinitializer: {Name}")] + public partial void ApplicationLifecycle_WaitingForPreinitializer(string name); + + [LoggerMessage(EventId = 715, Level = LogLevel.Error, Message = "Preinitializer failed: {Name}")] + public partial void ApplicationLifecycle_PreinitializerFailed(string name, Exception ex); [LoggerMessage(EventId = 205, Level = LogLevel.Warning, Message = "Not restarting after {ErrorCount} error(s)")] public partial void ConversationController_RetryLimitReached(int errorCount); @@ -357,4 +367,36 @@ public MessageLogger(ILogger logger) [LoggerMessage(EventId = 1602, Level = LogLevel.Error, Message = "Failed to acquire Cognito access token")] public partial void CognitoTokenService_AcquireTokenFailed(Exception exception); + + // 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 = 1702, Level = LogLevel.Information, Message = "Loaded asset '{AssetName}' from cache")] + public partial void CloudAssetOrchestrator_LoadedFromCache(string assetName); + + [LoggerMessage(EventId = 1703, Level = LogLevel.Information, Message = "Asset '{AssetName}' is up to date")] + public partial void CloudAssetOrchestrator_AssetUpToDate(string assetName); + + [LoggerMessage(EventId = 1704, Level = LogLevel.Information, Message = "Asset '{AssetName}' updated from server; scheduling recycle")] + public partial void CloudAssetOrchestrator_AssetUpdated(string assetName); + + [LoggerMessage(EventId = 1705, Level = LogLevel.Warning, Message = "Failed to download latest '{AssetName}' from server; keeping cached version")] + public partial void CloudAssetOrchestrator_BackgroundFetchFailed(string assetName, Exception? exception); + + [LoggerMessage(EventId = 1706, Level = LogLevel.Information, Message = "Layout service reported a change; re-downloading asset '{AssetName}'")] + public partial void CloudAssetOrchestrator_FileChangeDetected(string assetName); + + [LoggerMessage(EventId = 1707, Level = LogLevel.Warning, Message = "Received change notification for unknown asset '{AssetName}'; ignoring")] + public partial void CloudAssetOrchestrator_UnknownAssetChange(string assetName); + + [LoggerMessage(EventId = 1708, Level = LogLevel.Information, Message = "Asset '{AssetName}' not found in cache")] + public partial void CloudAssetOrchestrator_NotFoundInCache(string assetName); + + [LoggerMessage(EventId = 1709, Level = LogLevel.Information, Message = "Downloaded asset '{AssetName}'")] + public partial void CloudAssetOrchestrator_Downloaded(string assetName); } diff --git a/src/AdaptiveRemote.App/Models/CloudAssets/BasicCloudAsset.cs b/src/AdaptiveRemote.App/Models/CloudAssets/BasicCloudAsset.cs new file mode 100644 index 00000000..8905d8d1 --- /dev/null +++ b/src/AdaptiveRemote.App/Models/CloudAssets/BasicCloudAsset.cs @@ -0,0 +1,19 @@ +namespace AdaptiveRemote.Models.CloudAssets; + +internal abstract class BasicCloudAsset : ICloudAsset +{ + 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 DeserializeAsync(Stream stream, CancellationToken ct); +} diff --git a/src/AdaptiveRemote.App/Models/CloudAssets/ICloudAsset.cs b/src/AdaptiveRemote.App/Models/CloudAssets/ICloudAsset.cs new file mode 100644 index 00000000..63d57b6c --- /dev/null +++ b/src/AdaptiveRemote.App/Models/CloudAssets/ICloudAsset.cs @@ -0,0 +1,38 @@ +namespace AdaptiveRemote.Models.CloudAssets; + +/// +/// Per-asset capability bundle. One implementation per cloud-fetched asset type. +/// +internal interface ICloudAsset +{ + /// + /// Unique key used in store/cache; also used for logging + /// + string Name { get; } + + /// + /// SSE endpoint, e.g. "/notifications/layouts/stream" + /// + string StreamUrl { get; } + + /// + /// SSE event name, e.g. "layout-ready" + /// + string EventName { get; } + + /// + /// REST base path, e.g. "/layouts/compiled" + /// + string ResourcePath { get; } + + /// + /// Deserializes downloaded or cached bytes into the asset's runtime type. + /// + Task DeserializeAsync(Stream stream, CancellationToken ct); +} + +/// +/// Marker interface — allows type-constrained DI registrations: +/// services.AddScoped(sp => sp.GetRequiredService<ICloudAssetStore>().Get<T>(name)) +/// +internal interface ICloudAsset : ICloudAsset { } diff --git a/src/AdaptiveRemote.App/Models/CloudAssets/JsonCloudAsset.cs b/src/AdaptiveRemote.App/Models/CloudAssets/JsonCloudAsset.cs new file mode 100644 index 00000000..0a506bd9 --- /dev/null +++ b/src/AdaptiveRemote.App/Models/CloudAssets/JsonCloudAsset.cs @@ -0,0 +1,20 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AdaptiveRemote.Models.CloudAssets; + +internal sealed class JsonCloudAsset( + string name, + string streamUrl, + string eventName, + string resourcePath, + JsonSerializerContext jsonContext) + : BasicCloudAsset(name, streamUrl, eventName, resourcePath) +{ + public override async Task 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}'."); + } +} diff --git a/src/AdaptiveRemote.App/Models/IRCommand.cs b/src/AdaptiveRemote.App/Models/IRCommand.cs index 81650782..ebd576a2 100644 --- a/src/AdaptiveRemote.App/Models/IRCommand.cs +++ b/src/AdaptiveRemote.App/Models/IRCommand.cs @@ -9,8 +9,9 @@ public IRCommand( string? cssid = null, string? glyph = null, string? reverse = null, - string? speakName = null) - : base(name, placement, label, cssid, glyph, reverse, Phrases.Conversation_Sent(speakName ?? name)) + string? speakName = null, + string? speakPhrase = null) + : base(name, placement, label, cssid, glyph, reverse, speakPhrase ?? Phrases.Conversation_Sent(speakName ?? name)) { } } diff --git a/src/AdaptiveRemote.App/Models/Phrases.cs b/src/AdaptiveRemote.App/Models/Phrases.cs index b939b0b2..8edc0651 100644 --- a/src/AdaptiveRemote.App/Models/Phrases.cs +++ b/src/AdaptiveRemote.App/Models/Phrases.cs @@ -1,4 +1,6 @@ -namespace AdaptiveRemote.Models; +using AdaptiveRemote.Services.Lifecycle; + +namespace AdaptiveRemote.Models; internal static class Phrases { @@ -20,6 +22,8 @@ internal static class Phrases public static string Startup_StartingApplication => "Starting application"; public static string Startup_BuildingServiceGraph => "Building service graph"; public static string Startup_StartingServices => "Starting services"; + public static string Startup_Preinitializing(string initializer) => $"Waiting for preinitializer {initializer}"; + public static string Startup_LoadingCloudAssets => "Loading cloud assets"; public static string Startup_ConnectingToBroadlink => "Connecting to Broadlink device"; public static string Startup_ConnectingToTiVo => "Connecting to TiVo"; public static string Startup_Starting(string service) => $"Starting {service}"; diff --git a/src/AdaptiveRemote.App/Models/TiVoCommand.cs b/src/AdaptiveRemote.App/Models/TiVoCommand.cs index 35a84416..b8ccdc84 100644 --- a/src/AdaptiveRemote.App/Models/TiVoCommand.cs +++ b/src/AdaptiveRemote.App/Models/TiVoCommand.cs @@ -10,8 +10,9 @@ public TiVoCommand( string? cssid = null, string? glyph = null, string? reverse = null, - string? speakName = null) - : base(name, placement, label, cssid ?? commandId, glyph, reverse, Phrases.Conversation_Sent(speakName ?? name)) + string? speakName = null, + string? speakPhrase = null) + : base(name, placement, label, cssid ?? commandId, glyph, reverse, speakPhrase ?? Phrases.Conversation_Sent(speakName ?? name)) { CommandId = commandId ?? name.ToUpperInvariant(); } diff --git a/src/AdaptiveRemote.App/Mvvm/MvvmPropertyActivityDetector.cs b/src/AdaptiveRemote.App/Mvvm/MvvmPropertyActivityDetector.cs new file mode 100644 index 00000000..7b61c191 --- /dev/null +++ b/src/AdaptiveRemote.App/Mvvm/MvvmPropertyActivityDetector.cs @@ -0,0 +1,48 @@ +using System.ComponentModel; +using AdaptiveRemote.Services; + +namespace AdaptiveRemote.Mvvm; + +// Subscribes to a bool MvvmProperty on an MvvmObject and holds a non-idle token via +// IIdleDetector while the property is true. Thread-safe: InitializeAsync, CleanUpAsync, +// and OnPropertyChanged all synchronize on _lock, and _subscribed prevents token leaks +// if a PropertyChanged callback races with CleanUpAsync. +internal abstract class MvvmPropertyActivityDetector : IUserActivityDetector, IDisposable +{ + private readonly MvvmObject _target; + private readonly MvvmProperty _property; + private DateTime? _lastActivityTime; + + protected MvvmPropertyActivityDetector(MvvmObject target, MvvmProperty property) + { + _target = target ?? throw new ArgumentNullException(nameof(target)); + _property = property ?? throw new ArgumentNullException(nameof(property)); + + _target.PropertyChanged += OnPropertyChanged; + _lastActivityTime = target.GetValue(property) ? null : DateTime.MinValue; + } + + public DateTime LastActivityTime => _lastActivityTime ?? DateTime.Now; + + private void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != _property.Name) + { + return; + } + + if (_target.GetValue(_property)) + { + _lastActivityTime = null; + } + else + { + _lastActivityTime ??= DateTime.Now; + } + } + + public void Dispose() + { + _target.PropertyChanged -= OnPropertyChanged; + } +} diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetCache.cs b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetCache.cs new file mode 100644 index 00000000..5760038e --- /dev/null +++ b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetCache.cs @@ -0,0 +1,44 @@ +using AdaptiveRemote.Services; +using Microsoft.Extensions.Options; + +namespace AdaptiveRemote.Services.CloudAssets; + +internal sealed class CloudAssetCache : ICloudAssetCache +{ + private readonly string _cacheDirectory; + private readonly IFileSystem _fileSystem; + + public CloudAssetCache(IOptions options, IFileSystem fileSystem) + { + _cacheDirectory = Environment.ExpandEnvironmentVariables(options.Value.CachePath); + _fileSystem = fileSystem; + } + + public Task LoadAsync(string name, CancellationToken ct) + { + string path = GetCachePath(name); + if (!_fileSystem.FileExists(path)) + { + return Task.FromResult(null); + } + return Task.FromResult(_fileSystem.OpenRead(path)); + } + + public async Task SaveAsync(string name, Stream assetData, CancellationToken ct) + { + string path = GetCachePath(name); + await using Stream dest = _fileSystem.OpenWrite(path, createDirectory: true); + await assetData.CopyToAsync(dest, ct); + } + + private string GetCachePath(string name) + { + // Asset names are developer-controlled DI constants, but guard against accidental + // misconfiguration that would place a cache file outside the cache directory. + if (name.IndexOfAny(['/', '\\', ':']) >= 0 || name.StartsWith('.')) + { + throw new ArgumentException($"Invalid asset name '{name}'.", nameof(name)); + } + return Path.Combine(_cacheDirectory, $"{name}.cache"); + } +} diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs new file mode 100644 index 00000000..d75cbcca --- /dev/null +++ b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetOrchestrator.cs @@ -0,0 +1,281 @@ +using System.Runtime.InteropServices.Marshalling; +using System.Security.Cryptography; +using AdaptiveRemote.Logging; +using AdaptiveRemote.Models; +using AdaptiveRemote.Models.CloudAssets; +using AdaptiveRemote.Services.Lifecycle; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace AdaptiveRemote.Services.CloudAssets; + +internal class CloudAssetOrchestrator : BackgroundService, IPreScopeInitializer +{ + private readonly IEnumerable _assets; + private readonly ICloudAssetDownloader _downloader; + private readonly ICloudAssetStore _store; + private readonly ICloudAssetCache _cache; + private readonly IApplicationRecycleSignal _signal; + private readonly IIdleDetector _idleDetector; + private readonly ICloudAssetChangeNotifier _changeNotifier; + private readonly MessageLogger _log; + private readonly TaskCompletionSource _initCompleted = new(); + + // SHA256 hashes of bytes last written to cache, keyed by asset name. + // Populated only for assets loaded from cache in Phase 1; used by Phase 2 to detect server changes. + private readonly Dictionary _cacheHashes = new(); + + // Non-null while a WaitForIdleAsync task is pending; prevents stacking recycle requests + // across Phase 2/3 cycles. + private Task? _pendingRecycleTask; + + public CloudAssetOrchestrator( + IEnumerable assets, + ICloudAssetDownloader downloader, + ICloudAssetStore store, + ICloudAssetCache cache, + IApplicationRecycleSignal signal, + IIdleDetector idleDetector, + ICloudAssetChangeNotifier changeNotifier, + ILogger logger) + { + _assets = assets; + _downloader = downloader; + _store = store; + _cache = cache; + _signal = signal; + _idleDetector = idleDetector; + _changeNotifier = changeNotifier; + _log = new MessageLogger(logger); + } + + public string Name => "Loading cloud assets"; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await Phase1Async(stoppingToken); + _initCompleted.SetResult(); + } + catch (Exception ex) + { + _log.CloudAssetOrchestrator_Failed(ex); + _initCompleted.TrySetException(ex); + // Do not re-throw: ApplicationLifecycle observes the faulted WaitAsync and sets + // FatalError. Re-throwing here would trigger BackgroundServiceExceptionBehavior.StopHost, + // which kills the process before the FatalError UI state can be observed. + return; + } + + try + { + await Phase2Async(stoppingToken); + await Phase3Async(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // Normal shutdown — swallow so BackgroundServiceExceptionBehavior.StopHost is not triggered. + } + catch (Exception ex) + { + // Unexpected exception in background phases — log and exit cleanly. + _log.CloudAssetOrchestrator_Failed(ex); + } + } + + public Task WaitAsync(ILifecycleActivity activity, CancellationToken ct) + { + activity.Description = Phrases.Startup_LoadingCloudAssets; + return _initCompleted.Task.WaitAsync(ct); + } + + private async Task Phase1Async(CancellationToken ct) + { + ICloudAsset[] assets = _assets.ToArray(); + await Task.WhenAll(assets.Select(asset => LoadAssetAsync(asset, ct))); + } + + private async Task LoadAssetAsync(ICloudAsset asset, CancellationToken ct) + { + byte[]? cachedBytes = await LoadAssetFromCacheAsync(asset, ct); + if (cachedBytes != null) + { + await ApplyAssetAsync(asset, cachedBytes, fromCache: true, ct); + _log.CloudAssetOrchestrator_LoadedFromCache(asset.Name); + return; + } + else + { + _log.CloudAssetOrchestrator_NotFoundInCache(asset.Name); + } + + byte[] serverBytes = await DownloadAssetAsync(asset, ct) + ?? throw new InvalidOperationException($"Failed to download asset '{asset.Name}'."); + + await ApplyAssetAsync(asset, serverBytes, ct); + } + + private async Task LoadAssetFromCacheAsync(ICloudAsset asset, CancellationToken ct) + { + Stream? cachedStream = await _cache.LoadAsync(asset.Name, ct); + if (cachedStream != null) + { + return await ReadAllBytesAndDisposeAsync(cachedStream, ct); + } + + return null; + } + + private async Task Phase2Async(CancellationToken ct) + { + // Only check assets that were loaded from cache in Phase 1 (present in _cacheHashes). + HashSet cacheLoadedNames; + lock (_cacheHashes) + { + cacheLoadedNames = [.. _cacheHashes.Keys]; + } + ICloudAsset[] toCheck = _assets.Where(a => cacheLoadedNames.Contains(a.Name)).ToArray(); + + foreach (ICloudAsset asset in toCheck) + { + if (ct.IsCancellationRequested) + { + return; + } + + byte[]? serverBytes = await DownloadAssetAsync(asset, ct); + ct.ThrowIfCancellationRequested(); + + if (serverBytes == null) + { + continue; + } + + await ApplyAssetAsync(asset, serverBytes, ct); + ct.ThrowIfCancellationRequested(); + } + } + + private async Task Phase3Async(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + ICloudAsset asset = await _changeNotifier.WaitForChangeAsync(ct); + ct.ThrowIfCancellationRequested(); + + _log.CloudAssetOrchestrator_FileChangeDetected(asset.Name); + + byte[]? serverBytes = await DownloadAssetAsync(asset, ct); + ct.ThrowIfCancellationRequested(); + + if (serverBytes is not null) + { + await ApplyAssetAsync(asset, serverBytes, ct); + } + } + } + + private static async Task ReadAllBytesAndDisposeAsync(Stream stream, CancellationToken ct) + { + await using (stream) + { + using MemoryStream ms = new(); + await stream.CopyToAsync(ms, ct); + return ms.ToArray(); + } + } + + private Task ApplyAssetAsync(ICloudAsset asset, byte[] bytes, CancellationToken ct = default) + => ApplyAssetAsync(asset, bytes, fromCache: false, ct); + + private async Task ApplyAssetAsync(ICloudAsset asset, byte[] bytes, bool fromCache, CancellationToken ct = default) + { + byte[] assetHash = SHA256.HashData(bytes); + if (ShouldApply(asset, assetHash)) + { + object value = await asset.DeserializeAsync(new MemoryStream(bytes), ct); + _store.Set(asset.Name, value); + + if (fromCache) + { + _cacheHashes[asset.Name] = assetHash; + } + else + { + await _cache.SaveAsync(asset.Name, new MemoryStream(bytes), ct); + } + + if (_initCompleted.Task.IsCompleted) + { + // Only need to recycle if we've already signaled that initialization is complete, + // otherwise the initialization sequence will take care of applying the assets. + _log.CloudAssetOrchestrator_AssetUpdated(asset.Name); + + IdleDeferRecycle(); + } + } + } + + private bool ShouldApply(ICloudAsset asset, byte[] assetHash) + { + lock (_cacheHashes) + { + if (_cacheHashes.TryGetValue(asset.Name, out byte[]? cachedHash) + && assetHash.AsSpan().SequenceEqual(cachedHash)) + { + _log.CloudAssetOrchestrator_AssetUpToDate(asset.Name); + return false; + } + } + + return true; + } + + private async Task DownloadAssetAsync(ICloudAsset asset, CancellationToken ct) + { + _log.CloudAssetOrchestrator_Downloading(asset.Name); + + Stream? serverStream; + try + { + serverStream = await _downloader.GetActiveAsync(asset.ResourcePath, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _log.CloudAssetOrchestrator_BackgroundFetchFailed(asset.Name, ex); + return null; + } + + if (serverStream == null) + { + _log.CloudAssetOrchestrator_BackgroundFetchFailed(asset.Name, null); + return null; + } + + byte[] bytes = await ReadAllBytesAndDisposeAsync(serverStream, ct); + _log.CloudAssetOrchestrator_Downloaded(asset.Name); + + return bytes; + } + + private void IdleDeferRecycle() + { + if (_pendingRecycleTask?.IsCompleted == false) + { + return; + } + + _pendingRecycleTask = _idleDetector.WaitForIdleAsync(default) + .ContinueWith( + _ => _signal.RequestRecycle(), + CancellationToken.None, + TaskContinuationOptions.OnlyOnRanToCompletion, + TaskScheduler.Default); + } +} + diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetStore.cs b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetStore.cs new file mode 100644 index 00000000..02e974a2 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/CloudAssets/CloudAssetStore.cs @@ -0,0 +1,31 @@ +using System.Collections.Concurrent; + +namespace AdaptiveRemote.Services.CloudAssets; + +/// +/// Thread-safe in-memory dictionary keyed by asset name. +/// +internal class CloudAssetStore : ICloudAssetStore +{ + private readonly ConcurrentDictionary _assets = new(); + + public T Get(string name) + { + if (!_assets.TryGetValue(name, out object? value)) + { + throw new InvalidOperationException($"Asset '{name}' not found in store. Ensure CloudAssetOrchestrator has completed before accessing assets."); + } + + if (value is not T typedValue) + { + throw new InvalidOperationException($"Asset '{name}' is of type '{value.GetType().Name}', but '{typeof(T).Name}' was requested."); + } + + return typedValue; + } + + public void Set(string name, object asset) + { + _assets[name] = asset; + } +} diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/CloudSettings.cs b/src/AdaptiveRemote.App/Services/CloudAssets/CloudSettings.cs new file mode 100644 index 00000000..5d863198 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/CloudAssets/CloudSettings.cs @@ -0,0 +1,12 @@ +namespace AdaptiveRemote.Services.CloudAssets; + +/// +/// Shared settings for all cloud asset services. +/// +internal class CloudSettings +{ + 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"; +} diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/FileSystemCloudAssetDownloader.cs b/src/AdaptiveRemote.App/Services/CloudAssets/FileSystemCloudAssetDownloader.cs new file mode 100644 index 00000000..05575b76 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/CloudAssets/FileSystemCloudAssetDownloader.cs @@ -0,0 +1,29 @@ +using AdaptiveRemote.Services; +using Microsoft.Extensions.Options; + +namespace AdaptiveRemote.Services.CloudAssets; + +internal sealed class FileSystemCloudAssetDownloader : ICloudAssetDownloader +{ + private readonly CloudSettings _settings; + private readonly IFileSystem _fileSystem; + + public FileSystemCloudAssetDownloader(IOptions options, IFileSystem fileSystem) + { + _settings = options.Value; + _fileSystem = fileSystem; + } + + public Task GetActiveAsync(string resourcePath, CancellationToken ct) + { + string path = Environment.ExpandEnvironmentVariables(_settings.StubFilePath); + if (!_fileSystem.FileExists(path)) + { + return Task.FromResult(null); + } + return Task.FromResult(_fileSystem.OpenRead(path)); + } + + public Task GetByIdAsync(string resourcePath, Guid id, CancellationToken ct) + => Task.FromResult(null); +} diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/FileSystemCloudAssetWatchService.cs b/src/AdaptiveRemote.App/Services/CloudAssets/FileSystemCloudAssetWatchService.cs new file mode 100644 index 00000000..e85fe2ef --- /dev/null +++ b/src/AdaptiveRemote.App/Services/CloudAssets/FileSystemCloudAssetWatchService.cs @@ -0,0 +1,86 @@ +using AdaptiveRemote.Models.CloudAssets; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace AdaptiveRemote.Services.CloudAssets; + +internal sealed class FileSystemCloudAssetWatchService : BackgroundService, ICloudAssetChangeNotifier +{ + private readonly CloudSettings _settings; + private readonly ICloudAsset _asset; + private readonly SemaphoreSlim _semaphore = new(0, 1); + private readonly object _debounceLock = new(); + private CancellationTokenSource? _debounceCts; + + public FileSystemCloudAssetWatchService(IEnumerable assets, IOptions options) + { + _settings = options.Value; + + // The real implementation will need to support multiple assets, but for now we only have one and this + // is just for short-term testing. + _asset = assets.First(); + } + + public async Task WaitForChangeAsync(CancellationToken ct) + { + await _semaphore.WaitAsync(ct); + return _asset; + } + + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + string path = Environment.ExpandEnvironmentVariables(_settings.StubFilePath); + string? dir = Path.GetDirectoryName(path); + string fileName = Path.GetFileName(path); + + if (dir is null || fileName is null) + { + return Task.CompletedTask; + } + + FileSystemWatcher watcher = new(dir, fileName) + { + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.CreationTime, + EnableRaisingEvents = true + }; + + watcher.Changed += OnChanged; + watcher.Created += OnChanged; + watcher.Renamed += OnChanged; + + stoppingToken.Register(watcher.Dispose); + + return Task.CompletedTask; + } + + private void OnChanged(object sender, FileSystemEventArgs e) + { + CancellationTokenSource newCts; + lock (_debounceLock) + { + _debounceCts?.Cancel(); + _debounceCts?.Dispose(); + newCts = _debounceCts = new CancellationTokenSource(); + } + + _ = Task.Run(async () => + { + try + { + await Task.Delay(100, newCts.Token); + try + { + _semaphore.Release(); + } + catch (SemaphoreFullException) + { + // Already a notification pending — swallow so the count stays at 1. + } + } + catch (OperationCanceledException) + { + // Debounced by a later event. + } + }); + } +} diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAssetCache.cs b/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAssetCache.cs new file mode 100644 index 00000000..6fc8e553 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAssetCache.cs @@ -0,0 +1,23 @@ +namespace AdaptiveRemote.Services.CloudAssets; + +/// +/// File-backed persistence for raw asset bytes, keyed by Name. +/// +internal interface ICloudAssetCache +{ + /// + /// Loads an asset from the cache. + /// + /// The unique name of the asset + /// Cancellation token + /// A stream containing the cached asset data, or null if no cached file exists + Task LoadAsync(string name, CancellationToken ct); + + /// + /// Saves an asset to the cache. + /// + /// The unique name of the asset + /// A stream containing the asset data to cache + /// Cancellation token + Task SaveAsync(string name, Stream assetData, CancellationToken ct); +} diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAssetChangeNotifier.cs b/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAssetChangeNotifier.cs new file mode 100644 index 00000000..065fab88 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAssetChangeNotifier.cs @@ -0,0 +1,11 @@ +using AdaptiveRemote.Models.CloudAssets; + +namespace AdaptiveRemote.Services.CloudAssets; + +internal interface ICloudAssetChangeNotifier +{ + /// + /// Waits until a change is detected and returns the name of the asset that changed. + /// + Task WaitForChangeAsync(CancellationToken ct); +} diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAssetDownloader.cs b/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAssetDownloader.cs new file mode 100644 index 00000000..3c37717f --- /dev/null +++ b/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAssetDownloader.cs @@ -0,0 +1,24 @@ +namespace AdaptiveRemote.Services.CloudAssets; + +/// +/// HTTP download against the backend REST API. +/// +internal interface ICloudAssetDownloader +{ + /// + /// Downloads the currently active version of an asset. + /// + /// The REST resource path for the asset + /// Cancellation token + /// A stream containing the asset data, or null if the asset is not available + Task GetActiveAsync(string resourcePath, CancellationToken ct); + + /// + /// Downloads a specific version of an asset by ID. + /// + /// The REST resource path for the asset + /// The unique identifier of the asset version + /// Cancellation token + /// A stream containing the asset data, or null if the asset is not available + Task GetByIdAsync(string resourcePath, Guid id, CancellationToken ct); +} diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAssetStore.cs b/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAssetStore.cs new file mode 100644 index 00000000..87517307 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/CloudAssets/ICloudAssetStore.cs @@ -0,0 +1,23 @@ +namespace AdaptiveRemote.Services.CloudAssets; + +/// +/// In-memory cross-scope holder for all cloud assets, keyed by Name. +/// +internal interface ICloudAssetStore +{ + /// + /// Retrieves an asset from the store. + /// + /// The expected type of the asset + /// The unique name of the asset + /// The asset value + /// Thrown if asset not found or wrong type + T Get(string name); + + /// + /// Stores an asset in the store. + /// + /// The unique name of the asset + /// The asset value to store + void Set(string name, object asset); +} diff --git a/src/AdaptiveRemote.App/Services/CloudAssets/_doc_CloudAssets.md b/src/AdaptiveRemote.App/Services/CloudAssets/_doc_CloudAssets.md new file mode 100644 index 00000000..a2071581 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/CloudAssets/_doc_CloudAssets.md @@ -0,0 +1,60 @@ +# Cloud Assets + +Cloud assets are server-fetched, locally-cached data bundles (e.g. the compiled layout) that drive the runtime behavior of the application. + +## Composition model + +Each asset is a singleton implementing [`ICloudAsset`](ICloudAsset.cs): + +| Property | Purpose | +|----------|---------| +| `Name` | Cache key; also used in logging (e.g. `"layout"`) | +| `ResourcePath` | REST path for fetching the latest version | +| `StreamUrl` / `EventName` | SSE endpoint/event for live push notifications (future) | +| `DeserializeAsync` | Converts raw bytes to the asset's runtime type | + +Assets are registered via [`CloudAssetServiceExtensions.AddScopedCloudAsset`](../../Configuration/CloudAssetServiceExtensions.cs) and resolved from the DI-scoped [`ICloudAssetStore`](ICloudAssetStore.cs). + +## Three-phase orchestrator + +[`CloudAssetOrchestrator`](CloudAssetOrchestrator.cs) is a `BackgroundService` and `IPreScopeInitializer`. It runs three phases: + +**Phase 1 — cache-first load (blocks scope initialization)** + +All assets are loaded in parallel. For each asset: +- If a `.cache` file exists → deserialize from cache; record SHA-256 of those bytes. +- Otherwise → download from server, save to cache, deserialize. + +`WaitAsync` returns once all assets are in the store. If any asset fails, `WaitAsync` faults. + +**Phase 2 — background server refresh (runs after Phase 1 completes)** + +For every asset that was loaded from cache, the server is queried. If the content differs (by SHA-256), the cache and store are updated and an idle-deferred scope recycle is scheduled. Server failures log a warning and are silently skipped. + +**Phase 3 — ongoing file-change loop (stub / dev mode)** + +Waits on [`IAssetChangeNotifier.WaitForChangeAsync`](IAssetChangeNotifier.cs) in a loop. On each notification, all assets are re-downloaded, cached, and stored, and a recycle is scheduled. + +## Cache + +[`CloudAssetCache`](CloudAssetCache.cs) reads and writes `.cache` files under `CloudSettings.CachePath` (environment variables expanded). File path: `{CachePath}/{name}.cache`. + +## File-change notification + +[`FileSystemCloudAssetWatchService`](FileSystemCloudAssetWatchService.cs) watches `CloudSettings.StubFilePath` using `FileSystemWatcher`. It debounces rapid events using a cancel-restart pattern (100 ms delay) and exposes a `SemaphoreSlim(0,1)` so multiple events collapse to a single notification. + +This service will be replaced by an SSE-based implementation (ADR-186) without touching the orchestrator. + +## Idle-deferred scope recycle + +When an update is detected, the orchestrator calls `IdleDeferRecycle`: +- If `IIdleDetector.IsIdle` → `IApplicationRecycleSignal.RequestRecycle()` immediately. +- Otherwise → subscribe to `IIdleDetector.BecameIdle`; recycle when the event fires. + +The idle cooldown in tests is set to 0 seconds via `--cloud:IdleCooldownSeconds=0`. + +## Adding a new asset type + +1. Create a class implementing `ICloudAsset` (or use [`JsonCloudAsset`](JsonCloudAsset.cs)). +2. Register it: `services.AddScopedCloudAsset(new JsonCloudAsset(...))`. +3. Inject `MyType` into scoped services via DI — the store resolves it automatically. diff --git a/src/AdaptiveRemote.App/Services/Commands/CommandActivityDetector.cs b/src/AdaptiveRemote.App/Services/Commands/CommandActivityDetector.cs new file mode 100644 index 00000000..d74b400d --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Commands/CommandActivityDetector.cs @@ -0,0 +1,13 @@ +using AdaptiveRemote.Models; +using AdaptiveRemote.Mvvm; + +namespace AdaptiveRemote.Services.Commands; + +// Tracks IsActive for a single Command; created by CommandExecutionIdleAdapter. +internal sealed class CommandActivityDetector : MvvmPropertyActivityDetector +{ + internal CommandActivityDetector(Command command) + : base(command, Command.IsActiveProperty) + { + } +} diff --git a/src/AdaptiveRemote.App/Services/Commands/CommandsActivityDetector.cs b/src/AdaptiveRemote.App/Services/Commands/CommandsActivityDetector.cs new file mode 100644 index 00000000..e898ecef --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Commands/CommandsActivityDetector.cs @@ -0,0 +1,16 @@ +namespace AdaptiveRemote.Services.Commands; + +// Creates one CommandIdleAdapter per command and delegates lifecycle calls to them. +internal class CommandsActivityDetector : IUserActivityDetector +{ + private readonly IReadOnlyList _adapters; + + public DateTime LastActivityTime => _adapters.Select(x => x.LastActivityTime).Max(); + + public CommandsActivityDetector(IRemoteDefinitionService remoteDefinition) + { + _adapters = remoteDefinition.GetCommands() + .Select(cmd => new CommandActivityDetector(cmd)) + .ToList(); + } +} diff --git a/src/AdaptiveRemote.App/Services/Commands/StaticCommandGroupProvider.cs b/src/AdaptiveRemote.App/Services/Commands/StaticCommandGroupProvider.cs deleted file mode 100644 index b4874fc0..00000000 --- a/src/AdaptiveRemote.App/Services/Commands/StaticCommandGroupProvider.cs +++ /dev/null @@ -1,50 +0,0 @@ -using AdaptiveRemote.Models; - -namespace AdaptiveRemote.Services.Commands; - -internal class StaticCommandGroupProvider : IRemoteDefinitionService -{ - public RemoteLayoutElement RemoteRoot { get; } = new LayoutGroup("ROOT", - [ - new LayoutGroup("DPAD", - [ - new TiVoCommand("Up", reverse: "Down"), - new TiVoCommand("Down", reverse: "Up"), - new TiVoCommand("Left", reverse: "Right"), - new TiVoCommand("Right", reverse: "Left"), - new TiVoCommand("Select"), - new TiVoCommand("Back"), - new IRCommand("Power", reverse: "Power"), - new IRCommand("PowerOn", reverse: "PowerOff"), - new IRCommand("PowerOff", reverse: "PowerOn"), - ]), - new LayoutGroup("WELL", - [ - new TiVoCommand("TiVo"), - new TiVoCommand("Netflix"), - new TiVoCommand("Guide"), - ]), - new LayoutGroup("PLAYBACK", - [ - new TiVoCommand("Play", reverse: "Pause"), - new TiVoCommand("Pause", reverse: "Play"), - new TiVoCommand("Record"), - new TiVoCommand("Skip", reverse: "Replay"), - new TiVoCommand("Replay", reverse: "Skip"), - ]), - new LayoutGroup("CHANNELANDVOLUME", - [ - new TiVoCommand("ChannelUp", reverse: "ChannelDown", label: "Up", speakName: "Channel Up"), - new TiVoCommand("ChannelDown", reverse: "ChannelUp", label: "Down", speakName: "Channel Down"), - new IRCommand("VolumeUp", reverse: "VolumeDown", label: "Up", speakName: "Volume Up"), - new IRCommand("VolumeDown", reverse: "VolumeUp", label: "Down", speakName: "Volume Down"), - new IRCommand("Mute", reverse: "Mute", label: "Mute"), - ]), - new LayoutGroup("GUTTER", - [ - new ConversationView(), - new LifecycleCommand("Learn"), - new LifecycleCommand("Exit", speakPhrase: Phrases.Conversation_Goodbye) - ]) - ]); -} diff --git a/src/AdaptiveRemote.App/Services/Conversation/ConversationActivityDetector.cs b/src/AdaptiveRemote.App/Services/Conversation/ConversationActivityDetector.cs new file mode 100644 index 00000000..881865af --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Conversation/ConversationActivityDetector.cs @@ -0,0 +1,12 @@ +using AdaptiveRemote.Models; +using AdaptiveRemote.Mvvm; + +namespace AdaptiveRemote.Services.Conversation; + +internal class ConversationActivityDetector : MvvmPropertyActivityDetector +{ + public ConversationActivityDetector(IRemoteDefinitionService remoteDefinition) + : base(remoteDefinition.GetElement(), ConversationView.IsListeningProperty) + { + } +} diff --git a/src/AdaptiveRemote.App/Services/IDynamicStylesheetProvider.cs b/src/AdaptiveRemote.App/Services/IDynamicStylesheetProvider.cs new file mode 100644 index 00000000..8645c875 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/IDynamicStylesheetProvider.cs @@ -0,0 +1,9 @@ +namespace AdaptiveRemote.Services; + +/// +/// Scoped. Returns the CSS for the active layout in this scope. +/// +public interface IDynamicStylesheetProvider +{ + string? GetCss(); +} diff --git a/src/AdaptiveRemote.App/Services/IIdleDetector.cs b/src/AdaptiveRemote.App/Services/IIdleDetector.cs new file mode 100644 index 00000000..dbf4e71d --- /dev/null +++ b/src/AdaptiveRemote.App/Services/IIdleDetector.cs @@ -0,0 +1,8 @@ +namespace AdaptiveRemote.Services; + +/// +/// +internal interface IIdleDetector +{ + Task WaitForIdleAsync(CancellationToken cancellationToken); +} diff --git a/src/AdaptiveRemote.App/Services/IUserActivityDetector.cs b/src/AdaptiveRemote.App/Services/IUserActivityDetector.cs new file mode 100644 index 00000000..ed174a7e --- /dev/null +++ b/src/AdaptiveRemote.App/Services/IUserActivityDetector.cs @@ -0,0 +1,6 @@ +namespace AdaptiveRemote.Services; + +internal interface IUserActivityDetector +{ + DateTime LastActivityTime { get; } +} diff --git a/src/AdaptiveRemote.App/Services/IdleDetection/IdleDetector.cs b/src/AdaptiveRemote.App/Services/IdleDetection/IdleDetector.cs new file mode 100644 index 00000000..269d9378 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/IdleDetection/IdleDetector.cs @@ -0,0 +1,70 @@ +using System.Collections.Immutable; +using AdaptiveRemote.Services.CloudAssets; +using Microsoft.Extensions.Options; + +namespace AdaptiveRemote.Services.IdleDetection; + +internal class IdleDetector : IIdleDetector +{ + private TaskCompletionSource _scopedIdleDetectorTask = new(); + + public async Task WaitForIdleAsync(CancellationToken cancellationToken) + { +#pragma warning disable VSTHRD003 // Avoid awaiting foreign Tasks + ScopedIdleDetector scoped = await _scopedIdleDetectorTask.Task; +#pragma warning restore VSTHRD003 // Avoid awaiting foreign Tasks + + await scoped.WaitForIdleAsync(cancellationToken); + } + + internal class ScopedIdleDetector : IScopedLifecycle + { + private readonly TimeSpan _cooldown; + private readonly IEnumerable _userActivityDetectors; + private readonly IdleDetector _globalIdleDetector; + + public ScopedIdleDetector(IEnumerable userActivityDetectors, IIdleDetector globalIdleDetector, IOptions settings) + { + _cooldown = TimeSpan.FromSeconds(Math.Max(.1, settings.Value.IdleCooldownSeconds)); + _userActivityDetectors = userActivityDetectors.ToImmutableList(); + _globalIdleDetector = globalIdleDetector as IdleDetector + ?? throw new ArgumentException("Wrong type was injected for IIdleDetector", nameof(globalIdleDetector)); + } + + public string Name => "Idle Detection"; + + public Task CleanUpAsync(ILifecycleActivity activity, CancellationToken cancellationToken) + { + _globalIdleDetector._scopedIdleDetectorTask = new(); + return Task.CompletedTask; + } + + public Task InitializeAsync(ILifecycleActivity activity, CancellationToken cancellationToken) + { + _globalIdleDetector._scopedIdleDetectorTask.TrySetResult(this); + return Task.CompletedTask; + } + + public async Task WaitForIdleAsync(CancellationToken cancellationToken) + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + DateTime mostRecentActivity = _userActivityDetectors + .Select(x => x.LastActivityTime) + .DefaultIfEmpty(DateTime.MinValue) + .Max(); + + TimeSpan timeUntilIdle = (mostRecentActivity + _cooldown) - DateTime.Now; + + if (timeUntilIdle <= TimeSpan.Zero) + { + break; + } + + await Task.Delay(timeUntilIdle, cancellationToken); + } + } + } +} diff --git a/src/AdaptiveRemote.App/Services/Layout/LayoutStylesheetProvider.cs b/src/AdaptiveRemote.App/Services/Layout/LayoutStylesheetProvider.cs new file mode 100644 index 00000000..a4eb3761 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Layout/LayoutStylesheetProvider.cs @@ -0,0 +1,18 @@ +using AdaptiveRemote.Contracts; + +namespace AdaptiveRemote.Services.Layout; + +internal sealed class LayoutStylesheetProvider : IDynamicStylesheetProvider +{ + private readonly CompiledLayout _layout; + + public LayoutStylesheetProvider(CompiledLayout layout) + { + _layout = layout; + } + + public string? GetCss() + { + return _layout.CssDefinitions; + } +} diff --git a/src/AdaptiveRemote.App/Services/Layout/RemoteLayoutDefinitionService.cs b/src/AdaptiveRemote.App/Services/Layout/RemoteLayoutDefinitionService.cs new file mode 100644 index 00000000..66ceac25 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Layout/RemoteLayoutDefinitionService.cs @@ -0,0 +1,70 @@ +using AdaptiveRemote.Contracts; +using AdaptiveRemote.Models; + +namespace AdaptiveRemote.Services.Layout; + +internal class RemoteLayoutDefinitionService : IRemoteDefinitionService +{ + public RemoteLayoutElement RemoteRoot { get; } + + public RemoteLayoutDefinitionService(CompiledLayout layout) + { + RemoteRoot = BuildRoot(layout); + } + + private static RemoteLayoutElement BuildRoot(CompiledLayout layout) + { + if (layout.Elements.Any(e => e.CssId == "GUTTER")) + { + throw new InvalidOperationException( + "The CompiledLayout already contains a GUTTER element. " + + "The client appends its own GUTTER; the server must not include one."); + } + + List elements = layout.Elements + .Select(MapElement) + .ToList(); + elements.Add(BuildGutter()); + + return new LayoutGroup("ROOT", elements); + } + + private static RemoteLayoutElement MapElement(LayoutElementDto dto) => dto switch + { + LayoutGroupDefinitionDto group => new LayoutGroup( + group.CssId, + group.Children.Select(MapElement).ToList()), + + CommandDefinitionDto cmd => cmd.Type switch + { + CommandType.TiVo => new TiVoCommand( + cmd.Name, placement: null, label: cmd.Label, + cssid: cmd.CssId, glyph: cmd.Glyph, reverse: cmd.Reverse, + speakPhrase: cmd.SpeakPhrase), + + CommandType.IR => new IRCommand( + cmd.Name, placement: null, label: cmd.Label, + cssid: cmd.CssId, glyph: cmd.Glyph, reverse: cmd.Reverse, + speakPhrase: cmd.SpeakPhrase), + + CommandType.Lifecycle => new LifecycleCommand( + cmd.Name, placement: null, label: cmd.Label, + cssid: cmd.CssId, glyph: cmd.Glyph, reverse: cmd.Reverse, + speakPhrase: cmd.SpeakPhrase), + + _ => throw new InvalidOperationException( + $"Unknown CommandType '{cmd.Type}' on command '{cmd.Name}'.") + }, + + _ => throw new InvalidOperationException( + $"Unknown LayoutElementDto type '{dto.GetType().Name}'.") + }; + + private static LayoutGroup BuildGutter() => + new("GUTTER", + [ + new ConversationView(), + new LifecycleCommand("Learn"), + new LifecycleCommand("Exit", speakPhrase: Phrases.Conversation_Goodbye), + ]); +} diff --git a/src/AdaptiveRemote.App/Services/Layout/_doc_LayoutConsumption.md b/src/AdaptiveRemote.App/Services/Layout/_doc_LayoutConsumption.md new file mode 100644 index 00000000..44a8a0e0 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Layout/_doc_LayoutConsumption.md @@ -0,0 +1,113 @@ +# Client-Side Layout Consumption + +## Overview + +AdaptiveRemote downloads its compiled remote control layout from the backend +`CompiledLayoutService`, caches it locally, and applies it at startup. When the backend +publishes a new layout, the client downloads it and automatically applies it the next time +the user is idle. The downloaded `CompiledLayout` (defined in `AdaptiveRemote.Contracts`) +is mapped to the runtime `Command` and `LayoutGroup` types used by all other subsystems. +CSS from the compiled layout is injected into the Blazor page as a ` +} +``` +This runs once per scope lifetime; the style is replaced cleanly on every scope recycle. + +## Open Questions + +- [X] ~~**`CommandType.Action` runtime type:** Resolved: `CommandType.Action` is removed + from the backend spec. That was an error in that spec.~~ + +- [X] ~~**SSE reconnect policy:** Resolved: `System.Net.ServerSentEvents` for parsing; + manual exponential backoff loop (5 s → 2 min cap, jitter) in `CloudAssetWatchService`; + silent reconnect with warning logging; error logged after `CloudSettings.SseMaxConsecutiveFailures` + consecutive failures (default 10). No user notification on disconnect.~~ + +## Tasks + +### 1. [ADR-175](https://jodasoft.atlassian.net/browse/ADR-175) — Interfaces, CloudAssetStore, pass-through RemoteLayoutDefinitionService, and stub orchestrator + +Replace `StaticCommandGroupProvider` with a minimal store + pass-through definition service +backed by a stub orchestrator that inlines the same hardcoded layout. No DTO mapping or +parsing yet; the store holds runtime types directly. + +- [ ] All interfaces defined: `ICloudAsset`, `ICloudAsset`, `ICloudAssetStore`, + `ICloudAssetCache`, `ICloudAssetDownloader`, `IPreScopeInitializer` +- [ ] `CloudSettings` registered as `IOptions` +- [ ] `CloudAssetStore` implemented as thread-safe singleton +- [ ] **Stub `CloudAssetOrchestrator`**: inlines the same hardcoded commands as + `StaticCommandGroupProvider` (GUTTER included); stores the `LayoutGroup` root directly in + `CloudAssetStore`; immediately signals `IPreScopeInitializer` complete; no file I/O, no HTTP +- [ ] `StaticCommandGroupProvider` removed +- [ ] `RemoteLayoutDefinitionService` v1: reads `LayoutGroup` directly from + `ICloudAssetStore.Get("layout")`; returns it as `RemoteRoot`; no DTO mapping +- [ ] `RemoteLayoutDefinitionService` registered as sole `IRemoteDefinitionService` +- [ ] `ApplicationLifecycle` awaits `IPreScopeInitializer` before calling `InvokeInScopeAsync` + (single-iteration; recycle loop comes in Task 5) +- [ ] Unit tests: `CloudAssetStore.Get` throws on missing key and on wrong type; + `Set` + `Get` round-trips correctly; `RemoteLayoutDefinitionService` returns store + contents unchanged; empty store throws with a descriptive message +- [ ] All existing unit and E2E tests pass + +--- + +### 2. [ADR-176](https://jodasoft.atlassian.net/browse/ADR-176) — CSS extraction and stub IDynamicStylesheetProvider + +Wire the Blazor root to consume CSS from `IDynamicStylesheetProvider`, backed by a stub +that returns the current static grid CSS extracted from `app.css`. + +- [ ] `IDynamicStylesheetProvider` interface defined in `Services/Layout/` +- [ ] Grid CSS extracted from `app.css` into a standalone resource; `LayoutStylesheetProvider` + v1 returns this content as a static string +- [ ] Blazor root wired to render `` from + `IDynamicStylesheetProvider` +- [ ] `app.css` no longer contains the extracted grid CSS +- [ ] Unit tests: `LayoutStylesheetProvider.GetCss()` returns non-null content +- [ ] All existing unit and E2E tests pass + +--- + +### 3. [ADR-177](https://jodasoft.atlassian.net/browse/ADR-177) — CompiledLayout DTO and DTO-to-runtime mapping + +Switch the stub orchestrator to push a `CompiledLayout` DTO and implement full DTO-to-runtime +mapping in `RemoteLayoutDefinitionService`. + +- [ ] Stub orchestrator updated: constructs a `CompiledLayout` object in code representing + the same hardcoded layout, without GUTTER (GUTTER is now appended by the definition service) +- [ ] `RemoteLayoutDefinitionService` v2: reads `CompiledLayout` from + `ICloudAssetStore.Get("layout")`; maps element tree per the DTO mapping + table; appends GUTTER; throws descriptive error if store is empty +- [ ] DI registration updated: + `services.AddScoped(sp => sp.GetRequiredService().Get("layout"))` +- [ ] Unit tests: each `CommandType` maps to the correct runtime type; GUTTER always appended + as last root child; unknown `CommandType` throws; empty store throws with descriptive message +- [ ] All existing unit and E2E tests pass + +--- + +### 4. [ADR-178](https://jodasoft.atlassian.net/browse/ADR-178) — JSON parsing, BasicCloudAsset\/JsonCloudAsset\, and stub file-based downloader + +Introduce the asset abstraction and replace in-code `CompiledLayout` construction with JSON +deserialization from a file, so the stub orchestrator exercises the same parse path as the +real orchestrator will. + +- [ ] `BasicCloudAsset` (abstract `ICloudAsset`) and `JsonCloudAsset` (concrete, + configurable `JsonSerializerContext`) implemented +- [ ] `JsonCloudAsset` registered as `ICloudAsset` with layout name, URLs, + and `LayoutContractsJsonContext` +- [ ] **Stub `FileCloudAssetDownloader`**: implements `ICloudAssetDownloader`; reads a stream + from a configured path on disk; `GetByIdAsync` returns null +- [ ] A sample `layout.json` (serialized `CompiledLayout`) checked in for development use +- [ ] Stub orchestrator updated: iterates `IEnumerable`; for each, calls + `FileCloudAssetDownloader.GetActiveAsync` → `asset.ParseAsync` → `CloudAssetStore.Set` +- [ ] Unit tests: `JsonCloudAsset.ParseAsync` correctly deserializes a `CompiledLayout`; + `FileCloudAssetDownloader` returns a stream for the configured path; returns null when + file is absent +- [ ] All existing unit and E2E tests pass + +--- + +### 5. [ADR-179](https://jodasoft.atlassian.net/browse/ADR-179) — ApplicationLifecycle recycle loop and BlazorAppScope.RecycleAsync + +Convert `ApplicationLifecycle.ExecuteAsync` to a recycle loop and implement +`BlazorAppScope.RecycleAsync()`. + +- [ ] `IApplicationRecycleSignal` and `ApplicationRecycleSignal` added to `Services/Lifecycle/` +- [ ] `ApplicationLifecycle.ExecuteAsync` refactored to `while` loop; linked token from + `stoppingToken + signal.Token` passed into scope work item +- [ ] `ApplicationLifecycle` awaits all `IPreScopeInitializer.WaitAsync(ct)` before the + first scope; not re-awaited on subsequent loop iterations +- [ ] Steady-state path: signal fires during wait → cleanup → `RecycleScopeAsync` → + `signal.Reset()` → loop +- [ ] Init-phase path: signal fires during `InitializeAllAsync` → cancel → cleanup → + `signal.Reset()` → loop without `RecycleScopeAsync` +- [ ] `BlazorAppScope.RecycleAsync()` implemented via + `IJSRuntime.InvokeVoidAsync("location.reload")` +- [ ] Unit tests: loop iterates on recycle signal; loop exits cleanly on `stoppingToken`; + second signal during init cancels init and retries without an additional `RecycleScopeAsync` + call; first scope creation waits for `IPreScopeInitializer`; recycles do not re-await + `IPreScopeInitializer` +- [ ] `_doc_Lifecycle.md` updated +- [ ] All existing unit and E2E tests pass + +--- + +### 6. [ADR-180](https://jodasoft.atlassian.net/browse/ADR-180) — Idle detection + +Introduce `IIdleDetector`, its implementation, and the three ViewModel adapter services. + +- [ ] `IIdleDetector` interface defined in `Services/CloudAssets/` +- [ ] `IdleDetector` implements token-based non-idle tracking with cooldown timer +- [ ] `ConversationIdleAdapter`, `ProgrammingModeIdleAdapter`, `CommandExecutionIdleAdapter` + implemented as scoped `IScopedLifecycle` services +- [ ] `IdleCooldownSeconds` sourced from `CloudSettings` +- [ ] Unit tests: `IsIdle` is false while any token is held; cooldown starts when last token + is disposed; `BecameIdle` fires after cooldown; new `StartNonIdle()` during cooldown resets + the timer; adapters hold/release token in response to ViewModel property changes +- [ ] All existing unit and E2E tests pass + +--- + +### 7. [ADR-181](https://jodasoft.atlassian.net/browse/ADR-181) — Real CloudAssetOrchestrator, CloudAssetCache, FileSystemCloudAssetWatchService, and file-based E2E tests + +Replace the stub orchestrator with the real `CloudAssetOrchestrator` backed by the real file +cache and stub file downloader; introduce `FileSystemCloudAssetWatchService` so the full +update path is exercisable and all startup/update scenarios are covered by E2E tests without +any backend dependency. + +- [ ] Real `CloudAssetOrchestrator` (singleton `BackgroundService` + `IPreScopeInitializer`): + loads each `ICloudAsset` from cache in parallel → parse → store → signal complete; in + background calls `FileCloudAssetDownloader.GetActiveAsync` → updates store/cache → + `RequestRecycle()` if asset changed +- [ ] `CloudAssetCache` (real file-backed `ICloudAssetCache`): reads/writes streams to files + under `CloudSettings.CachePath`; creates directory if absent +- [ ] Stub orchestrator removed +- [ ] **`FileSystemCloudAssetWatchService`** (BackgroundService): watches the same configured + file path as `FileCloudAssetDownloader`; on change, downloads → parses → updates cache and + store → idle-defers `IApplicationRecycleSignal.RequestRecycle()`; registered in DI in place + of `CloudAssetWatchService` until Task 12 +- [ ] Unit tests: orchestrator signals after cache load; cache miss causes orchestrator to + wait for downloader; both fail → fatal error; background downloader triggers recycle when + content differs; `WaitAsync` not re-awaited on recycle path; `CloudAssetCache` writes file + on `SaveAsync`; returns stream on hit; null on miss; creates directory if absent +- [ ] E2E tests: two test layout fixtures defined in + `test/AdaptiveRemote.EndToEndTests.TestServices/`: + — **primary-layout** — same commands as the current static layout (Play, Pause, Exit, TiVo, + Power); `FileCloudAssetDownloader` is configured to read this fixture by default + — **updated-layout** — same commands plus `'Guide'` (or another command absent from + primary-layout, chosen during implementation); the presence of this command is the assertion + signal for which layout is active in E2E steps + + The E2E test configuration sets `IdleCooldownSeconds: 0` so idle-deferred recycles happen as + soon as the user is not in an active conversation or command execution. + + Existing steps used without modification: + `Given the application is not running`, + `When I start the application`, + `Then I should see the application in the {LifecyclePhase} phase`, + `Then I should see the '{string}' button is enabled/disabled`, + `Then I should not see any error messages in the logs`, + `Then I should see an error message in the logs:`, + `When I say {string}`, + `Then the application should enter listening mode`, + `Then the application should exit listening mode` + + New steps required — each describes the test environment or what the user observes, not + application internals: + - `Given the local layout cache is empty` — deletes the E2E test cache directory before start + - `Given the local layout cache contains the primary/updated test layout` — pre-seeds the + cache file from the named fixture + - `Given the stub layout file is set to the primary/updated test layout` — writes the named + fixture to the path `FileCloudAssetDownloader` is configured to read + - `Given the stub layout file is absent` — deletes that file + - `When I update the stub layout file to the updated test layout` — overwrites the file at + runtime; `FileSystemCloudAssetWatchService` detects the change and starts the update path + - `Then I should see a fatal startup error message` — verifies the fatal-error UI is visible + +```gherkin + Scenario: App loads layout from stub file when cache is empty + Given the application is not running + And the local layout cache is empty + And the stub layout file is set to the primary test layout + When I start the application + Then I should see the application in the Ready phase + And I should see the 'Play' button is enabled + And I should not see any error messages in the logs + + Scenario: App starts from cache when stub file is unchanged + Given the application is not running + And the local layout cache contains the primary test layout + And the stub layout file is set to the primary test layout + When I start the application + Then I should see the application in the Ready phase + And I should see the 'Play' button is enabled + And I should not see any error messages in the logs + + Scenario: App fails to start when cache is empty and stub file is absent + Given the application is not running + And the local layout cache is empty + And the stub layout file is absent + When I start the application + Then I should see a fatal startup error message + + Scenario: App applies updated layout on first idle cycle after startup + # Cache has primary-layout; stub has updated-layout. Background download detects the + # difference, triggers a recycle; zero cooldown means it fires immediately on idle. + Given the application is not running + And the local layout cache contains the primary test layout + And the stub layout file is set to the updated test layout + When I start the application + Then I should see the application in the Ready phase + And I should see the 'Guide' button is enabled + And I should not see any error messages in the logs + + Scenario: App continues on cached layout when background download fails + # Stub file is absent so the downloader returns null; the app stays on the cached layout. + Given the application is not running + And the local layout cache contains the primary test layout + And the stub layout file is absent + When I start the application + Then I should see the application in the Ready phase + And I should see the 'Play' button is enabled + And I should see an error message in the logs: + """ + Failed to download asset 'layout' + """ + # Note: exact log message text determined in MessageLogger.cs during implementation. + + Scenario: Layout is updated when stub file changes while the user is idle + Given the application is in the Ready phase + When I update the stub layout file to the updated test layout + Then I should see the application in the Ready phase + And I should see the 'Guide' button is enabled + And I should not see any error messages in the logs + + Scenario: Layout update is deferred until after an active conversation ends + Given the application is in the Ready phase + When I say "Hey Remote" + Then the application should enter listening mode + When I update the stub layout file to the updated test layout + And I say "Thank you" + Then the application should exit listening mode + And I should see the application in the Ready phase + And I should see the 'Guide' button is enabled + And I should not see any error messages in the logs +``` +- [ ] All existing unit and E2E tests pass + +--- + +### 8. [ADR-182](https://jodasoft.atlassian.net/browse/ADR-182) — CSS from CompiledLayout + +Switch `LayoutStylesheetProvider` to read `CssDefinitions` from the `CompiledLayout` in the +store; remove the extracted static grid CSS. + +- [ ] `LayoutStylesheetProvider` v2: returns + `ICloudAssetStore.Get("layout").CssDefinitions` +- [ ] Extracted static grid CSS resource removed +- [ ] Sample `layout.json` updated to include representative `CssDefinitions` +- [ ] Unit tests: `LayoutStylesheetProvider` returns `CssDefinitions` from store; null store + throws descriptive error +- [ ] All existing unit and E2E tests pass + +--- + +### 9. [ADR-183](https://jodasoft.atlassian.net/browse/ADR-183) — OAuth token provider + +Introduce `ICloudAuthTokenProvider` and `CognitoTokenProvider`. + +- [ ] `ICloudAuthTokenProvider` defined in `Services/CloudAssets/` +- [ ] `CognitoTokenProvider` POSTs to `CloudSettings.CognitoTokenEndpointUrl` with + `client_credentials` grant; caches token; refreshes before expiry +- [ ] `ClientId`, `ClientSecret`, `CognitoTokenEndpointUrl` present in `CloudSettings` +- [ ] Unit tests: token fetched on first call; cached token reused before expiry; token + refreshed when near expiry; `CancellationToken` respected +- [ ] All existing unit and E2E tests pass + +--- + +### 10. [ADR-184](https://jodasoft.atlassian.net/browse/ADR-184) — E2E test infrastructure — Docker Compose for backend services + +Set up Docker Compose so the real backend services are available before HTTP implementations +are introduced in subsequent tasks. + +- [ ] `docker-compose.yml` starts `CompiledLayoutService` and `NotificationService` with + test configuration +- [ ] Both services expose test-only control endpoints when `ASPNETCORE_ENVIRONMENT=Test` + (to be added to ADR-161 backend spec); these are the seams that make Tasks 11–12 E2E + scenarios exercisable without manual intervention: + - `POST /test/layouts/set-active` — activates a named test layout fixture on the backend + - `POST /test/backend/set-unavailable` / `POST /test/backend/set-available` — makes all + requests return 503 or restores normal operation + - `POST /test/backend/set-download-unavailable` — makes only the download endpoint return 503 + while SSE continues to function + - `POST /test/sse/publish-event` — stores a new active layout version and sends a + `layout-ready` SSE event to all connected clients + - `POST /test/sse/disconnect-all` — forcibly closes all active SSE connections +- [ ] New step definitions added to `AdaptiveRemote.EndToEndTests.Steps` for the + backend-control steps used in Tasks 11–12 +- [ ] E2E test fixture starts and stops the compose stack as part of test setup/teardown +- [ ] Tests wait for service health checks before proceeding +- [ ] `test/_doc_EndToEndTests.md` updated to document Docker-based E2E setup + +--- + +### 11. [ADR-185](https://jodasoft.atlassian.net/browse/ADR-185) — Real CloudAssetDownloader + +Replace `FileCloudAssetDownloader` with the real HTTP `CloudAssetDownloader`; startup +scenarios now run against the real backend. + +- [ ] `CloudAssetDownloader` wraps `HttpClient`; appends resource path for active/by-id + fetches; attaches `Authorization: Bearer` token via `ICloudAuthTokenProvider` +- [ ] `FileCloudAssetDownloader` removed from DI +- [ ] Unit tests: `Authorization` header attached on every request; `GetActiveAsync` + constructs the correct URL; null returned on 404; exception propagated on other HTTP errors +- [ ] E2E tests: startup scenarios repeated against the Docker backend using the test-only + control endpoints from Task 10. Reuses the primary/updated test layout fixtures and all + step definitions from Task 7. + + New backend-control steps (calling the test-only API from Task 10): + - `Given the backend has the primary/updated test layout active` — calls + `POST /test/layouts/set-active` + - `Given the backend is not responding` — calls `POST /test/backend/set-unavailable` + +```gherkin + Scenario: App loads layout from backend when cache is empty + Given the application is not running + And the local layout cache is empty + And the backend has the primary test layout active + When I start the application + Then I should see the application in the Ready phase + And I should see the 'Play' button is enabled + And I should not see any error messages in the logs + + Scenario: App falls back to cache when backend is unavailable at startup + Given the application is not running + And the local layout cache contains the primary test layout + And the backend is not responding + When I start the application + Then I should see the application in the Ready phase + And I should see the 'Play' button is enabled + And I should not see any error messages in the logs + + Scenario: App fails to start when backend is unavailable and cache is empty + Given the application is not running + And the local layout cache is empty + And the backend is not responding + When I start the application + Then I should see a fatal startup error message + + Scenario: App applies updated layout on first idle cycle when backend has newer version + Given the application is not running + And the local layout cache contains the primary test layout + And the backend has the updated test layout active + When I start the application + Then I should see the application in the Ready phase + And I should see the 'Guide' button is enabled + And I should not see any error messages in the logs +``` +- [ ] All existing unit and E2E tests pass + +--- + +### 12. [ADR-186](https://jodasoft.atlassian.net/browse/ADR-186) — Real CloudAssetWatchService — SSE subscription and update flow + +Replace `FileSystemCloudAssetWatchService` with the real SSE-based `CloudAssetWatchService`; +SSE update scenarios now run against the real backend. + +- [ ] `CloudAssetWatchService` opens one SSE connection per `ICloudAsset.StreamUrl` using + `System.Net.ServerSentEvents` +- [ ] On event matching `asset.EventName`: calls + `ICloudAssetDownloader.GetActiveAsync(asset.ResourcePath, ct)`, `asset.ParseAsync`, + `ICloudAssetCache.SaveAsync(asset.Name, ...)`, `ICloudAssetStore.Set(asset.Name, ...)`, + then idle-defers `IApplicationRecycleSignal.RequestRecycle()` +- [ ] Exponential backoff reconnect loop (5 s → 2 min cap, jitter) on any non-cancellation + exception +- [ ] Warning logged per reconnect attempt; error logged after + `CloudSettings.SseMaxConsecutiveFailures` consecutive failures +- [ ] Bearer token attached via `ICloudAuthTokenProvider` +- [ ] `FileSystemCloudAssetWatchService` removed from DI +- [ ] Unit tests: store and cache updated on matching event; non-matching events ignored; + `RequestRecycle()` called after idle; backoff delay increases on repeated failures; second + SSE event overwrites store with newer value without duplicate recycle request +- [ ] E2E tests: SSE-triggered update scenarios run against the Docker backend using the + test-only control endpoints from Task 10. Reuses primary/updated test layout fixtures and + all step definitions from Tasks 7 and 11. + + New backend-control steps (calling the test-only API from Task 10): + - `When the backend publishes the updated test layout` — calls `POST /test/sse/publish-event`, + which stores the updated layout as active and sends a `layout-ready` SSE event to all + connected clients + - `When the backend drops all SSE connections` — calls `POST /test/sse/disconnect-all` + - `Given the backend download endpoint is not responding` — calls + `POST /test/backend/set-download-unavailable` + - `When the backend sends a layout-ready SSE event` — calls `POST /test/sse/publish-event` + with the current active layout unchanged (notifies without providing new content) + +```gherkin + Scenario: Layout is updated when backend publishes while user is idle + Given the application is in the Ready phase + When the backend publishes the updated test layout + Then I should see the application in the Ready phase + And I should see the 'Guide' button is enabled + And I should not see any error messages in the logs + + Scenario: Layout update is deferred until after an active conversation ends + Given the application is in the Ready phase + When I say "Hey Remote" + Then the application should enter listening mode + When the backend publishes the updated test layout + And I say "Thank you" + Then the application should exit listening mode + And I should see the application in the Ready phase + And I should see the 'Guide' button is enabled + And I should not see any error messages in the logs + + Scenario: App continues running when the SSE connection is dropped + Given the application is in the Ready phase + When the backend drops all SSE connections + Then I should see the application in the Ready phase + And I should not see any error messages in the logs + + Scenario: App continues on current layout when download fails after SSE notification + Given the application is in the Ready phase + And the backend download endpoint is not responding + When the backend sends a layout-ready SSE event + Then I should see the application in the Ready phase + And I should see the 'Play' button is enabled + And I should see an error message in the logs: + """ + Failed to download asset 'layout' + """ + # Note: exact log message text determined in MessageLogger.cs during implementation. +``` +- [ ] All existing unit and E2E tests pass + +--- + +## Related Docs + +- [`src/AdaptiveRemote.App/Services/Lifecycle/_doc_Lifecycle.md`](../Lifecycle/_doc_Lifecycle.md) +- [`src/AdaptiveRemote.App/Services/Commands/_doc_Commands.md`](../Commands/_doc_Commands.md) +- [`src/AdaptiveRemote.App/Components/_doc_UI.md`](../../Components/_doc_UI.md) +- [`src/AdaptiveRemote.App/Services/ProgrammaticSettings/_doc_ProgrammaticSettings.md`](../ProgrammaticSettings/_doc_ProgrammaticSettings.md) +- [`src/_doc_Projects.md`](../../../_doc_Projects.md) +- [`src/_spec_LayoutCustomizationService.md`](../../../_spec_LayoutCustomizationService.md) — backend spec (ADR-161); defines `CompiledLayout` wire format, REST endpoints, and SSE event types diff --git a/src/AdaptiveRemote.App/Services/Lifecycle/ApplicationLifecycle.cs b/src/AdaptiveRemote.App/Services/Lifecycle/ApplicationLifecycle.cs index 59619249..6e176456 100644 --- a/src/AdaptiveRemote.App/Services/Lifecycle/ApplicationLifecycle.cs +++ b/src/AdaptiveRemote.App/Services/Lifecycle/ApplicationLifecycle.cs @@ -1,4 +1,5 @@ -using AdaptiveRemote.Logging; +using AdaptiveRemote.Logging; +using AdaptiveRemote.Models; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -9,24 +10,69 @@ internal class ApplicationLifecycle : BackgroundService { private readonly IApplicationScopeProvider _scopeProvider; private readonly ILifecycleViewController _viewController; + private readonly IApplicationRecycleSignal _signal; + private readonly IEnumerable _preInitializers; private readonly MessageLogger _logger; private ScopedLifecycleContainer? _currentContainer; - public ApplicationLifecycle(IApplicationScopeProvider scopeProvider, ILifecycleViewController viewController, ILogger logger) + public ApplicationLifecycle( + IApplicationScopeProvider scopeProvider, + ILifecycleViewController viewController, + IApplicationRecycleSignal signal, + IEnumerable preInitializers, + ILogger logger) { _scopeProvider = scopeProvider; _viewController = viewController; + _signal = signal; + _preInitializers = preInitializers; _logger = new(logger); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - _logger.ApplicationLifecycle_WaitingForScope(); - try { - await _scopeProvider.InvokeInScopeAsync(InitializeLifecycleAsync, stoppingToken); - _logger.ApplicationLifecycle_ScopeReleased(); + // Await all IPreScopeInitializer services before creating the first scope. + // Not re-awaited on scope recycles — the store is already populated. + if (await RunPreInitializersAsync(stoppingToken)) + { + while (!stoppingToken.IsCancellationRequested) + { + _signal.Reset(); + + using CancellationTokenSource linkedCts = + CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, _signal.Token); + + _logger.ApplicationLifecycle_WaitingForScope(); + + bool initialized = await InitializeScopeAsync(linkedCts.Token); + if (!linkedCts.Token.IsCancellationRequested) + { + if (initialized) + { + // Scope is ready; block until stoppingToken or signal.Token fires. + _logger.ApplicationLifecycle_ScopeReady(); + await linkedCts.Token.WaitForCancelledAsync(); + } + else + { + // A fatal error occurred and was logged. + break; + } + } + + if (stoppingToken.IsCancellationRequested) + { + break; + } + + await CleanUpCurrentContainerAsync(default); + + _logger.ApplicationLifecycle_RecyclingScope(); + await _scopeProvider.RecycleScopeAsync(); + } + } } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { @@ -35,50 +81,85 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) catch (Exception ex) { _logger.ApplicationLifecycle_UnhandledError(ex); - await CleanUpCurrentContainerAsync(default); } - await stoppingToken.WaitForCancelledAsync(); - _logger.ApplicationLifecycle_ShuttingDown(); - await CleanUpCurrentContainerAsync(default); } - private async Task InitializeLifecycleAsync(IServiceProvider provider, CancellationToken cancellationToken) + private async Task RunPreInitializersAsync(CancellationToken stoppingToken) { - _currentContainer = SafeGetContainer(provider); + try + { + Task[] initTasks = _preInitializers.Select(init => RunSinglePreInitializerAsync(init, stoppingToken)).ToArray(); + await Task.WhenAll(initTasks); + return true; + } + catch + { + return false; + } + } - if (_currentContainer is not null) + private async Task RunSinglePreInitializerAsync(IPreScopeInitializer initializer, CancellationToken stoppingToken) + { + using ILifecycleActivity activity = _viewController.StartTask(Phrases.Startup_Preinitializing(initializer.Name)); + try { - try - { - await _currentContainer.InitializeAllAsync(cancellationToken); - } - catch (OperationCanceledException) - { - throw; - } - catch + Task waitTask = initializer.WaitAsync(activity, stoppingToken); + if (!waitTask.IsCompleted) { - // Service initialization failures are already logged in ScopedLifecycleContainer. - // Clean up and return normally so ExecuteAsync can log ScopeReleased. - await CleanUpCurrentContainerAsync(default); + _logger.ApplicationLifecycle_WaitingForPreinitializer(initializer.Name); } + await waitTask; } + catch (Exception error) + { + _logger.ApplicationLifecycle_PreinitializerFailed(initializer.Name, error); + activity.SetFatalError(error); + throw; + } + } - ScopedLifecycleContainer? SafeGetContainer(IServiceProvider provider) + private async Task InitializeScopeAsync(CancellationToken cancellationToken) + { + bool initialized = false; + try { - try + await _scopeProvider.InvokeInScopeAsync(async (provider, ct) => { - return provider.GetRequiredService(); - } - catch (Exception ex) - { - _logger.ApplicationLifecycle_ScopeConstructionFailed(ex); - _viewController.SetFatalError(ex); - return null; - } + _currentContainer = SafeGetContainer(provider); + if (_currentContainer is null) + { + return; + } + await _currentContainer.InitializeAllAsync(ct); + initialized = true; + }, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Cancelled by stoppingToken or signal + } + catch + { + // Exceptions from scope creation or initialization are already handled and logged; no need to log again. + } + + return initialized; + } + + private ScopedLifecycleContainer? SafeGetContainer(IServiceProvider provider) + { + try + { + return provider.GetRequiredService(); + } + catch (Exception ex) + { + _logger.ApplicationLifecycle_ScopeConstructionFailed(ex); + _viewController.SetFatalError(ex); + return null; } } diff --git a/src/AdaptiveRemote.App/Services/Lifecycle/ApplicationRecycleSignal.cs b/src/AdaptiveRemote.App/Services/Lifecycle/ApplicationRecycleSignal.cs new file mode 100644 index 00000000..c802ac1d --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Lifecycle/ApplicationRecycleSignal.cs @@ -0,0 +1,48 @@ +namespace AdaptiveRemote.Services.Lifecycle; + +internal sealed class ApplicationRecycleSignal : IApplicationRecycleSignal, IDisposable +{ + private readonly object _sync = new(); + private CancellationTokenSource _cts = new(); + + public CancellationToken Token + { + get + { + lock (_sync) + { + return _cts.Token; + } + } + } + + public void RequestRecycle() + { + lock (_sync) + { + _cts.Cancel(); + } + } + + public void Reset() + { + CancellationTokenSource old; + lock (_sync) + { + old = _cts; + _cts = new CancellationTokenSource(); + } + old.Dispose(); + } + + public void Dispose() + { + CancellationTokenSource old; + lock (_sync) + { + old = _cts; + _cts = new CancellationTokenSource(); + } + old.Dispose(); + } +} diff --git a/src/AdaptiveRemote.App/Services/Lifecycle/IApplicationRecycleSignal.cs b/src/AdaptiveRemote.App/Services/Lifecycle/IApplicationRecycleSignal.cs new file mode 100644 index 00000000..5ea83d35 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Lifecycle/IApplicationRecycleSignal.cs @@ -0,0 +1,28 @@ +namespace AdaptiveRemote.Services.Lifecycle; + +/// +/// Signals that a scope recycle has been requested. ApplicationLifecycle links this token +/// into its scope work item; RequestRecycle() cancels that token whether init is in progress +/// or the loop is in steady-state wait. Reset() is called by ApplicationLifecycle after +/// cleanup, before starting the next init cycle. +/// +internal interface IApplicationRecycleSignal +{ + /// + /// Requests a scope recycle. Cancels . + /// Safe to call from any thread, including concurrently with . + /// + void RequestRecycle(); + + /// + /// The cancellation token that fires when is called. + /// Linked into the scope work item by ApplicationLifecycle. + /// + CancellationToken Token { get; } + + /// + /// Resets the signal after cleanup, replacing the cancelled token with a fresh one. + /// Called by ApplicationLifecycle before starting the next scope iteration. + /// + void Reset(); +} diff --git a/src/AdaptiveRemote.App/Services/Lifecycle/IPreScopeInitializer.cs b/src/AdaptiveRemote.App/Services/Lifecycle/IPreScopeInitializer.cs new file mode 100644 index 00000000..c15babfe --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Lifecycle/IPreScopeInitializer.cs @@ -0,0 +1,21 @@ +namespace AdaptiveRemote.Services.Lifecycle; + +/// +/// Implemented by singleton services that must fully initialize before the first scope is +/// created. ApplicationLifecycle awaits all registrations before calling InvokeInScopeAsync. +/// Not re-awaited on scope recycles. +/// +internal interface IPreScopeInitializer +{ + /// + /// Gets a friendly name for the initializer, used for logging and error messages. + /// + string Name { get; } + + /// + /// Waits for the service to be ready before scope creation can proceed. + /// + /// Lifecycle activity for reporting progress and errors + /// Cancellation token + Task WaitAsync(ILifecycleActivity activity, CancellationToken ct); +} diff --git a/src/AdaptiveRemote.App/Services/Lifecycle/ProgrammingModeActivityDetector.cs b/src/AdaptiveRemote.App/Services/Lifecycle/ProgrammingModeActivityDetector.cs new file mode 100644 index 00000000..3a98a2ee --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Lifecycle/ProgrammingModeActivityDetector.cs @@ -0,0 +1,12 @@ +using AdaptiveRemote.Models; +using AdaptiveRemote.Mvvm; + +namespace AdaptiveRemote.Services.Lifecycle; + +internal class ProgrammingModeActivityDetector : MvvmPropertyActivityDetector +{ + public ProgrammingModeActivityDetector(LifecycleView lifecycleView) + : base(lifecycleView, LifecycleView.IsProgrammingModeProperty) + { + } +} diff --git a/src/AdaptiveRemote.App/Services/Lifecycle/_doc_Lifecycle.md b/src/AdaptiveRemote.App/Services/Lifecycle/_doc_Lifecycle.md index 16982538..14a1ba28 100644 --- a/src/AdaptiveRemote.App/Services/Lifecycle/_doc_Lifecycle.md +++ b/src/AdaptiveRemote.App/Services/Lifecycle/_doc_Lifecycle.md @@ -1,13 +1,16 @@ # Lifecycle Subsystem Architecture & Design ## Overview -The Lifecycle subsystem orchestrates application startup, shutdown, and scoped updates. Its main role is to manage -DI scopes for services that need to be re-initialized when configuration or data changes. It is not responsible for -configuration or service orchestration itself; those are handled by the .NET hosting model. +The Lifecycle subsystem orchestrates application startup, scope recycling, and shutdown. Its main role is to manage +DI scopes for services that need to be re-initialized when configuration or data changes (e.g., a new compiled layout +downloaded from the backend). It is not responsible for configuration or service orchestration itself; those are +handled by the .NET hosting model. ## Responsibilities & Boundaries -- **Scope management:** Creates and recycles DI scopes for "scoped lifecycle services" when updates occur. +- **Scope management:** Creates and recycles DI scopes for "scoped lifecycle services" when layout updates arrive. - **Lifecycle hooks:** Calls `InitializeAsync` and `CleanUpAsync` on services implementing [`IScopedLifecycle`](../IScopedLifecycle.cs) at the start and end of each scope. +- **Pre-scope initialization:** Awaits all [`IPreScopeInitializer`](./IPreScopeInitializer.cs) services (e.g., `CloudAssetOrchestrator`) before creating the first scope. Not re-awaited on recycles — the store is already populated. +- **Recycle signaling:** Responds to [`IApplicationRecycleSignal`](./IApplicationRecycleSignal.cs) to trigger a scope recycle; the signal is fired by cloud asset services when a new layout is available. - **UI independence:** Keeps orchestration logic separate from UI concerns; UI updates are handled via [`ILifecycleViewController`](../ILifecycleViewController.cs) when needed. ## Key Abstractions @@ -15,23 +18,53 @@ configuration or service orchestration itself; those are handled by the .NET hos - [`ScopedBackgroundProcess`](../ScopedBackgroundProcess.cs): Base class for background tasks that run within a scope, adapting `IScopedLifecycle` hooks for async method execution. - [`ILifecycleActivity`](../ILifecycleActivity.cs): Allows services to report progress/status during lifecycle events (useful for UI feedback). - [`IApplicationScope`/`IApplicationScopeProvider`](../IApplicationScopeFactory.cs): Abstracts DI scope creation, enabling sharing of Blazor-created scopes with other services. +- [`IApplicationRecycleSignal`](./IApplicationRecycleSignal.cs): Cross-service mechanism to request a scope recycle without coupling callers to the scope machinery. +- [`IPreScopeInitializer`](./IPreScopeInitializer.cs): Implemented by singleton services that must fully initialize before the first scope is created. ## Scope provider abstraction Blazor creates a DI scope for its own components, and that needs to be shared with all the other application services so that Blazor components can access initialized components. This is handled by `IApplicationScopeProvider`, which will execute work using a scoped IServiceProvider. The components involved are: - [`IApplicationScope`](./IApplicationScope.cs): Represents a DI scope in which work can be run. -- [`BlazorAppScope`](../../Components/BlazorAppScope.cs): Implements `IApplicationScope`. This object is created for the root Blazor component, and pushes itself into the `IApplicationScopeContainer`. +- [`BlazorAppScope`](../../Components/BlazorAppScope.cs): Implements `IApplicationScope`. Created for the root Blazor component; pushes itself into `IApplicationScopeContainer`. `RecycleAsync()` calls `IJSRuntime.InvokeVoidAsync("location.reload")`, causing the browser to reload and create a new Blazor scope. - [`IApplicationScopeContainer`](./IApplicationScopeContainer.cs): A scope object (such as `BlazorAppScope`) can be pushed into this interface, which is then used by `IApplicationScopeProvider` as the current scope. -- [`IApplicationScopeProvider`](./IApplicationScopeProvider.cs): Provides the ability to run work items in the current scope. -- [`ApplicationLifecycle`](./ApplicationLifecycle.cs): Uses `IApplicationScopeProvider` to get a `ScopedServiceContainer`. -- [`ScopedLifecycleContainer`](./ScopedLifecycleContainer.cs): A scoped service that resolves all the `IScopedLifecycle` services and manages calls to `InitializeAsync` and `CleanUpAsync` for the lifetime of the scope. +- [`IApplicationScopeProvider`](./IApplicationScopeProvider.cs): Provides the ability to run work items in the current scope, and to recycle (replace) the current scope. +- [`ApplicationLifecycle`](./ApplicationLifecycle.cs): Uses `IApplicationScopeProvider` to get a `ScopedLifecycleContainer`. +- [`ScopedLifecycleContainer`](./ScopedLifecycleContainer.cs): A scoped service that resolves all the `IScopedLifecycle` services and manages calls to `InitializeAsync` and `CleanUpAsync` for the lifetime of the scope. + +## Recycle loop +`ApplicationLifecycle.ExecuteAsync` runs as a `while` loop. Each iteration: + +1. Creates a linked `CancellationToken` from `stoppingToken + signal.Token`. +2. Calls `TryInitializeScopeAsync`, which invokes `InvokeInScopeAsync` to initialize all scoped services. Returns `true` if initialization completed successfully. +3. If init succeeded, logs `ScopeReady` and waits for either token to fire via `WaitForCancelledAsync` (returns normally — no exception). +4. Runs cleanup unconditionally, then branches: + + **Steady-state recycle** (signal fires after init completes, `stoppingToken` not set): + cleanup → `RecycleScopeAsync()` (triggers browser reload) → `signal.Reset()` → next iteration. + + **Init-phase recycle** (signal fires while `InitializeAllAsync` is running): + `InitializeAllAsync` cancels → `TryInitializeScopeAsync` returns `false` → cleanup → `signal.Reset()` → next iteration without a browser reload (the scope TCS is still valid). + + **Shutdown** (stoppingToken fires at any point): break the loop. + + **Init failure** (non-OCE exception during init): cleanup → log `ScopeReleased` → break the loop. + +5. After the loop: wait for `stoppingToken` (already cancelled), log `ShuttingDown`, run final cleanup. + +The **pre-initializers** (`IPreScopeInitializer`) are only awaited once — before the first scope — and are not re-awaited on recycles, since the asset store is already populated after the first successful scope. + +## Recycle signal +[`IApplicationRecycleSignal`](./IApplicationRecycleSignal.cs) / [`ApplicationRecycleSignal`](./ApplicationRecycleSignal.cs): +- `RequestRecycle()`: cancels the internal `CancellationTokenSource`. +- `Token`: the `CancellationToken` linked into the scope work item. +- `Reset()`: disposes the old CTS and creates a fresh one; called by `ApplicationLifecycle` after cleanup, before the next loop iteration. + +Callers (cloud asset services) call `RequestRecycle()` without knowing how the recycle is executed. ## Testability - The subsystem is unit tested using mock `IScopedLifecycle` services to verify correct orchestration and error handling. - -## Future Plans -- The update cycle is not yet implemented, but the architecture is designed to support live updates (e.g., remote layouts, speech models, configuration) so that services can reinitialize with new data without a full restart. +- Recycle behavior is tested by injecting a real `ApplicationRecycleSignal` and firing it at specific points. ## Updating This Document Update this document only when the overall design or boundaries of the Lifecycle subsystem change, or when new features are added. For implementation details, refer to source code and inline comments. diff --git a/src/AdaptiveRemote.App/Services/Testing/IUITestService.cs b/src/AdaptiveRemote.App/Services/Testing/IUITestService.cs index 855f6163..107c805e 100644 --- a/src/AdaptiveRemote.App/Services/Testing/IUITestService.cs +++ b/src/AdaptiveRemote.App/Services/Testing/IUITestService.cs @@ -53,4 +53,13 @@ public partial interface IUITestService : IDisposable /// Cancellation token for the operation. /// The inner HTML of the first matching element, or null if not found or not visible. Task GetInnerHtmlFromElementWithCssClassAsync(string cssClass, CancellationToken cancellationToken); + + /// + /// Gets a CSS property value from the first stylesheet rule that matches the provided selector. + /// + /// The exact CSS selector text to match. + /// The CSS property name to read (for example, display). + /// Cancellation token for the operation. + /// The matching property value, or null if no matching rule/property exists. + Task GetStylesheetRulePropertyValueAsync(string selector, string propertyName, CancellationToken cancellationToken); } diff --git a/src/AdaptiveRemote.App/wwwroot/css/app.css b/src/AdaptiveRemote.App/wwwroot/css/app.css index 83ee5b09..2116cdd2 100644 --- a/src/AdaptiveRemote.App/wwwroot/css/app.css +++ b/src/AdaptiveRemote.App/wwwroot/css/app.css @@ -84,214 +84,41 @@ div.conversation-speaking-message { left: 50%; transform: translate(-50%, -50%); } +#LifecycleTitle { + height: 50vh; + width: 100%; + color: white; + position: relative; +} +#LifecycleTitle > div { + text-align: center; + position: absolute; + width: 100%; + bottom: 0; +} +#LifecycleTaskDescription { + text-align: center; + color: white; + font-size: 36px; +} #ROOT { display: grid; grid-template-rows: 6fr 3fr 1fr; grid-template-columns: 3fr 2fr; - grid-gap: 10px; grid-gap: 20px; width: 98vw; height: 96vh; padding: 2vh 1vw; } -#ROOT #DPAD { - grid-row: 1; - grid-column: 1; - display: grid; - grid-template-rows: repeat(3, 1fr); - grid-template-columns: repeat(3, 1fr); - grid-gap: 10px; -} -#ROOT #DPAD #UP { - grid-row: 1; - grid-column: 2; -} -#ROOT #DPAD #DOWN { - grid-row: 3; - grid-column: 2; -} -#ROOT #DPAD #LEFT { - grid-row: 2; - grid-column: 1; -} -#ROOT #DPAD #RIGHT { - grid-row: 2; - grid-column: 3; -} -#ROOT #DPAD #SELECT { - grid-row: 2; - grid-column: 2; -} -#ROOT #DPAD #POWER { - grid-row: 1; - grid-column: 1; - margin: 20px; - background-color: #aa2525; - border-color: #561313; - border-width: 2px; - color: black; -} -#ROOT #DPAD #POWER:hover { - background-color: #bf2a2a; -} -#ROOT #DPAD #POWER:active, -#ROOT #DPAD #POWER.btn-active { - background-color: #d74545; -} -#ROOT #DPAD #POWER.btn-not-programmed { - color: #aa2525; - border-color: #aa2525; - background-color: #222; - border-width: 5px; -} -#ROOT #DPAD #POWER.btn-not-programmed:hover { - background-color: #2f2f2f; -} -#ROOT #DPAD #POWER.btn-not-programmed:active, -#ROOT #DPAD #POWER.btn-not-programmed.btn-active { - background-color: #484848; -} -#ROOT #DPAD #POWER.btn-disabled { - background-color: #888888; - color: #3c3c3c; - border-color: #555555; -} -#ROOT #DPAD #POWERON { - display: none; -} -#ROOT #DPAD #POWEROFF { - display: none; -} -#ROOT #DPAD #BACK { - grid-row: 3; - grid-column: 1; - margin: 20px; -} -#ROOT #WELL { - grid-row: 1; - grid-column: 2; - margin: -0.5%; -} -#ROOT #WELL > button { - width: 49%; - height: 19%; - margin: 0.5%; -} -#ROOT #PLAYBACK { - grid-row: 2; - grid-column: 1; - display: grid; - grid-template-rows: repeat(3, 1fr); - grid-template-columns: repeat(5, 1fr); - grid-gap: 10px; -} -#ROOT #PLAYBACK #REPLAY { - grid-row: 1; - grid-column: 1; -} -#ROOT #PLAYBACK #PLAY { - grid-row: 1; - grid-column: 2; -} -#ROOT #PLAYBACK #PAUSE { - grid-row: 1; - grid-column: 3; -} -#ROOT #PLAYBACK #RECORD { - grid-row: 1; - grid-column: 4; -} -#ROOT #PLAYBACK #SKIP { - grid-row: 1; - grid-column: 5; -} -#ROOT #CHANNELANDVOLUME { - grid-row: 2; - grid-column: 2; - display: grid; - grid-template-rows: 36pt 1fr 1fr; - grid-template-columns: 2fr 1fr 2fr; - grid-gap: 10px; -} -#ROOT #CHANNELANDVOLUME:before { - grid-row: 1; - grid-column: 1; - font-size: 36pt; - font-weight: bold; - color: #62b0ff; - content: "Channel"; - text-align: center; -} -#ROOT #CHANNELANDVOLUME:after { - grid-row: 1; - grid-column: 3; - font-size: 36pt; - font-weight: bold; - color: #62b0ff; - content: "Volume"; - text-align: center; -} -#ROOT #CHANNELANDVOLUME #CHANNELUP { - grid-row: 2; - grid-column: 1; -} -#ROOT #CHANNELANDVOLUME #CHANNELDOWN { - grid-row: 3; - grid-column: 1; -} -#ROOT #CHANNELANDVOLUME #VOLUMEUP { - grid-row: 2; - grid-column: 3; -} -#ROOT #CHANNELANDVOLUME #VOLUMEDOWN { - grid-row: 3; - grid-column: 3; -} -#ROOT #CHANNELANDVOLUME #MUTE { - grid-row: 2; - grid-column: 2; - grid-row-end: span 2; -} #ROOT #GUTTER { grid-row: 3; grid-column-start: 1; grid-column-end: span 2; - grid-gap: 20px; display: grid; grid-template-rows: 1fr; grid-template-columns: 6fr 1fr 1fr; grid-gap: 10px; } -#ROOT #GUTTER #EXIT { - grid-row: 1; - grid-column: 3; -} -#ROOT #GUTTER #LEARN { - grid-row: 1; - grid-column: 2; - margin: 10px; -} -#ROOT #GUTTER #LISTENING { - grid-row: 1; - grid-column: 1; -} -#LifecycleTitle { - height: 50vh; - width: 100%; - color: white; - position: relative; -} -#LifecycleTitle > div { - text-align: center; - position: absolute; - width: 100%; - bottom: 0; -} -#LifecycleTaskDescription { - text-align: center; - color: white; - font-size: 36px; -} #blazor-error-ui { background: lightyellow; bottom: 0; diff --git a/src/AdaptiveRemote.App/wwwroot/css/app.less b/src/AdaptiveRemote.App/wwwroot/css/app.less index fbd3bbec..0fd18add 100644 --- a/src/AdaptiveRemote.App/wwwroot/css/app.less +++ b/src/AdaptiveRemote.App/wwwroot/css/app.less @@ -6,9 +6,28 @@ html, body { @import "button_ui.less"; @import "conversation_ui.less"; -@import "layout.less"; @import "loading_screen.less"; +#ROOT { + display: grid; + grid-template-rows: 6fr 3fr 1fr; + grid-template-columns: 3fr 2fr; + grid-gap: 20px; + width: 98vw; + height: 96vh; + padding: 2vh 1vw; + + #GUTTER { + grid-row: 3; + grid-column-start: 1; + grid-column-end: span 2; + display: grid; + grid-template-rows: 1fr; + grid-template-columns: 6fr 1fr 1fr; + grid-gap: 10px; + } +} + #blazor-error-ui { background: lightyellow; bottom: 0; diff --git a/src/AdaptiveRemote.App/wwwroot/css/app.min.css b/src/AdaptiveRemote.App/wwwroot/css/app.min.css index 97e45add..030a2891 100644 --- a/src/AdaptiveRemote.App/wwwroot/css/app.min.css +++ b/src/AdaptiveRemote.App/wwwroot/css/app.min.css @@ -1 +1 @@ -html,body{font-family:'Segoe UI',Helvetica,Arial,sans-serif;font-size:48px;font-weight:bold;background-color:#222;}html,body{margin:0;}.btn-primary{color:#222;border-radius:15px;font-size:36pt;font-weight:bold;cursor:pointer;background-color:#62b0ff;border-color:#007dfb;border-width:2px;}.btn-primary:hover{background-color:#7cbdff;}.btn-primary:active,.btn-primary.btn-active{background-color:#afd6ff;}.btn-primary.btn-not-programmed{color:#62b0ff;border-color:#62b0ff;background-color:#222;border-width:5px;}.btn-primary.btn-not-programmed:hover{background-color:#2f2f2f;}.btn-primary.btn-not-programmed:active,.btn-primary.btn-not-programmed.btn-active{background-color:#484848;}.btn-primary.btn-disabled{background-color:#888;color:#3c3c3c;border-color:#555;}div.conversation-border{pointer-events:none;position:fixed;top:-20px;left:-20px;border-color:#ffea00;border-style:solid;border-width:30px;border-radius:45px;}div.conversation-border div{width:100vw;height:100vh;margin:-10px;}div.conversation-status-message{color:#ffea00;text-align:center;padding:20px;border-radius:15px;}div.conversation-status-message.clickable{cursor:pointer;}div.conversation-status-message.clickable:hover{background-color:#484848;}div.conversation-speaking-message{font-size:24px;background-color:#222;border-radius:15px;border:5px solid #ffea00;margin:-5px;color:#fff;padding:0 48px;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);}#ROOT{display:grid;grid-template-rows:6fr 3fr 1fr;grid-template-columns:3fr 2fr;grid-gap:10px;grid-gap:20px;width:98vw;height:96vh;padding:2vh 1vw;}#ROOT #DPAD{grid-row:1;grid-column:1;display:grid;grid-template-rows:repeat(3,1fr);grid-template-columns:repeat(3,1fr);grid-gap:10px;}#ROOT #DPAD #UP{grid-row:1;grid-column:2;}#ROOT #DPAD #DOWN{grid-row:3;grid-column:2;}#ROOT #DPAD #LEFT{grid-row:2;grid-column:1;}#ROOT #DPAD #RIGHT{grid-row:2;grid-column:3;}#ROOT #DPAD #SELECT{grid-row:2;grid-column:2;}#ROOT #DPAD #POWER{grid-row:1;grid-column:1;margin:20px;background-color:#aa2525;border-color:#561313;border-width:2px;color:#000;}#ROOT #DPAD #POWER:hover{background-color:#bf2a2a;}#ROOT #DPAD #POWER:active,#ROOT #DPAD #POWER.btn-active{background-color:#d74545;}#ROOT #DPAD #POWER.btn-not-programmed{color:#aa2525;border-color:#aa2525;background-color:#222;border-width:5px;}#ROOT #DPAD #POWER.btn-not-programmed:hover{background-color:#2f2f2f;}#ROOT #DPAD #POWER.btn-not-programmed:active,#ROOT #DPAD #POWER.btn-not-programmed.btn-active{background-color:#484848;}#ROOT #DPAD #POWER.btn-disabled{background-color:#888;color:#3c3c3c;border-color:#555;}#ROOT #DPAD #POWERON{display:none;}#ROOT #DPAD #POWEROFF{display:none;}#ROOT #DPAD #BACK{grid-row:3;grid-column:1;margin:20px;}#ROOT #WELL{grid-row:1;grid-column:2;margin:-.5%;}#ROOT #WELL>button{width:49%;height:19%;margin:.5%;}#ROOT #PLAYBACK{grid-row:2;grid-column:1;display:grid;grid-template-rows:repeat(3,1fr);grid-template-columns:repeat(5,1fr);grid-gap:10px;}#ROOT #PLAYBACK #REPLAY{grid-row:1;grid-column:1;}#ROOT #PLAYBACK #PLAY{grid-row:1;grid-column:2;}#ROOT #PLAYBACK #PAUSE{grid-row:1;grid-column:3;}#ROOT #PLAYBACK #RECORD{grid-row:1;grid-column:4;}#ROOT #PLAYBACK #SKIP{grid-row:1;grid-column:5;}#ROOT #CHANNELANDVOLUME{grid-row:2;grid-column:2;display:grid;grid-template-rows:36pt 1fr 1fr;grid-template-columns:2fr 1fr 2fr;grid-gap:10px;}#ROOT #CHANNELANDVOLUME:before{grid-row:1;grid-column:1;font-size:36pt;font-weight:bold;color:#62b0ff;content:"Channel";text-align:center;}#ROOT #CHANNELANDVOLUME:after{grid-row:1;grid-column:3;font-size:36pt;font-weight:bold;color:#62b0ff;content:"Volume";text-align:center;}#ROOT #CHANNELANDVOLUME #CHANNELUP{grid-row:2;grid-column:1;}#ROOT #CHANNELANDVOLUME #CHANNELDOWN{grid-row:3;grid-column:1;}#ROOT #CHANNELANDVOLUME #VOLUMEUP{grid-row:2;grid-column:3;}#ROOT #CHANNELANDVOLUME #VOLUMEDOWN{grid-row:3;grid-column:3;}#ROOT #CHANNELANDVOLUME #MUTE{grid-row:2;grid-column:2;grid-row-end:span 2;}#ROOT #GUTTER{grid-row:3;grid-column-start:1;grid-column-end:span 2;grid-gap:20px;display:grid;grid-template-rows:1fr;grid-template-columns:6fr 1fr 1fr;grid-gap:10px;}#ROOT #GUTTER #EXIT{grid-row:1;grid-column:3;}#ROOT #GUTTER #LEARN{grid-row:1;grid-column:2;margin:10px;}#ROOT #GUTTER #LISTENING{grid-row:1;grid-column:1;}#LifecycleTitle{height:50vh;width:100%;color:#fff;position:relative;}#LifecycleTitle>div{text-align:center;position:absolute;width:100%;bottom:0;}#LifecycleTaskDescription{text-align:center;color:#fff;font-size:36px;}#blazor-error-ui{background:#ffffe0;bottom:0;box-shadow:0 -1px 2px rgba(0,0,0,.2);display:none;left:0;padding:.6rem 1.25rem .7rem 1.25rem;position:fixed;width:100%;z-index:1000;}#blazor-error-ui .dismiss{cursor:pointer;position:absolute;right:.75rem;top:.5rem;} \ No newline at end of file +html,body{font-family:'Segoe UI',Helvetica,Arial,sans-serif;font-size:48px;font-weight:bold;background-color:#222;}html,body{margin:0;}.btn-primary{color:#222;border-radius:15px;font-size:36pt;font-weight:bold;cursor:pointer;background-color:#62b0ff;border-color:#007dfb;border-width:2px;}.btn-primary:hover{background-color:#7cbdff;}.btn-primary:active,.btn-primary.btn-active{background-color:#afd6ff;}.btn-primary.btn-not-programmed{color:#62b0ff;border-color:#62b0ff;background-color:#222;border-width:5px;}.btn-primary.btn-not-programmed:hover{background-color:#2f2f2f;}.btn-primary.btn-not-programmed:active,.btn-primary.btn-not-programmed.btn-active{background-color:#484848;}.btn-primary.btn-disabled{background-color:#888;color:#3c3c3c;border-color:#555;}div.conversation-border{pointer-events:none;position:fixed;top:-20px;left:-20px;border-color:#ffea00;border-style:solid;border-width:30px;border-radius:45px;}div.conversation-border div{width:100vw;height:100vh;margin:-10px;}div.conversation-status-message{color:#ffea00;text-align:center;padding:20px;border-radius:15px;}div.conversation-status-message.clickable{cursor:pointer;}div.conversation-status-message.clickable:hover{background-color:#484848;}div.conversation-speaking-message{font-size:24px;background-color:#222;border-radius:15px;border:5px solid #ffea00;margin:-5px;color:#fff;padding:0 48px;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);}#LifecycleTitle{height:50vh;width:100%;color:#fff;position:relative;}#LifecycleTitle>div{text-align:center;position:absolute;width:100%;bottom:0;}#LifecycleTaskDescription{text-align:center;color:#fff;font-size:36px;}#ROOT{display:grid;grid-template-rows:6fr 3fr 1fr;grid-template-columns:3fr 2fr;grid-gap:20px;width:98vw;height:96vh;padding:2vh 1vw;}#ROOT #GUTTER{grid-row:3;grid-column-start:1;grid-column-end:span 2;display:grid;grid-template-rows:1fr;grid-template-columns:6fr 1fr 1fr;grid-gap:10px;}#blazor-error-ui{background:#ffffe0;bottom:0;box-shadow:0 -1px 2px rgba(0,0,0,.2);display:none;left:0;padding:.6rem 1.25rem .7rem 1.25rem;position:fixed;width:100%;z-index:1000;}#blazor-error-ui .dismiss{cursor:pointer;position:absolute;right:.75rem;top:.5rem;} \ No newline at end of file diff --git a/src/AdaptiveRemote.Contracts/CommandType.cs b/src/AdaptiveRemote.Contracts/CommandType.cs index 5ea8a4f9..240b57fe 100644 --- a/src/AdaptiveRemote.Contracts/CommandType.cs +++ b/src/AdaptiveRemote.Contracts/CommandType.cs @@ -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 @@ -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))] public enum CommandType { Lifecycle, TiVo, IR } diff --git a/src/AdaptiveRemote.Headless/AdaptiveRemote.Headless.csproj b/src/AdaptiveRemote.Headless/AdaptiveRemote.Headless.csproj index 695eb357..8110e8dc 100644 --- a/src/AdaptiveRemote.Headless/AdaptiveRemote.Headless.csproj +++ b/src/AdaptiveRemote.Headless/AdaptiveRemote.Headless.csproj @@ -7,6 +7,11 @@ enable + + + + + diff --git a/src/AdaptiveRemote.Headless/appsettings.Development.json b/src/AdaptiveRemote.Headless/appsettings.Development.json new file mode 100644 index 00000000..87ad47dc --- /dev/null +++ b/src/AdaptiveRemote.Headless/appsettings.Development.json @@ -0,0 +1,5 @@ +{ + "CloudSettings": { + "StubFilePath": "dev/layout.json" + } +} diff --git a/src/AdaptiveRemote.Headless/dev/layout.json b/src/AdaptiveRemote.Headless/dev/layout.json new file mode 100644 index 00000000..b62bebdc --- /dev/null +++ b/src/AdaptiveRemote.Headless/dev/layout.json @@ -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": "#ROOT #DPAD { grid-row: 1; grid-column: 1; display: grid; grid-template-rows: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr); grid-gap: 10px; } #ROOT #DPAD #UP { grid-row: 1; grid-column: 2; } #ROOT #DPAD #DOWN { grid-row: 3; grid-column: 2; } #ROOT #DPAD #LEFT { grid-row: 2; grid-column: 1; } #ROOT #DPAD #RIGHT { grid-row: 2; grid-column: 3; } #ROOT #DPAD #SELECT { grid-row: 2; grid-column: 2; } #ROOT #DPAD #POWER { grid-row: 1; grid-column: 1; margin: 20px; background-color: #aa2525; border-color: #561313; border-width: 2px; color: black; } #ROOT #DPAD #POWER:hover { background-color: #bf2a2a; } #ROOT #DPAD #POWER:active, #ROOT #DPAD #POWER.btn-active { background-color: #d74545; } #ROOT #DPAD #POWER.btn-not-programmed { color: #aa2525; border-color: #aa2525; background-color: #222; border-width: 5px; } #ROOT #DPAD #POWER.btn-not-programmed:hover { background-color: #2f2f2f; } #ROOT #DPAD #POWER.btn-not-programmed:active, #ROOT #DPAD #POWER.btn-not-programmed.btn-active { background-color: #484848; } #ROOT #DPAD #POWER.btn-disabled { background-color: #888888; color: #3c3c3c; border-color: #555555; } #ROOT #DPAD #POWERON { display: none; } #ROOT #DPAD #POWEROFF { display: none; } #ROOT #DPAD #BACK { grid-row: 3; grid-column: 1; margin: 20px; } #ROOT #WELL { grid-row: 1; grid-column: 2; margin: -0.5%; } #ROOT #WELL > button { width: 49%; height: 19%; margin: 0.5%; } #ROOT #PLAYBACK { grid-row: 2; grid-column: 1; display: grid; grid-template-rows: repeat(3, 1fr); grid-template-columns: repeat(5, 1fr); grid-gap: 10px; } #ROOT #PLAYBACK #REPLAY { grid-row: 1; grid-column: 1; } #ROOT #PLAYBACK #PLAY { grid-row: 1; grid-column: 2; } #ROOT #PLAYBACK #PAUSE { grid-row: 1; grid-column: 3; } #ROOT #PLAYBACK #RECORD { grid-row: 1; grid-column: 4; } #ROOT #PLAYBACK #SKIP { grid-row: 1; grid-column: 5; } #ROOT #CHANNELANDVOLUME { grid-row: 2; grid-column: 2; display: grid; grid-template-rows: 36pt 1fr 1fr; grid-template-columns: 2fr 1fr 2fr; grid-gap: 10px; } #ROOT #CHANNELANDVOLUME:before { grid-row: 1; grid-column: 1; font-size: 36pt; font-weight: bold; color: #62b0ff; content: \"Channel\"; text-align: center; } #ROOT #CHANNELANDVOLUME:after { grid-row: 1; grid-column: 3; font-size: 36pt; font-weight: bold; color: #62b0ff; content: \"Volume\"; text-align: center; } #ROOT #CHANNELANDVOLUME #CHANNELUP { grid-row: 2; grid-column: 1; } #ROOT #CHANNELANDVOLUME #CHANNELDOWN { grid-row: 3; grid-column: 1; } #ROOT #CHANNELANDVOLUME #VOLUMEUP { grid-row: 2; grid-column: 3; } #ROOT #CHANNELANDVOLUME #VOLUMEDOWN { grid-row: 3; grid-column: 3; } #ROOT #CHANNELANDVOLUME #MUTE { grid-row: 2; grid-column: 2; grid-row-end: span 2; } #ROOT #GUTTER { grid-row: 3; grid-column-start: 1; grid-column-end: span 2; grid-gap: 20px; display: grid; grid-template-rows: 1fr; grid-template-columns: 6fr 1fr 1fr; grid-gap: 10px; } #ROOT #GUTTER #EXIT { grid-row: 1; grid-column: 3; } #ROOT #GUTTER #LEARN { grid-row: 1; grid-column: 2; margin: 10px; } #ROOT #GUTTER #LISTENING { grid-row: 1; grid-column: 1; }", + "compiledAt": "2026-04-19T00:00:00+00:00" +} diff --git a/src/AdaptiveRemote/AdaptiveRemote.csproj b/src/AdaptiveRemote/AdaptiveRemote.csproj index a9085492..fb8df2fe 100644 --- a/src/AdaptiveRemote/AdaptiveRemote.csproj +++ b/src/AdaptiveRemote/AdaptiveRemote.csproj @@ -23,6 +23,7 @@ + diff --git a/src/AdaptiveRemote/appsettings.Development.json b/src/AdaptiveRemote/appsettings.Development.json index 27804e97..65b56f7f 100644 --- a/src/AdaptiveRemote/appsettings.Development.json +++ b/src/AdaptiveRemote/appsettings.Development.json @@ -7,5 +7,8 @@ "clientSecret": "", "scope": "" } + }, + "CloudSettings": { + "StubFilePath": "dev/layout.json" } } diff --git a/src/AdaptiveRemote/dev/layout.json b/src/AdaptiveRemote/dev/layout.json new file mode 100644 index 00000000..b62bebdc --- /dev/null +++ b/src/AdaptiveRemote/dev/layout.json @@ -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": "#ROOT #DPAD { grid-row: 1; grid-column: 1; display: grid; grid-template-rows: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr); grid-gap: 10px; } #ROOT #DPAD #UP { grid-row: 1; grid-column: 2; } #ROOT #DPAD #DOWN { grid-row: 3; grid-column: 2; } #ROOT #DPAD #LEFT { grid-row: 2; grid-column: 1; } #ROOT #DPAD #RIGHT { grid-row: 2; grid-column: 3; } #ROOT #DPAD #SELECT { grid-row: 2; grid-column: 2; } #ROOT #DPAD #POWER { grid-row: 1; grid-column: 1; margin: 20px; background-color: #aa2525; border-color: #561313; border-width: 2px; color: black; } #ROOT #DPAD #POWER:hover { background-color: #bf2a2a; } #ROOT #DPAD #POWER:active, #ROOT #DPAD #POWER.btn-active { background-color: #d74545; } #ROOT #DPAD #POWER.btn-not-programmed { color: #aa2525; border-color: #aa2525; background-color: #222; border-width: 5px; } #ROOT #DPAD #POWER.btn-not-programmed:hover { background-color: #2f2f2f; } #ROOT #DPAD #POWER.btn-not-programmed:active, #ROOT #DPAD #POWER.btn-not-programmed.btn-active { background-color: #484848; } #ROOT #DPAD #POWER.btn-disabled { background-color: #888888; color: #3c3c3c; border-color: #555555; } #ROOT #DPAD #POWERON { display: none; } #ROOT #DPAD #POWEROFF { display: none; } #ROOT #DPAD #BACK { grid-row: 3; grid-column: 1; margin: 20px; } #ROOT #WELL { grid-row: 1; grid-column: 2; margin: -0.5%; } #ROOT #WELL > button { width: 49%; height: 19%; margin: 0.5%; } #ROOT #PLAYBACK { grid-row: 2; grid-column: 1; display: grid; grid-template-rows: repeat(3, 1fr); grid-template-columns: repeat(5, 1fr); grid-gap: 10px; } #ROOT #PLAYBACK #REPLAY { grid-row: 1; grid-column: 1; } #ROOT #PLAYBACK #PLAY { grid-row: 1; grid-column: 2; } #ROOT #PLAYBACK #PAUSE { grid-row: 1; grid-column: 3; } #ROOT #PLAYBACK #RECORD { grid-row: 1; grid-column: 4; } #ROOT #PLAYBACK #SKIP { grid-row: 1; grid-column: 5; } #ROOT #CHANNELANDVOLUME { grid-row: 2; grid-column: 2; display: grid; grid-template-rows: 36pt 1fr 1fr; grid-template-columns: 2fr 1fr 2fr; grid-gap: 10px; } #ROOT #CHANNELANDVOLUME:before { grid-row: 1; grid-column: 1; font-size: 36pt; font-weight: bold; color: #62b0ff; content: \"Channel\"; text-align: center; } #ROOT #CHANNELANDVOLUME:after { grid-row: 1; grid-column: 3; font-size: 36pt; font-weight: bold; color: #62b0ff; content: \"Volume\"; text-align: center; } #ROOT #CHANNELANDVOLUME #CHANNELUP { grid-row: 2; grid-column: 1; } #ROOT #CHANNELANDVOLUME #CHANNELDOWN { grid-row: 3; grid-column: 1; } #ROOT #CHANNELANDVOLUME #VOLUMEUP { grid-row: 2; grid-column: 3; } #ROOT #CHANNELANDVOLUME #VOLUMEDOWN { grid-row: 3; grid-column: 3; } #ROOT #CHANNELANDVOLUME #MUTE { grid-row: 2; grid-column: 2; grid-row-end: span 2; } #ROOT #GUTTER { grid-row: 3; grid-column-start: 1; grid-column-end: span 2; grid-gap: 20px; display: grid; grid-template-rows: 1fr; grid-template-columns: 6fr 1fr 1fr; grid-gap: 10px; } #ROOT #GUTTER #EXIT { grid-row: 1; grid-column: 3; } #ROOT #GUTTER #LEARN { grid-row: 1; grid-column: 2; margin: 10px; } #ROOT #GUTTER #LISTENING { grid-row: 1; grid-column: 1; }", + "compiledAt": "2026-04-19T00:00:00+00:00" +} diff --git a/src/_spec_LayoutCustomizationService.md b/src/_spec_LayoutCustomizationService.md index 283c2cfd..4e8b70c2 100644 --- a/src/_spec_LayoutCustomizationService.md +++ b/src/_spec_LayoutCustomizationService.md @@ -309,7 +309,7 @@ Test projects follow the pattern `.Tests` under `test/`. // IR — payload programmed via remote, stored in ProgrammaticSettings // Others — keyed by Name // Subtypes with additional properties are deferred until a concrete need arises. -public enum CommandType { Lifecycle, TiVo, IR, Action } +public enum CommandType { Lifecycle, TiVo, IR } // Shared behavioral interface — prevents drift between the compiled and raw command types. // Adding a new behavioral property means updating this interface first; the compiler diff --git a/test/AdaptiveRemote.App.Tests/Services/CloudAssets/CloudAssetCacheTests.cs b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/CloudAssetCacheTests.cs new file mode 100644 index 00000000..be6e0bfc --- /dev/null +++ b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/CloudAssetCacheTests.cs @@ -0,0 +1,128 @@ +using System.Text; +using AdaptiveRemote.TestUtilities; +using FluentAssertions; + +namespace AdaptiveRemote.Services.CloudAssets; + +[TestClass] +public class CloudAssetCacheTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + private const string AssetName = "layout"; + private const string CachePath = "cache"; + private static readonly string CacheFile = Path.Combine("cache", "layout.cache"); + private const string CacheDir = CachePath; + + private static CloudAssetCache MakeSut(MockFileSystem fileSystem, string cachePath = CachePath) + => new(new MockOptions(new CloudSettings { CachePath = cachePath }), fileSystem.Object); + + [TestMethod] + public void CloudAssetCache_SaveAsync_WritesFileWithCorrectContent() + { + // Arrange + MockFileSystem fileSystem = new(); + fileSystem.AddDirectory(CacheDir); + byte[] content = "test-payload"u8.ToArray(); + CloudAssetCache sut = MakeSut(fileSystem); + + // Act + sut.SaveAsync(AssetName, new MemoryStream(content), CancellationToken.None) + .Should().BeCompleteWithin(Timeout); + + // Assert + fileSystem.VerifyFileContents(CacheFile, "test-payload"); + } + + [TestMethod] + public void CloudAssetCache_SaveAsync_CreatesDirectoryWhenAbsent() + { + // Arrange + MockFileSystem fileSystem = new(); + fileSystem.Expect_CreateDirectory_ForPath(CacheDir); + byte[] content = "data"u8.ToArray(); + CloudAssetCache sut = MakeSut(fileSystem); + + // Act + sut.SaveAsync(AssetName, new MemoryStream(content), CancellationToken.None) + .Should().BeCompleteWithin(Timeout); + + // Assert + fileSystem.Verify(); + } + + [TestMethod] + public void CloudAssetCache_SaveAsync_DoesNotCreateDirectoryWhenPresent() + { + // Arrange + MockFileSystem fileSystem = new(); + fileSystem.AddDirectory(CacheDir); + fileSystem.Expect_CreateDirectory_IsNotCalled(); + CloudAssetCache sut = MakeSut(fileSystem); + + // Act + sut.SaveAsync(AssetName, new MemoryStream("data"u8.ToArray()), CancellationToken.None) + .Should().BeCompleteWithin(Timeout); + + // Assert + fileSystem.Verify(); + } + + [TestMethod] + public void CloudAssetCache_LoadAsync_ReturnsStreamOnCacheHit() + { + // Arrange + MockFileSystem fileSystem = new(); + fileSystem.AddFile(CacheFile, "cached-data"); + CloudAssetCache sut = MakeSut(fileSystem); + + // Act + Task loadTask = sut.LoadAsync(AssetName, CancellationToken.None); + loadTask.Should().BeCompleteWithin(Timeout); + Stream? result = loadTask.Result; + + // Assert + result.Should().NotBeNull(); + using StreamReader reader = new(result!); + reader.ReadToEnd().Should().Be("cached-data"); + } + + [TestMethod] + public void CloudAssetCache_LoadAsync_ReturnsNullOnCacheMiss() + { + // Arrange + MockFileSystem fileSystem = new(); + CloudAssetCache sut = MakeSut(fileSystem); + + // Act + Task loadTask = sut.LoadAsync(AssetName, CancellationToken.None); + loadTask.Should().BeCompleteWithin(Timeout); + Stream? result = loadTask.Result; + + // Assert + result.Should().BeNull(); + } + + [TestMethod] + public void CloudAssetCache_SaveThenLoad_RoundTrips() + { + // Arrange + MockFileSystem fileSystem = new(); + fileSystem.AddDirectory(CacheDir); + byte[] original = Encoding.UTF8.GetBytes("{\"key\":\"value\"}"); + CloudAssetCache sut = MakeSut(fileSystem); + + // Act + sut.SaveAsync(AssetName, new MemoryStream(original), CancellationToken.None) + .Should().BeCompleteWithin(Timeout); + Task loadTask = sut.LoadAsync(AssetName, CancellationToken.None); + loadTask.Should().BeCompleteWithin(Timeout); + Stream? loaded = loadTask.Result; + + // Assert + loaded.Should().NotBeNull(); + using MemoryStream ms = new(); + loaded!.CopyTo(ms); + ms.ToArray().Should().Equal(original); + } +} diff --git a/test/AdaptiveRemote.App.Tests/Services/CloudAssets/CloudAssetOrchestratorTests.cs b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/CloudAssetOrchestratorTests.cs new file mode 100644 index 00000000..01eade93 --- /dev/null +++ b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/CloudAssetOrchestratorTests.cs @@ -0,0 +1,628 @@ +using AdaptiveRemote.Models.CloudAssets; +using AdaptiveRemote.Services.Lifecycle; +using FluentAssertions; +using Moq; + +namespace AdaptiveRemote.Services.CloudAssets; + +[TestClass] +public class CloudAssetOrchestratorTests +{ + private const string AssetName = "test-asset"; + private const string ResourcePath = "/test"; + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + private readonly Mock MockAsset = new(); + private readonly Mock MockDownloader = new(); + private readonly Mock MockStore = new(); + private readonly Mock MockCache = new(); + private readonly Mock MockSignal = new(); + private readonly Mock MockIdleDetector = new(); + private readonly Mock MockChangeNotifier = new(); + private readonly MockLogger MockLogger = new(); + private readonly Mock MockActivity = new(); + + public TestContext TestContext { get; set; } = null!; + + [TestInitialize] + public void SetupMocks() + { + MockAsset.SetupGet(a => a.Name).Returns(AssetName); + MockAsset.SetupGet(a => a.ResourcePath).Returns(ResourcePath); + MockActivity.SetupSet(a => a.Description = It.IsAny()); + + // Default: idle, no changes pending + MockIdleDetector.Setup(d => d.WaitForIdleAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + // Default: block forever on change notifier (prevents Phase 3 interference) + MockChangeNotifier.Setup(n => n.WaitForChangeAsync(It.IsAny())) + .Returns(ct => Task.Delay(Timeout.Multiply(10), ct).ContinueWith(_ => MockAsset.Object, ct, TaskContinuationOptions.None, TaskScheduler.Default)); + + // Default: cache miss + MockCache.Setup(c => c.LoadAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Stream?)null); + } + + [TestCleanup] + public void LogMockLoggerMessages() + { + // Log all captured messages for easier debugging of test failures; in a real test suite we might want to be more selective about this + foreach (string log in MockLogger.Messages) + { + TestContext.WriteLine(log.ToString()); + } + } + + private CloudAssetOrchestrator MakeSut(IEnumerable? assets = null) + => new(assets ?? [MockAsset.Object], + MockDownloader.Object, + MockStore.Object, + MockCache.Object, + MockSignal.Object, + MockIdleDetector.Object, + MockChangeNotifier.Object, + MockLogger); + + // ────────────────────────────────────────────────────────────── + // Phase 1 — cache miss (server download path) + // ────────────────────────────────────────────────────────────── + + [TestMethod] + public void CloudAssetOrchestrator_ExecuteAsync_CacheMiss_DownloadsAndStoresAsset() + { + // Arrange + object parsedValue = new(); + MockDownloader.Setup(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ReturnsAsync(new MemoryStream()); + MockAsset.Setup(a => a.DeserializeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(parsedValue); + CloudAssetOrchestrator sut = MakeSut(); + + // Act + _ = sut.StartAsync(CancellationToken.None); + Task waitTask = sut.WaitAsync(MockActivity.Object, CancellationToken.None); + + // Assert + waitTask.Should().BeCompleteWithin(Timeout); + waitTask.Should().BeSuccessful(); + MockStore.Verify(s => s.Set(AssetName, parsedValue), Times.Once); + MockCache.Verify(c => c.SaveAsync(AssetName, It.IsAny(), It.IsAny()), Times.Once); + MockLogger.VerifyMessages( + log => + { + log.CloudAssetOrchestrator_NotFoundInCache(AssetName); + log.CloudAssetOrchestrator_Downloading(AssetName); + log.CloudAssetOrchestrator_Downloaded(AssetName); + }); + } + + [TestMethod] + public void CloudAssetOrchestrator_ExecuteAsync_CacheMiss_FaultsWhenDownloadReturnsNull() + { + // Arrange + MockDownloader.Setup(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ReturnsAsync((Stream?)null); + CloudAssetOrchestrator sut = MakeSut(); + + // Act + _ = sut.StartAsync(CancellationToken.None); + Task waitTask = sut.WaitAsync(MockActivity.Object, CancellationToken.None); + + // Assert + InvalidOperationException expectedException = new($"Failed to download asset '{AssetName}'."); + waitTask.Should().BeFaultedWith(expectedException, within: Timeout); + MockLogger.VerifyMessages(log => + { + log.CloudAssetOrchestrator_NotFoundInCache(AssetName); + log.CloudAssetOrchestrator_Downloading(AssetName); + log.CloudAssetOrchestrator_BackgroundFetchFailed(AssetName, null); + log.CloudAssetOrchestrator_Failed(expectedException); + }); + } + + [TestMethod] + public void CloudAssetOrchestrator_ExecuteAsync_CacheMiss_FaultsWhenDownloaderThrows() + { + // Arrange + InvalidOperationException downloadException = new($"Failed to download asset '{AssetName}'."); + MockDownloader.Setup(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ThrowsAsync(downloadException); + CloudAssetOrchestrator sut = MakeSut(); + + // Act + _ = sut.StartAsync(CancellationToken.None); + Task waitTask = sut.WaitAsync(MockActivity.Object, CancellationToken.None); + + // Assert + waitTask.Should().BeFaultedWith(downloadException, within: Timeout); + MockLogger.VerifyMessages(log => + { + log.CloudAssetOrchestrator_NotFoundInCache(AssetName); + log.CloudAssetOrchestrator_Downloading(AssetName); + log.CloudAssetOrchestrator_BackgroundFetchFailed(AssetName, downloadException); + log.CloudAssetOrchestrator_Failed(downloadException); + }); + } + + [TestMethod] + public void CloudAssetOrchestrator_ExecuteAsync_CacheMiss_FaultsWhenDeserializationFails() + { + // Arrange + InvalidOperationException parseException = new("Deserialization failed"); + MockDownloader.Setup(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ReturnsAsync(new MemoryStream()); + MockAsset.Setup(a => a.DeserializeAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(parseException); + CloudAssetOrchestrator sut = MakeSut(); + + // Act + _ = sut.StartAsync(CancellationToken.None); + Task waitTask = sut.WaitAsync(MockActivity.Object, CancellationToken.None); + + // Assert + waitTask.Should().BeFaultedWith(parseException, within: Timeout); + MockLogger.VerifyMessages(log => + { + log.CloudAssetOrchestrator_NotFoundInCache(AssetName); + log.CloudAssetOrchestrator_Downloading(AssetName); + log.CloudAssetOrchestrator_Downloaded(AssetName); + log.CloudAssetOrchestrator_Failed(parseException); + }); + } + + [TestMethod] + public void CloudAssetOrchestrator_ExecuteAsync_CacheMiss_FaultsWhenCacheSaveThrows() + { + // Arrange + IOException saveException = new("disk full"); + MockDownloader.Setup(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ReturnsAsync(new MemoryStream()); + MockAsset.Setup(a => a.DeserializeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new object()); + MockCache.Setup(c => c.SaveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(saveException); + CloudAssetOrchestrator sut = MakeSut(); + + // Act + _ = sut.StartAsync(CancellationToken.None); + Task waitTask = sut.WaitAsync(MockActivity.Object, CancellationToken.None); + + // Assert + waitTask.Should().BeFaultedWith(saveException, within: Timeout); + } + + // ────────────────────────────────────────────────────────────── + // Phase 1 — cache hit + // ────────────────────────────────────────────────────────────── + + [TestMethod] + public void CloudAssetOrchestrator_ExecuteAsync_CacheHit_LoadsFromCacheAndSignalsReady() + { + // Arrange + object parsedValue = new(); + MockCache.Setup(c => c.LoadAsync(AssetName, It.IsAny())) + .ReturnsAsync(new MemoryStream()); + MockAsset.Setup(a => a.DeserializeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(parsedValue); + + // Phase 2 server check returns same bytes — no recycle expected + MockDownloader.Setup(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ReturnsAsync(new MemoryStream()); + + CloudAssetOrchestrator sut = MakeSut(); + + // Act + _ = sut.StartAsync(CancellationToken.None); + Task waitTask = sut.WaitAsync(MockActivity.Object, CancellationToken.None); + + // Assert — Phase 1 completed; Phase 2 runs concurrently after WaitAsync returns + waitTask.Should().BeCompleteWithin(Timeout); + waitTask.Should().BeSuccessful(); + MockStore.Verify(s => s.Set(AssetName, parsedValue), Times.AtLeastOnce); + // Downloader not called during Phase 1 (cache hit path); only Phase 2 may call it + MockLogger.CountMessages(log => { log.CloudAssetOrchestrator_LoadedFromCache(AssetName); }) + .Should().BeGreaterThanOrEqualTo(1); + } + + [TestMethod] + public void CloudAssetOrchestrator_ExecuteAsync_CacheHit_FaultsWhenDeserializationFails() + { + // Arrange + InvalidOperationException parseException = new("parse error"); + MockCache.Setup(c => c.LoadAsync(AssetName, It.IsAny())) + .ReturnsAsync(new MemoryStream()); + MockAsset.Setup(a => a.DeserializeAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(parseException); + CloudAssetOrchestrator sut = MakeSut(); + + // Act + _ = sut.StartAsync(CancellationToken.None); + Task waitTask = sut.WaitAsync(MockActivity.Object, CancellationToken.None); + + // Assert + waitTask.Should().BeFaultedWith(parseException, within: Timeout); + MockLogger.VerifyMessages(log => { log.CloudAssetOrchestrator_Failed(parseException); }); + } + + [TestMethod] + public void CloudAssetOrchestrator_ExecuteAsync_CacheHit_FaultsWhenCacheLoadThrows() + { + // Arrange + IOException loadException = new("disk error"); + MockCache.Setup(c => c.LoadAsync(AssetName, It.IsAny())) + .ThrowsAsync(loadException); + CloudAssetOrchestrator sut = MakeSut(); + + // Act + _ = sut.StartAsync(CancellationToken.None); + Task waitTask = sut.WaitAsync(MockActivity.Object, CancellationToken.None); + + // Assert + waitTask.Should().BeFaultedWith(loadException, within: Timeout); + MockLogger.VerifyMessages(log => { log.CloudAssetOrchestrator_Failed(loadException); }); + } + + // ────────────────────────────────────────────────────────────── + // Phase 1 — multiple assets + // ────────────────────────────────────────────────────────────── + + [TestMethod] + public void CloudAssetOrchestrator_ExecuteAsync_MultipleAssets_AllStoredBeforeReady() + { + // Arrange + Mock asset1 = new(); + Mock asset2 = new(); + asset1.SetupGet(a => a.Name).Returns("asset1"); + asset1.SetupGet(a => a.ResourcePath).Returns("/path1"); + asset2.SetupGet(a => a.Name).Returns("asset2"); + asset2.SetupGet(a => a.ResourcePath).Returns("/path2"); + + object val1 = new(), val2 = new(); + MockDownloader.Setup(d => d.GetActiveAsync("/path1", It.IsAny())) + .ReturnsAsync(new MemoryStream()); + MockDownloader.Setup(d => d.GetActiveAsync("/path2", It.IsAny())) + .ReturnsAsync(new MemoryStream()); + asset1.Setup(a => a.DeserializeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(val1); + asset2.Setup(a => a.DeserializeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(val2); + + CloudAssetOrchestrator sut = MakeSut([asset1.Object, asset2.Object]); + + // Act + _ = sut.StartAsync(CancellationToken.None); + Task waitTask = sut.WaitAsync(MockActivity.Object, CancellationToken.None); + + // Assert + waitTask.Should().BeCompleteWithin(Timeout); + MockStore.Verify(s => s.Set("asset1", val1), Times.Once); + MockStore.Verify(s => s.Set("asset2", val2), Times.Once); + } + + // ────────────────────────────────────────────────────────────── + // Phase 2 — background server check + // ────────────────────────────────────────────────────────────── + + [TestMethod] + public void CloudAssetOrchestrator_Phase2_UpdatesStoreWhenServerReturnsDifferentContent() + { + // Arrange + byte[] cachedBytes = "cached"u8.ToArray(); + byte[] serverBytes = "updated"u8.ToArray(); + object initialValue = new(); + object updatedValue = new(); + + MockCache.Setup(c => c.LoadAsync(AssetName, It.IsAny())) + .ReturnsAsync(() => new MemoryStream(cachedBytes)); + + int deserializeCallCount = 0; + MockAsset.Setup(a => a.DeserializeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(() => deserializeCallCount++ == 0 ? initialValue : updatedValue); + + MockDownloader.Setup(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ReturnsAsync(() => new MemoryStream(serverBytes)); + + CloudAssetOrchestrator sut = MakeSut(); + + // Act + _ = sut.StartAsync(CancellationToken.None); + Task waitTask = sut.WaitAsync(MockActivity.Object, CancellationToken.None); + waitTask.Should().BeCompleteWithin(Timeout); + + // Wait for Phase 2 completion log rather than a fixed delay + MockLogger.WaitForMessageAsync(log => { log.CloudAssetOrchestrator_AssetUpdated(AssetName); }).Should().BeCompleteWithin(Timeout); + + // Assert + MockStore.Verify(s => s.Set(AssetName, updatedValue), Times.Once); + MockSignal.Verify(s => s.RequestRecycle(), Times.Once); + MockLogger.VerifyMessages(log => + { + log.CloudAssetOrchestrator_LoadedFromCache(AssetName); + log.CloudAssetOrchestrator_Downloading(AssetName); + log.CloudAssetOrchestrator_Downloaded(AssetName); + log.CloudAssetOrchestrator_AssetUpdated(AssetName); + }); + } + + [TestMethod] + public void CloudAssetOrchestrator_Phase2_DoesNotRecycleWhenServerReturnsIdenticalContent() + { + // Arrange + byte[] sharedBytes = "same-content"u8.ToArray(); + object parsedValue = new(); + + MockCache.Setup(c => c.LoadAsync(AssetName, It.IsAny())) + .ReturnsAsync(() => new MemoryStream(sharedBytes)); + MockAsset.Setup(a => a.DeserializeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(parsedValue); + MockDownloader.Setup(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ReturnsAsync(() => new MemoryStream(sharedBytes)); + + CloudAssetOrchestrator sut = MakeSut(); + + // Act + _ = sut.StartAsync(CancellationToken.None); + Task waitTask = sut.WaitAsync(MockActivity.Object, CancellationToken.None); + waitTask.Should().BeCompleteWithin(Timeout); + MockLogger.WaitForMessageAsync(log => { log.CloudAssetOrchestrator_AssetUpToDate(AssetName); }).Should().BeCompleteWithin(Timeout); + + // Assert + MockSignal.Verify(s => s.RequestRecycle(), Times.Never); + MockLogger.VerifyMessages(log => + { + log.CloudAssetOrchestrator_LoadedFromCache(AssetName); + log.CloudAssetOrchestrator_Downloading(AssetName); + log.CloudAssetOrchestrator_Downloaded(AssetName); + log.CloudAssetOrchestrator_AssetUpToDate(AssetName); + }); + } + + [TestMethod] + public void CloudAssetOrchestrator_Phase2_LogsWarningAndContinuesWhenServerReturnsNull() + { + // Arrange + MockCache.Setup(c => c.LoadAsync(AssetName, It.IsAny())) + .ReturnsAsync(() => new MemoryStream("cached"u8.ToArray())); + MockAsset.Setup(a => a.DeserializeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new object()); + MockDownloader.Setup(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ReturnsAsync((Stream?)null); + + CloudAssetOrchestrator sut = MakeSut(); + + // Act + _ = sut.StartAsync(CancellationToken.None); + Task waitTask = sut.WaitAsync(MockActivity.Object, CancellationToken.None); + waitTask.Should().BeCompleteWithin(Timeout); + MockLogger.WaitForMessageAsync(log => { log.CloudAssetOrchestrator_BackgroundFetchFailed(AssetName, null); }).Should().BeCompleteWithin(Timeout); + + // Assert — Phase 2 warning logged, no recycle, no crash + MockSignal.Verify(s => s.RequestRecycle(), Times.Never); + MockLogger.VerifyMessages(log => + { + log.CloudAssetOrchestrator_LoadedFromCache(AssetName); + log.CloudAssetOrchestrator_Downloading(AssetName); + log.CloudAssetOrchestrator_BackgroundFetchFailed(AssetName, null); + }); + } + + [TestMethod] + public void CloudAssetOrchestrator_Phase2_LogsWarningAndContinuesWhenServerThrows() + { + // Arrange + HttpRequestException networkException = new("network error"); + MockCache.Setup(c => c.LoadAsync(AssetName, It.IsAny())) + .ReturnsAsync(() => new MemoryStream("cached"u8.ToArray())); + MockAsset.Setup(a => a.DeserializeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new object()); + MockDownloader.Setup(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ThrowsAsync(networkException); + + CloudAssetOrchestrator sut = MakeSut(); + + // Act + _ = sut.StartAsync(CancellationToken.None); + Task waitTask = sut.WaitAsync(MockActivity.Object, CancellationToken.None); + waitTask.Should().BeCompleteWithin(Timeout); + MockLogger.WaitForMessageAsync(log => { log.CloudAssetOrchestrator_BackgroundFetchFailed(AssetName, networkException); }).Should().BeCompleteWithin(Timeout); + + // Assert — Phase 2 warning, no crash + MockSignal.Verify(s => s.RequestRecycle(), Times.Never); + MockLogger.VerifyMessages(log => + { + log.CloudAssetOrchestrator_LoadedFromCache(AssetName); + log.CloudAssetOrchestrator_Downloading(AssetName); + log.CloudAssetOrchestrator_BackgroundFetchFailed(AssetName, networkException); + }); + } + + // ────────────────────────────────────────────────────────────── + // Phase 3 — file-change loop + // ────────────────────────────────────────────────────────────── + + [TestMethod] + public void CloudAssetOrchestrator_Phase3_OnFileChange_DownloadsAndUpdatesStore() + { + // Arrange + TaskCompletionSource notificationSource = new(); + MockChangeNotifier.Setup(n => n.WaitForChangeAsync(It.IsAny())) + .Returns(async ct => + { + await notificationSource.Task.WaitAsync(ct); + // Block subsequent calls so only one Phase 3 cycle runs + MockChangeNotifier.Setup(n => n.WaitForChangeAsync(It.IsAny())) + .Returns(ct2 => Task.Delay(Timeout.Multiply(10), ct2).ContinueWith(_ => MockAsset.Object, ct2, TaskContinuationOptions.None, TaskScheduler.Default)); + return MockAsset.Object; + }); + + object updatedValue = new(); + // Phase 1 (cache miss): downloader returns initial bytes + MockDownloader.Setup(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ReturnsAsync(() => new MemoryStream("server-data"u8.ToArray())); + MockAsset.Setup(a => a.DeserializeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(updatedValue); + + CloudAssetOrchestrator sut = MakeSut(); + + // Act — start, let Phase 1 (cache miss + download) complete + _ = sut.StartAsync(CancellationToken.None); + sut.WaitAsync(MockActivity.Object, CancellationToken.None).Should().BeCompleteWithin(Timeout); + // Clear Phase 1 logs and store invocations before Phase 3 triggers + MockLogger.ClearMessages(); + MockStore.Invocations.Clear(); + MockSignal.Invocations.Clear(); + + // Signal a file change; wait until RequestRecycle is called (last observable action in Phase 3) + notificationSource.SetResult(); + SpinWait.SpinUntil(() => MockSignal.Invocations.Any(), Timeout); + + // Assert + MockStore.Verify(s => s.Set(AssetName, updatedValue), Times.Once); + MockSignal.Verify(s => s.RequestRecycle(), Times.Once); + MockLogger.VerifyMessages(log => + { + log.CloudAssetOrchestrator_FileChangeDetected(AssetName); + log.CloudAssetOrchestrator_Downloading(AssetName); + log.CloudAssetOrchestrator_Downloaded(AssetName); + log.CloudAssetOrchestrator_AssetUpdated(AssetName); + }); + } + + [TestMethod] + public void CloudAssetOrchestrator_Phase3_OnFileChange_LogsWarningWhenServerReturnsNull() + { + // Arrange + TaskCompletionSource notificationSource = new(); + MockChangeNotifier.Setup(n => n.WaitForChangeAsync(It.IsAny())) + .Returns(async ct => + { + await notificationSource.Task.WaitAsync(ct); + MockChangeNotifier.Setup(n => n.WaitForChangeAsync(It.IsAny())) + .Returns(ct2 => Task.Delay(Timeout.Multiply(10), ct2).ContinueWith(_ => MockAsset.Object, ct2, TaskContinuationOptions.None, TaskScheduler.Default)); + return MockAsset.Object; + }); + + // Phase 1 (cache miss): downloader returns content so startup succeeds + // Phase 3: switch to null so the warning path is triggered + MockDownloader.SetupSequence(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ReturnsAsync(new MemoryStream("initial"u8.ToArray())) // Phase 1 + .ReturnsAsync((Stream?)null); // Phase 3 + + MockAsset.Setup(a => a.DeserializeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new object()); + + CloudAssetOrchestrator sut = MakeSut(); + + // Act — Phase 1 succeeds + _ = sut.StartAsync(CancellationToken.None); + sut.WaitAsync(MockActivity.Object, CancellationToken.None).Should().BeCompleteWithin(Timeout); + MockLogger.ClearMessages(); + + // Trigger Phase 3 and wait for warning log + notificationSource.SetResult(); + MockLogger.WaitForMessageAsync(log => { log.CloudAssetOrchestrator_BackgroundFetchFailed(AssetName, null); }).Should().BeCompleteWithin(Timeout); + + // Assert — warning logged, no crash, no recycle from Phase 3 + MockLogger.VerifyMessages(log => + { + log.CloudAssetOrchestrator_FileChangeDetected(AssetName); + log.CloudAssetOrchestrator_Downloading(AssetName); + log.CloudAssetOrchestrator_BackgroundFetchFailed(AssetName, null); + }); + } + + [TestMethod] + public void CloudAssetOrchestrator_Phase3_StopsCleanlyOnCancellation() + { + // Arrange — change notifier blocks until cancelled + CancellationTokenSource cts = new(); + MockChangeNotifier.Setup(n => n.WaitForChangeAsync(It.IsAny())) + .Returns(ct => Task.Delay(Timeout.Multiply(10), ct).ContinueWith(_ => MockAsset.Object, ct, TaskContinuationOptions.None, TaskScheduler.Default)); + + MockDownloader.Setup(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ReturnsAsync((Stream?)null); + + CloudAssetOrchestrator sut = MakeSut(); + + // Act — start then cancel + _ = sut.StartAsync(CancellationToken.None); + sut.WaitAsync(MockActivity.Object, CancellationToken.None).Should().BeCompleteWithin(Timeout); + + Task stopTask = sut.StopAsync(CancellationToken.None); + + // Assert + stopTask.Should().BeCompleteWithin(Timeout); + stopTask.Should().BeSuccessful(); + } + + // ────────────────────────────────────────────────────────────── + // Idle-defer recycle + // ────────────────────────────────────────────────────────────── + + [TestMethod] + public void CloudAssetOrchestrator_IdleDeferRecycle_RequestsRecycleImmediatelyWhenAlreadyIdle() + { + // Arrange — cache hit + different server bytes = update triggered + byte[] cachedBytes = "v1"u8.ToArray(); + byte[] serverBytes = "v2"u8.ToArray(); + MockCache.Setup(c => c.LoadAsync(AssetName, It.IsAny())) + .ReturnsAsync(() => new MemoryStream(cachedBytes)); + MockAsset.Setup(a => a.DeserializeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new object()); + MockDownloader.Setup(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ReturnsAsync(() => new MemoryStream(serverBytes)); + MockIdleDetector.Setup(d => d.WaitForIdleAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + CloudAssetOrchestrator sut = MakeSut(); + + // Act + _ = sut.StartAsync(CancellationToken.None); + sut.WaitAsync(MockActivity.Object, CancellationToken.None).Should().BeCompleteWithin(Timeout); + MockLogger.WaitForMessageAsync(log => { log.CloudAssetOrchestrator_AssetUpdated(AssetName); }).Should().BeCompleteWithin(Timeout); + + // Assert + MockSignal.Verify(s => s.RequestRecycle(), Times.Once); + // WaitForIdleAsync subscribes then immediately unsubscribes when already idle; + // the important assertion is that RequestRecycle was called synchronously. + MockIdleDetector.Verify(d => d.WaitForIdleAsync(It.IsAny()), Times.Once); + } + + [TestMethod] + public void CloudAssetOrchestrator_IdleDeferRecycle_SubscribesToBecameIdleWhenNotIdle() + { + // Arrange + byte[] cachedBytes = "v1"u8.ToArray(); + byte[] serverBytes = "v2"u8.ToArray(); + MockCache.Setup(c => c.LoadAsync(AssetName, It.IsAny())) + .ReturnsAsync(() => new MemoryStream(cachedBytes)); + MockAsset.Setup(a => a.DeserializeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new object()); + MockDownloader.Setup(d => d.GetActiveAsync(ResourcePath, It.IsAny())) + .ReturnsAsync(() => new MemoryStream(serverBytes)); + + TaskCompletionSource tcs = new(); + MockIdleDetector.Setup(d => d.WaitForIdleAsync(It.IsAny())) + .Returns(tcs.Task); + + CloudAssetOrchestrator sut = MakeSut(); + + // Act — wait for Phase 2 to register the handler + _ = sut.StartAsync(CancellationToken.None); + sut.WaitAsync(MockActivity.Object, CancellationToken.None).Should().BeCompleteWithin(Timeout); + MockLogger.WaitForMessageAsync(log => { log.CloudAssetOrchestrator_AssetUpdated(AssetName); }).Should().BeCompleteWithin(Timeout); + + // Not yet recycled + MockSignal.Verify(s => s.RequestRecycle(), Times.Never); + + // Simulate becoming idle + tcs.SetResult(); + + // Wait for the ContinueWith continuation to fire RequestRecycle + SpinWait.SpinUntil(() => MockSignal.Invocations.Any(), Timeout); + + // Now recycle should have been requested + MockSignal.Verify(s => s.RequestRecycle(), Times.Once); + } +} diff --git a/test/AdaptiveRemote.App.Tests/Services/CloudAssets/CloudAssetStoreTests.cs b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/CloudAssetStoreTests.cs new file mode 100644 index 00000000..55f170c3 --- /dev/null +++ b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/CloudAssetStoreTests.cs @@ -0,0 +1,165 @@ +using FluentAssertions; + +namespace AdaptiveRemote.Services.CloudAssets; + +[TestClass] +public class CloudAssetStoreTests +{ + [TestMethod] + public void CloudAssetStore_Get_ThrowsOnMissingKey() + { + // Arrange + CloudAssetStore sut = new(); + + // Act + Action act = () => sut.Get("nonexistent"); + + // Assert + act.Should().Throw() + .WithMessage("Asset 'nonexistent' not found in store.*"); + } + + [TestMethod] + public void CloudAssetStore_Get_ThrowsOnWrongType() + { + // Arrange + CloudAssetStore sut = new(); + sut.Set("test", 42); + + // Act + Action act = () => sut.Get("test"); + + // Assert + act.Should().Throw() + .WithMessage("Asset 'test' is of type 'Int32', but 'String' was requested."); + } + + [TestMethod] + public void CloudAssetStore_SetAndGet_RoundTripsCorrectly() + { + // Arrange + CloudAssetStore sut = new(); + string expectedValue = "test value"; + + // Act + sut.Set("key", expectedValue); + string actualValue = sut.Get("key"); + + // Assert + actualValue.Should().Be(expectedValue); + } + + [TestMethod] + public void CloudAssetStore_Set_OverwritesExistingValue() + { + // Arrange + CloudAssetStore sut = new(); + sut.Set("key", "initial"); + + // Act + sut.Set("key", "updated"); + string actualValue = sut.Get("key"); + + // Assert + actualValue.Should().Be("updated"); + } + + [TestMethod] + public void CloudAssetStore_Get_SupportsMultipleAssets() + { + // Arrange + CloudAssetStore sut = new(); + sut.Set("asset1", "value1"); + sut.Set("asset2", 123); + sut.Set("asset3", true); + + // Act & Assert + sut.Get("asset1").Should().Be("value1"); + sut.Get("asset2").Should().Be(123); + sut.Get("asset3").Should().BeTrue(); + } + + [TestMethod] + public void CloudAssetStore_ThreadSafety_HandlesSimultaneousReadsAndWrites() + { + // Arrange + CloudAssetStore sut = new(); + const int threadCount = 10; + const int operationsPerThread = 100; + List exceptions = []; + object exceptionsLock = new(); + + // Pre-populate with some assets + for (int i = 0; i < threadCount; i++) + { + sut.Set($"shared-asset-{i}", $"initial-{i}"); + } + + // Act + Thread[] threads = new Thread[threadCount]; + for (int threadIndex = 0; threadIndex < threadCount; threadIndex++) + { + int currentThreadIndex = threadIndex; + threads[threadIndex] = new Thread(() => + { + try + { + for (int operation = 0; operation < operationsPerThread; operation++) + { + // Mix of different operations + if (operation % 3 == 0) + { + // Write to shared assets + sut.Set($"shared-asset-{operation % threadCount}", $"thread-{currentThreadIndex}-op-{operation}"); + } + else if (operation % 3 == 1) + { + // Write to thread-specific assets + sut.Set($"thread-{currentThreadIndex}-asset-{operation}", $"value-{operation}"); + } + else + { + // Read from shared assets + try + { + _ = sut.Get($"shared-asset-{operation % threadCount}"); + } + catch (InvalidOperationException) + { + // Expected if another thread is modifying or type mismatch + } + } + } + } + catch (Exception ex) + { + lock (exceptionsLock) + { + exceptions.Add(ex); + } + } + }); + threads[threadIndex].Start(); + } + + // Wait for all threads to complete + foreach (Thread thread in threads) + { + thread.Join(); + } + + // Assert + exceptions.Should().BeEmpty("thread-safe operations should not throw exceptions"); + + // Verify that we can still read all thread-specific assets + for (int threadIndex = 0; threadIndex < threadCount; threadIndex++) + { + for (int operation = 1; operation < operationsPerThread; operation += 3) + { + string key = $"thread-{threadIndex}-asset-{operation}"; + string expectedValue = $"value-{operation}"; + sut.Get(key).Should().Be(expectedValue, $"asset {key} should have correct value"); + } + } + } +} diff --git a/test/AdaptiveRemote.App.Tests/Services/CloudAssets/FileCloudAssetDownloaderTests.cs b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/FileCloudAssetDownloaderTests.cs new file mode 100644 index 00000000..0934dfeb --- /dev/null +++ b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/FileCloudAssetDownloaderTests.cs @@ -0,0 +1,59 @@ +using FluentAssertions; + +namespace AdaptiveRemote.Services.CloudAssets; + +[TestClass] +public class FileCloudAssetDownloaderTests +{ + private const string FilePath = "dev/layout.json"; + private const string ResourcePath = "/layouts/compiled"; + + private static FileSystemCloudAssetDownloader MakeSut( + string stubFilePath, MockFileSystem fileSystem) => + new(new MockOptions(new CloudSettings { StubFilePath = stubFilePath }), + fileSystem.Object); + + [TestMethod] + public async Task FileCloudAssetDownloader_GetActiveAsync_ReturnsStreamForConfiguredPathAsync() + { + // Arrange + MockFileSystem fileSystem = new(); + fileSystem.AddFile(FilePath, "{}"); + FileSystemCloudAssetDownloader sut = MakeSut(FilePath, fileSystem); + + // Act + Stream? result = await sut.GetActiveAsync(ResourcePath, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + } + + [TestMethod] + public async Task FileCloudAssetDownloader_GetActiveAsync_ReturnsNullWhenFileAbsentAsync() + { + // Arrange + MockFileSystem fileSystem = new(); + FileSystemCloudAssetDownloader sut = MakeSut("nonexistent/layout.json", fileSystem); + + // Act + Stream? result = await sut.GetActiveAsync(ResourcePath, CancellationToken.None); + + // Assert + result.Should().BeNull(); + } + + [TestMethod] + public async Task FileCloudAssetDownloader_GetByIdAsync_ReturnsNullAsync() + { + // Arrange + MockFileSystem fileSystem = new(); + fileSystem.AddFile(FilePath, "{}"); + FileSystemCloudAssetDownloader sut = MakeSut(FilePath, fileSystem); + + // Act + Stream? result = await sut.GetByIdAsync(ResourcePath, Guid.NewGuid(), CancellationToken.None); + + // Assert + result.Should().BeNull(); + } +} diff --git a/test/AdaptiveRemote.App.Tests/Services/CloudAssets/FileSystemCloudAssetWatchServiceTests.cs b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/FileSystemCloudAssetWatchServiceTests.cs new file mode 100644 index 00000000..98883822 --- /dev/null +++ b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/FileSystemCloudAssetWatchServiceTests.cs @@ -0,0 +1,123 @@ +using AdaptiveRemote.Models.CloudAssets; +using FluentAssertions; +using Moq; + +namespace AdaptiveRemote.Services.CloudAssets; + +[TestClass] +public class FileSystemCloudAssetWatchServiceTests +{ + private static readonly TimeSpan WatchTimeout = TimeSpan.FromSeconds(5); + private readonly Mock MockAsset = new(); + + private FileSystemCloudAssetWatchService MakeSut(string stubFilePath) + => new([MockAsset.Object], new MockOptions(new CloudSettings { StubFilePath = stubFilePath })); + + [TestMethod] + public async Task FileSystemCloudAssetWatchService_WaitForChangeAsync_UnblocksOnFileWriteAsync() + { + // Arrange + string tempFile = Path.GetTempFileName(); + try + { + FileSystemCloudAssetWatchService sut = MakeSut(tempFile); + await sut.StartAsync(CancellationToken.None); + + Task waitTask = sut.WaitForChangeAsync(CancellationToken.None); + + // Act — write to the file after a short delay + await Task.Delay(50); + await File.WriteAllTextAsync(tempFile, "updated"); + + // Assert + waitTask.Should().BeCompleteWithin(WatchTimeout); + } + finally + { + File.Delete(tempFile); + } + } + + [TestMethod] + public async Task FileSystemCloudAssetWatchService_WaitForChangeAsync_UnblocksOnFileCreationAsync() + { + // Arrange + string tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempDir); + string stubPath = Path.Combine(tempDir, "stub.json"); + + try + { + FileSystemCloudAssetWatchService sut = MakeSut(stubPath); + await sut.StartAsync(CancellationToken.None); + + Task waitTask = sut.WaitForChangeAsync(CancellationToken.None); + + // Act — create the file + await Task.Delay(50); + await File.WriteAllTextAsync(stubPath, "{}"); + + // Assert + waitTask.Should().BeCompleteWithin(WatchTimeout); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [TestMethod] + public async Task FileSystemCloudAssetWatchService_WaitForChangeAsync_RapidEventsCollapseToOneNotificationAsync() + { + // Arrange + string tempFile = Path.GetTempFileName(); + try + { + FileSystemCloudAssetWatchService sut = MakeSut(tempFile); + await sut.StartAsync(CancellationToken.None); + + // Act — trigger two rapid writes + await Task.Delay(50); + await File.WriteAllTextAsync(tempFile, "write1"); + await Task.Delay(10); + await File.WriteAllTextAsync(tempFile, "write2"); + + // First WaitForChangeAsync should complete + Task firstWait = sut.WaitForChangeAsync(CancellationToken.None); + firstWait.Should().BeCompleteWithin(WatchTimeout); + + // Second call should block because rapid events collapsed to one notification + using CancellationTokenSource cts = new(TimeSpan.FromMilliseconds(200)); + Task secondWait = sut.WaitForChangeAsync(cts.Token); + await secondWait.Awaiting(t => t).Should().ThrowAsync(); + } + finally + { + File.Delete(tempFile); + } + } + + [TestMethod] + public async Task FileSystemCloudAssetWatchService_WaitForChangeAsync_RespectsCancellationAsync() + { + // Arrange + string tempFile = Path.GetTempFileName(); + try + { + FileSystemCloudAssetWatchService sut = MakeSut(tempFile); + await sut.StartAsync(CancellationToken.None); + + using CancellationTokenSource cts = new(TimeSpan.FromMilliseconds(100)); + + // Act + Task waitTask = sut.WaitForChangeAsync(cts.Token); + + // Assert + await waitTask.Awaiting(t => t).Should().ThrowAsync(); + } + finally + { + File.Delete(tempFile); + } + } +} diff --git a/test/AdaptiveRemote.App.Tests/Services/CloudAssets/IdleDetectorTests.cs b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/IdleDetectorTests.cs new file mode 100644 index 00000000..b300edb9 --- /dev/null +++ b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/IdleDetectorTests.cs @@ -0,0 +1,171 @@ +using System.Diagnostics; +using AdaptiveRemote.Services.IdleDetection; +using FluentAssertions; + +namespace AdaptiveRemote.Services.CloudAssets; + +[TestClass] +public class IdleDetectorTests +{ + private readonly MockOptions MockOptions = new(); + + [TestMethod] + public void WaitForIdleAsync_Waits_WhenNoScopedDetector() + { + // Arrange + FakeUserActivityDetector fake = new(DateTime.MinValue); + IdleDetector sut = new(); + IdleDetector.ScopedIdleDetector scoped = new([fake], sut, new FakeOptions(0)); + CancellationTokenSource cts = new(TimeSpan.FromSeconds(2)); + + // Act + Task resultTask = sut.WaitForIdleAsync(cts.Token); + + // Assert + resultTask.Should().NotBeComplete(); + } + + [TestMethod] + public void WaitForIdleAsync_Completes_WhenScopedDetectorInitialized() + { + // Arrange + FakeUserActivityDetector fake = new(DateTime.MinValue); + IdleDetector sut = new(); + CancellationTokenSource cts = new(TimeSpan.FromSeconds(2)); + Task resultTask = sut.WaitForIdleAsync(cts.Token); + + IdleDetector.ScopedIdleDetector scoped = new([fake], sut, new FakeOptions(0)); + + // Act + scoped.InitializeAsync(null!, default); + + // Assert + resultTask.Should().BeComplete(); + } + + [TestMethod] + public void WaitForIdleAsync_Waits_WhenScopedHasBeenCleanedUp() + { + // Arrange + FakeUserActivityDetector fake = new(DateTime.MinValue); + IdleDetector sut = new(); + CancellationTokenSource cts = new(TimeSpan.FromSeconds(2)); + + IdleDetector.ScopedIdleDetector scoped = new([fake], sut, new FakeOptions(0)); + scoped.InitializeAsync(null!, default); + + // Act + scoped.CleanUpAsync(null!, default); + Task resultTask = sut.WaitForIdleAsync(cts.Token); + + // Assert + resultTask.Should().NotBeComplete(because: "the scope is being recycled which should be considered non-idle"); + } + + [TestMethod] + public void WaitForIdleAsync_Completes_WhenNoActivity() + { + // Arrange + FakeUserActivityDetector fake = new(DateTime.MinValue); + IdleDetector sut = new(); + IdleDetector.ScopedIdleDetector scoped = new([fake], sut, new FakeOptions(0)); + scoped.InitializeAsync(null!, default); + CancellationTokenSource cts = new(TimeSpan.FromSeconds(2)); + + // Act + Task resultTask = sut.WaitForIdleAsync(cts.Token); + + // Assert + resultTask.Should().BeComplete(); + } + + [TestMethod] + public void WaitForIdleAsync_WaitsForCooldown() + { + // Arrange + DateTime now = DateTime.Now; + FakeUserActivityDetector fake = new(now); + int cooldown = 1; // seconds + IdleDetector sut = new(); + IdleDetector.ScopedIdleDetector scoped = new([fake], sut, new FakeOptions(cooldown)); + CancellationTokenSource cts = new(TimeSpan.FromSeconds(5)); + scoped.InitializeAsync(null!, default); + Stopwatch sw = System.Diagnostics.Stopwatch.StartNew(); + + // Act + Task waitTask = sut.WaitForIdleAsync(cts.Token); + + // Assert + waitTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(cooldown + 1)); + sw.Elapsed.TotalSeconds.Should().BeGreaterThanOrEqualTo(cooldown); + } + + [TestMethod] + public void WaitForIdleAsync_Cancels_IfTokenCancelled() + { + // Arrange + FakeUserActivityDetector fake = new(DateTime.Now); + IdleDetector sut = new(); + IdleDetector.ScopedIdleDetector scoped = new([fake], sut, new FakeOptions(10)); + scoped.InitializeAsync(null!, default); + CancellationTokenSource cts = new(); + Task task = sut.WaitForIdleAsync(cts.Token); + + // Act + cts.Cancel(); + + // Assert + task.Should().BeCanceledWithin(TimeSpan.FromSeconds(1)); + } + + [TestMethod] + public void WaitForIdleAsync_Throws_IfDetectorThrows() + { + // Arrange + ThrowingUserActivityDetector fake = new(); + IdleDetector sut = new(); + IdleDetector.ScopedIdleDetector scoped = new([fake], sut, new FakeOptions(1)); + scoped.InitializeAsync(null!, default); + CancellationTokenSource cts = new(TimeSpan.FromSeconds(2)); + + // Act + Task resultTask = sut.WaitForIdleAsync(cts.Token); + + // Assert + resultTask.Should().BeFaultedWith(new InvalidOperationException("Simulated error")); + } + + [TestMethod] + public void WaitForIdleAsync_Handles_EmptyDetectorList() + { + // Arrange + IdleDetector sut = new(); + IdleDetector.ScopedIdleDetector scoped = new(new List(), sut, new FakeOptions(0)); + scoped.InitializeAsync(null!, default); + CancellationTokenSource cts = new(TimeSpan.FromSeconds(2)); + + // Act + Task task = sut.WaitForIdleAsync(cts.Token); + + // Assert + task.Should().BeComplete(because: "there are no activity detectors, so it is always idle"); + } + + // Helpers + private class FakeUserActivityDetector : IUserActivityDetector + { + public DateTime LastActivityTime { get; set; } + public FakeUserActivityDetector(DateTime t) => LastActivityTime = t; + } + + private class ThrowingUserActivityDetector : IUserActivityDetector + { + public DateTime LastActivityTime => throw new InvalidOperationException("Simulated error"); + } + + private class FakeOptions : Microsoft.Extensions.Options.IOptions + { + public CloudSettings Value { get; } + public FakeOptions(int cooldownSeconds) => Value = new CloudSettings { IdleCooldownSeconds = cooldownSeconds }; + } +} diff --git a/test/AdaptiveRemote.App.Tests/Services/CloudAssets/JsonCloudAssetTests.cs b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/JsonCloudAssetTests.cs new file mode 100644 index 00000000..01cae398 --- /dev/null +++ b/test/AdaptiveRemote.App.Tests/Services/CloudAssets/JsonCloudAssetTests.cs @@ -0,0 +1,40 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using AdaptiveRemote.Models.CloudAssets; +using FluentAssertions; + +namespace AdaptiveRemote.Services.CloudAssets; + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(TestAsset))] +internal partial class TestAssetJsonContext : JsonSerializerContext { } + +internal record TestAsset(string Name, int Value); + +[TestClass] +public class JsonCloudAssetTests +{ + private static JsonCloudAsset MakeSut() => + new("asset", "/stream", "asset-ready", "/assets", + TestAssetJsonContext.Default); + + [TestMethod] + public async Task JsonCloudAsset_DeserializeAsync_CorrectlyDeserializesAsync() + { + // Arrange + TestAsset expected = new("test-name", 42); + string json = JsonSerializer.Serialize(expected, TestAssetJsonContext.Default.TestAsset); + using MemoryStream stream = new(Encoding.UTF8.GetBytes(json)); + JsonCloudAsset sut = MakeSut(); + + // Act + object result = await sut.DeserializeAsync(stream, CancellationToken.None); + + // Assert + result.Should().BeOfType(); + TestAsset asset = (TestAsset)result; + asset.Name.Should().Be("test-name"); + asset.Value.Should().Be(42); + } +} diff --git a/test/AdaptiveRemote.App.Tests/Services/Layout/LayoutStylesheetProviderTests.cs b/test/AdaptiveRemote.App.Tests/Services/Layout/LayoutStylesheetProviderTests.cs new file mode 100644 index 00000000..95eca654 --- /dev/null +++ b/test/AdaptiveRemote.App.Tests/Services/Layout/LayoutStylesheetProviderTests.cs @@ -0,0 +1,26 @@ +using AdaptiveRemote.Contracts; +using FluentAssertions; + +namespace AdaptiveRemote.Services.Layout; + +[TestClass] +public class LayoutStylesheetProviderTests +{ + private static CompiledLayout MakeLayout(string cssDefinitions) => + new(Guid.Empty, Guid.Empty, "stub", true, 1, [], cssDefinitions, DateTimeOffset.UtcNow); + + [TestMethod] + public void LayoutStylesheetProvider_GetCss_ReturnsCssFromCompiledLayout() + { + // Arrange + const string expectedCss = "#DPAD { display: grid; grid-template-columns: 3fr 2fr; }"; + CompiledLayout layout = MakeLayout(expectedCss); + LayoutStylesheetProvider sut = new(layout); + + // Act + string? css = sut.GetCss(); + + // Assert + css.Should().Be(expectedCss); + } +} diff --git a/test/AdaptiveRemote.App.Tests/Services/Layout/RemoteLayoutDefinitionServiceTests.cs b/test/AdaptiveRemote.App.Tests/Services/Layout/RemoteLayoutDefinitionServiceTests.cs new file mode 100644 index 00000000..a242dc44 --- /dev/null +++ b/test/AdaptiveRemote.App.Tests/Services/Layout/RemoteLayoutDefinitionServiceTests.cs @@ -0,0 +1,194 @@ +using AdaptiveRemote.Contracts; +using AdaptiveRemote.Models; +using FluentAssertions; + +namespace AdaptiveRemote.Services.Layout; + +[TestClass] +public class RemoteLayoutDefinitionServiceTests +{ + private static CompiledLayout MakeLayout(params LayoutElementDto[] elements) => + new(Guid.Empty, Guid.Empty, "stub", true, 1, elements, "", DateTimeOffset.UtcNow); + + [TestMethod] + public void RemoteLayoutDefinitionService_RemoteRoot_MapsTiVoCommandCorrectly() + { + // Arrange + CompiledLayout layout = MakeLayout( + new LayoutGroupDefinitionDto("GROUP", + [ + new CommandDefinitionDto(CommandType.TiVo, "Play", "Play", null, "Sent Play", "Pause", "Play"), + ])); + + RemoteLayoutDefinitionService sut = new(layout); + + // Act + LayoutGroup root = (LayoutGroup)sut.RemoteRoot; + LayoutGroup group = (LayoutGroup)root.Elements.First(); + TiVoCommand cmd = (TiVoCommand)group.Elements.First(); + + // Assert + cmd.Name.Should().Be("Play"); + cmd.Reverse.Should().Be("Pause"); + cmd.SpeakPhrase.Should().Be("Sent Play"); + } + + [TestMethod] + public void RemoteLayoutDefinitionService_RemoteRoot_MapsIRCommandCorrectly() + { + // Arrange + CompiledLayout layout = MakeLayout( + new LayoutGroupDefinitionDto("GROUP", + [ + new CommandDefinitionDto(CommandType.IR, "VolumeUp", "Up", null, "Sent Volume Up", "VolumeDown", "VolumeUp"), + ])); + + RemoteLayoutDefinitionService sut = new(layout); + + // Act + LayoutGroup root = (LayoutGroup)sut.RemoteRoot; + LayoutGroup group = (LayoutGroup)root.Elements.First(); + RemoteLayoutElement cmd = group.Elements.First(); + + // Assert + cmd.Should().BeOfType(); + cmd.As().Name.Should().Be("VolumeUp"); + cmd.As().SpeakPhrase.Should().Be("Sent Volume Up"); + } + + [TestMethod] + public void RemoteLayoutDefinitionService_RemoteRoot_MapsLifecycleCommandCorrectly() + { + // Arrange + CompiledLayout layout = MakeLayout( + new LayoutGroupDefinitionDto("GROUP", + [ + new CommandDefinitionDto(CommandType.Lifecycle, "Exit", "Exit", null, "Goodbye", null, "Exit"), + ])); + + RemoteLayoutDefinitionService sut = new(layout); + + // Act + LayoutGroup root = (LayoutGroup)sut.RemoteRoot; + LayoutGroup group = (LayoutGroup)root.Elements.First(); + RemoteLayoutElement cmd = group.Elements.First(); + + // Assert + cmd.Should().BeOfType(); + cmd.As().Name.Should().Be("Exit"); + } + + [TestMethod] + public void RemoteLayoutDefinitionService_RemoteRoot_MapsLayoutGroupCorrectly() + { + // Arrange + CompiledLayout layout = MakeLayout( + 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"), + ])); + + RemoteLayoutDefinitionService sut = new(layout); + + // Act + LayoutGroup root = (LayoutGroup)sut.RemoteRoot; + LayoutGroup dpad = (LayoutGroup)root.Elements.First(); + + // Assert + dpad.CSSID.Should().Be("DPAD"); + dpad.Elements.Should().HaveCount(2); + } + + [TestMethod] + public void RemoteLayoutDefinitionService_RemoteRoot_GutterAlwaysAppendedAsLastChild() + { + // Arrange + CompiledLayout layout = MakeLayout( + new LayoutGroupDefinitionDto("DPAD", + [ + new CommandDefinitionDto(CommandType.TiVo, "Up", "Up", null, "Sent Up", null, "Up"), + ])); + + RemoteLayoutDefinitionService sut = new(layout); + + // Act + LayoutGroup root = (LayoutGroup)sut.RemoteRoot; + RemoteLayoutElement lastChild = root.Elements.Last(); + + // Assert + lastChild.Should().BeOfType(); + LayoutGroup gutter = (LayoutGroup)lastChild; + gutter.CSSID.Should().Be("GUTTER"); + + List gutterElements = gutter.Elements.ToList(); + gutterElements.Should().HaveCount(3); + gutterElements[0].Should().BeOfType(); + gutterElements[1].Should().BeOfType() + .Which.Name.Should().Be("Learn"); + gutterElements[2].Should().BeOfType() + .Which.Name.Should().Be("Exit"); + } + + [TestMethod] + public void RemoteLayoutDefinitionService_RemoteRoot_UnknownCommandTypeThrows() + { + // Arrange + CompiledLayout layout = MakeLayout( + new LayoutGroupDefinitionDto("GROUP", + [ + new CommandDefinitionDto((CommandType)99, "Unknown", "Unknown", null, "Unknown", null, "Unknown"), + ])); + + // Act + Action act = () => _ = new RemoteLayoutDefinitionService(layout); + + // Assert + act.Should().Throw() + .WithMessage("*Unknown CommandType*"); + } + + [TestMethod] + public void RemoteLayoutDefinitionService_RemoteRoot_GutterInLayoutThrowsDescriptiveError() + { + // Arrange + CompiledLayout layout = MakeLayout( + new LayoutGroupDefinitionDto("GUTTER", + [ + new CommandDefinitionDto(CommandType.Lifecycle, "Exit", "Exit", null, "Goodbye", null, "Exit"), + ])); + + // Act + Action act = () => _ = new RemoteLayoutDefinitionService(layout); + + // Assert + act.Should().Throw() + .WithMessage("*already contains a GUTTER*"); + } + + [TestMethod] + public void RemoteLayoutDefinitionService_RemoteRoot_MapsNestedGroupsRecursively() + { + // Arrange + CompiledLayout layout = MakeLayout( + new LayoutGroupDefinitionDto("OUTER", + [ + new LayoutGroupDefinitionDto("INNER", + [ + new CommandDefinitionDto(CommandType.TiVo, "Select", "Select", null, "Sent Select", null, "Select"), + ]), + ])); + + RemoteLayoutDefinitionService sut = new(layout); + + // Act + LayoutGroup root = (LayoutGroup)sut.RemoteRoot; + LayoutGroup outer = (LayoutGroup)root.Elements.First(); + LayoutGroup inner = (LayoutGroup)outer.Elements.First(); + + // Assert + outer.CSSID.Should().Be("OUTER"); + inner.CSSID.Should().Be("INNER"); + inner.Elements.First().Should().BeOfType(); + } +} diff --git a/test/AdaptiveRemote.App.Tests/Services/Lifecycle/ApplicationLifecycleTests.cs b/test/AdaptiveRemote.App.Tests/Services/Lifecycle/ApplicationLifecycleTests.cs index 1e2bceb5..a28b9ca5 100644 --- a/test/AdaptiveRemote.App.Tests/Services/Lifecycle/ApplicationLifecycleTests.cs +++ b/test/AdaptiveRemote.App.Tests/Services/Lifecycle/ApplicationLifecycleTests.cs @@ -1,4 +1,5 @@ -using FluentAssertions; +using AdaptiveRemote.Models; +using FluentAssertions; using Moq; namespace AdaptiveRemote.Services.Lifecycle; @@ -16,13 +17,36 @@ public class ApplicationLifecycleTests private readonly Mock MockLifecycleViewController = new(); private readonly Mock MockActivity = new(); private readonly Mock MockServiceProvider = new(); + private readonly Mock MockSignal = new(); private readonly MockLogger MockLogger = new(); public TestContext? TestContext { get; set; } public LifecyclePhase LatestLifecyclePhase { get; private set; } - private ApplicationLifecycle CreateSut() => new ApplicationLifecycle(MockScopeProvider.Object, MockLifecycleViewController.Object, MockLogger); + private ApplicationLifecycle CreateSut(params Mock[] preScopeInitializers) + => CreateSutWithSignal(MockSignal.Object, preScopeInitializers); + + private ApplicationLifecycle CreateSutWithSignal(IApplicationRecycleSignal signal, params Mock[] preInitializers) + => new ApplicationLifecycle( + MockScopeProvider.Object, + MockLifecycleViewController.Object, + signal, + preInitializers.Select(x => x.Object), + MockLogger); + + private static Mock CreatePreScopeInitializer(string name, Task? result = null) + { + Mock mockPreInit = new(); + mockPreInit + .SetupGet(x => x.Name) + .Returns(name); + mockPreInit + .Setup(x => x.WaitAsync(It.IsAny(), It.IsAny())) + .Returns(result ?? Task.CompletedTask) + .Verifiable(Times.Once); + return mockPreInit; + } [TestInitialize] public void SetupMocks() @@ -34,11 +58,6 @@ public void SetupMocks() return workItem.Invoke(MockServiceProvider.Object, cancellationToken); }); - MockServiceProvider - .Setup(x => x.GetService(typeof(ScopedLifecycleContainer))) - .Returns(() => new ScopedLifecycleContainer([MockService1.Object, MockService2.Object, MockService3.Object], MockLifecycleViewController.Object, MockLogger)) - .Verifiable(Times.Between(1, 2, Moq.Range.Inclusive)); - MockService1 .SetupGet(x => x.Name) .Returns(nameof(MockService1)); @@ -80,6 +99,15 @@ public void SetupMocks() .Setup(x => x.SetFatalError(It.IsAny())) .Callback(delegate (Exception ex) { Assert.Fail("SetFatalError was called on the activity: {0}", ex); }); + MockSignal + .SetupGet(x => x.Token) + .Returns(CancellationToken.None); // Never fires; existing tests don't exercise recycle + + MockScopeProvider + .Setup(x => x.RecycleScopeAsync()) + .Throws(() => new AssertFailedException($"Unexpected call to {nameof(IApplicationScopeProvider.RecycleScopeAsync)}")) + .Verifiable(Times.Never); + MockLogger.OutputWriter = TestContext; } @@ -88,6 +116,7 @@ public void VerifyMocks() { Verify(MockServiceProvider, nameof(MockServiceProvider)); Verify(MockScopeProvider, nameof(MockScopeProvider)); + Verify(MockSignal, nameof(MockSignal)); Verify(MockService1, nameof(MockService1)); Verify(MockService2, nameof(MockService2)); @@ -104,7 +133,7 @@ static void Verify(Mock mock, string name) } catch (MockException e) { - throw new Exception($"Verify failed on {name}: {e.Message}"); + throw new AssertFailedException($"Verify failed on {name}: {e.Message}"); } } } @@ -115,6 +144,8 @@ public void ApplicationLifecycle_StartAsync_StartsExecuteTaskAndInitializesScope // Arrange ApplicationLifecycle sut = CreateSut(); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + Expect_InitializeAsyncOn(MockService1); Expect_InitializeAsyncOn(MockService2); Expect_InitializeAsyncOn(MockService3); @@ -132,7 +163,7 @@ public void ApplicationLifecycle_StartAsync_StartsExecuteTaskAndInitializesScope log.ApplicationLifecycle_Initialized(MockService2.Object.Name); log.ApplicationLifecycle_Initializing(MockService3.Object.Name); log.ApplicationLifecycle_Initialized(MockService3.Object.Name); - log.ApplicationLifecycle_ScopeReleased(); + log.ApplicationLifecycle_ScopeReady(); }); startTask.Should().BeComplete(because: "StartAsync should complete after all services are initialized"); @@ -146,6 +177,8 @@ public void ApplicationLifecycle_StartAsync_InitializesAllServicesWhileSomeCompl // Arrange ApplicationLifecycle sut = CreateSut(); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + Expect_InitializeAsyncOn(MockService1, IncompleteTask); Expect_InitializeAsyncOn(MockService2, IncompleteTask); Expect_InitializeAsyncOn(MockService3, IncompleteTask); @@ -175,6 +208,8 @@ public void ApplicationLifecycle_StartAsync_ImmediateFailure_LogsErrorAndDoesNot Exception expectedError1 = new InvalidOperationException("Error 1"); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + Expect_InitializeAsyncOn(MockService1, Task.CompletedTask); Expect_InitializeAsyncOn(MockService2, Task.FromException(expectedError1)); @@ -194,15 +229,15 @@ public void ApplicationLifecycle_StartAsync_ImmediateFailure_LogsErrorAndDoesNot log.ApplicationLifecycle_Initialized(MockService1.Object.Name); log.ApplicationLifecycle_Initializing(MockService2.Object.Name); log.ApplicationLifecycle_InitializingFailed(MockService2.Object.Name, expectedError1); + log.ApplicationLifecycle_ShuttingDown(); log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name); log.ApplicationLifecycle_CleanedUp(MockService1.Object.Name); log.ApplicationLifecycle_CleaningUp(MockService2.Object.Name); log.ApplicationLifecycle_CleanedUp(MockService2.Object.Name); - log.ApplicationLifecycle_ScopeReleased(); }); startTask.Should().BeComplete(because: "StartAsync should complete after all services are initialized"); - sut.ExecuteTask.Should().NotBeComplete(because: "ExecuteTask should remain running after startup"); + sut.ExecuteTask.Should().BeComplete(because: "ExecuteTask should exit if the loop ends"); LatestLifecyclePhase.Should().Be(LifecyclePhase.CleaningUp, because: "Services are being cleaned up after failure"); } @@ -215,6 +250,8 @@ public void ApplicationLifecycle_StartAsync_DelayedFailure_LogsErrorAndDoesNotSt Exception expectedError1 = new InvalidOperationException("Error 1"); TaskCompletionSource tcs = new(); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + Expect_InitializeAsyncOn(MockService1); Expect_InitializeAsyncOn(MockService2, tcs.Task); Expect_InitializeAsyncOn(MockService3); @@ -249,16 +286,16 @@ public void ApplicationLifecycle_StartAsync_DelayedFailure_LogsErrorAndDoesNotSt log.ApplicationLifecycle_Initializing(MockService3.Object.Name); log.ApplicationLifecycle_Initialized(MockService3.Object.Name); log.ApplicationLifecycle_InitializingFailed(MockService2.Object.Name, expectedError1); + log.ApplicationLifecycle_ShuttingDown(); log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name); log.ApplicationLifecycle_CleanedUp(MockService1.Object.Name); log.ApplicationLifecycle_CleaningUp(MockService2.Object.Name); log.ApplicationLifecycle_CleanedUp(MockService2.Object.Name); log.ApplicationLifecycle_CleaningUp(MockService3.Object.Name); log.ApplicationLifecycle_CleanedUp(MockService3.Object.Name); - log.ApplicationLifecycle_ScopeReleased(); }); - sut.ExecuteTask.Should().NotBeComplete(because: "ExecuteTask should remain running after startup"); + sut.ExecuteTask.Should().BeComplete(because: "ExecuteTask should exit if the loop ends"); LatestLifecyclePhase.Should().Be(LifecyclePhase.CleaningUp, because: "Services are being cleaned up after failure"); } @@ -288,7 +325,7 @@ public void ApplicationLifecycle_StartAsync_ErrorDuringConstructor_SetsFatalErro { log.ApplicationLifecycle_WaitingForScope(); log.ApplicationLifecycle_ScopeConstructionFailed(expectedError1); - log.ApplicationLifecycle_ScopeReleased(); + log.ApplicationLifecycle_ShuttingDown(); }); } @@ -322,7 +359,6 @@ public void ApplicationLifecycle_StopAsync_AfterErrorDuringConstructor_DoesNothi { log.ApplicationLifecycle_WaitingForScope(); log.ApplicationLifecycle_ScopeConstructionFailed(expectedError1); - log.ApplicationLifecycle_ScopeReleased(); log.ApplicationLifecycle_ShuttingDown(); }); } @@ -335,6 +371,8 @@ public void ApplicationLifecycle_StartAsync_ImmediateFailure_CancelsStartupThatI Exception expectedError1 = new InvalidOperationException("Error 1"); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + Expect_InitializeAsyncOn(MockService1, IncompleteTask); Expect_InitializeAsyncOn(MockService2, Task.FromException(expectedError1)); @@ -353,15 +391,15 @@ public void ApplicationLifecycle_StartAsync_ImmediateFailure_CancelsStartupThatI log.ApplicationLifecycle_Initializing(MockService1.Object.Name); log.ApplicationLifecycle_Initializing(MockService2.Object.Name); log.ApplicationLifecycle_InitializingFailed(MockService2.Object.Name, expectedError1); + log.ApplicationLifecycle_ShuttingDown(); log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name); log.ApplicationLifecycle_CleanedUp(MockService1.Object.Name); log.ApplicationLifecycle_CleaningUp(MockService2.Object.Name); log.ApplicationLifecycle_CleanedUp(MockService2.Object.Name); - log.ApplicationLifecycle_ScopeReleased(); }); startTask.Should().BeComplete(because: "StartAsync should complete after all services are initialized"); - sut.ExecuteTask.Should().NotBeComplete(because: "ExecuteTask should remain running after startup"); + sut.ExecuteTask.Should().BeComplete(because: "ExecuteTask should exit if the loop ends"); LatestLifecyclePhase.Should().Be(LifecyclePhase.CleaningUp, because: "Services are being cleaned up after failure"); } @@ -371,6 +409,8 @@ public void ApplicationLifecycle_StopAsync_DisposesScopeAndCompletesTask() // Arrange ApplicationLifecycle sut = CreateSut(); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + Expect_InitializeAsyncOn(MockService1); Expect_InitializeAsyncOn(MockService2); Expect_InitializeAsyncOn(MockService3); @@ -398,7 +438,7 @@ public void ApplicationLifecycle_StopAsync_DisposesScopeAndCompletesTask() log.ApplicationLifecycle_Initialized(MockService2.Object.Name); log.ApplicationLifecycle_Initializing(MockService3.Object.Name); log.ApplicationLifecycle_Initialized(MockService3.Object.Name); - log.ApplicationLifecycle_ScopeReleased(); + log.ApplicationLifecycle_ScopeReady(); log.ApplicationLifecycle_ShuttingDown(); log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name); log.ApplicationLifecycle_CleanedUp(MockService1.Object.Name); @@ -419,6 +459,8 @@ public void ApplicationLifecycle_StopAsync_BlocksUntilServicesAreCleanedUp() // Arrange ApplicationLifecycle sut = CreateSut(); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + Expect_InitializeAsyncOn(MockService1); Expect_InitializeAsyncOn(MockService2); Expect_InitializeAsyncOn(MockService3); @@ -446,7 +488,7 @@ public void ApplicationLifecycle_StopAsync_BlocksUntilServicesAreCleanedUp() log.ApplicationLifecycle_Initialized(MockService2.Object.Name); log.ApplicationLifecycle_Initializing(MockService3.Object.Name); log.ApplicationLifecycle_Initialized(MockService3.Object.Name); - log.ApplicationLifecycle_ScopeReleased(); + log.ApplicationLifecycle_ScopeReady(); log.ApplicationLifecycle_ShuttingDown(); log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name); log.ApplicationLifecycle_CleaningUp(MockService2.Object.Name); @@ -467,6 +509,8 @@ public void ApplicationLifecycle_StopAsync_ReportsErrorsInCleanUp() Exception expectedError1 = new InvalidOperationException("Error 1"); Exception expectedError2 = new FormatException("Error 2"); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + Expect_InitializeAsyncOn(MockService1); Expect_InitializeAsyncOn(MockService2); Expect_InitializeAsyncOn(MockService3); @@ -495,7 +539,7 @@ public void ApplicationLifecycle_StopAsync_ReportsErrorsInCleanUp() log.ApplicationLifecycle_Initialized(MockService2.Object.Name); log.ApplicationLifecycle_Initializing(MockService3.Object.Name); log.ApplicationLifecycle_Initialized(MockService3.Object.Name); - log.ApplicationLifecycle_ScopeReleased(); + log.ApplicationLifecycle_ScopeReady(); log.ApplicationLifecycle_ShuttingDown(); log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name); log.ApplicationLifecycle_CleaningUpFailed(MockService1.Object.Name, expectedError1); @@ -516,6 +560,8 @@ public void ApplicationLifecycle_StopAsync_CancelsInitializeMethodsThatAreWaitin // Arrange ApplicationLifecycle sut = CreateSut(); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + Expect_InitializeAsyncOn(MockService1); Expect_InitializeAsyncOn(MockService2, result: IncompleteTask); Expect_InitializeAsyncOn(MockService3); @@ -564,6 +610,8 @@ public void ApplicationLifecycle_StopAsync_AfterInitializeFailure_DoesNothing() Exception expectedError1 = new InvalidOperationException("Error 1"); + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + Expect_InitializeAsyncOn(MockService1, Task.CompletedTask); Expect_InitializeAsyncOn(MockService2, Task.FromException(expectedError1)); @@ -592,12 +640,11 @@ public void ApplicationLifecycle_StopAsync_AfterInitializeFailure_DoesNothing() log.ApplicationLifecycle_Initialized(MockService1.Object.Name); log.ApplicationLifecycle_Initializing(MockService2.Object.Name); log.ApplicationLifecycle_InitializingFailed(MockService2.Object.Name, expectedError1); + log.ApplicationLifecycle_ShuttingDown(); log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name); log.ApplicationLifecycle_CleanedUp(MockService1.Object.Name); log.ApplicationLifecycle_CleaningUp(MockService2.Object.Name); log.ApplicationLifecycle_CleanedUp(MockService2.Object.Name); - log.ApplicationLifecycle_ScopeReleased(); - log.ApplicationLifecycle_ShuttingDown(); }); sut.ExecuteTask.Should().BeComplete(because: "ExecuteTask should complete after all services have stopped"); @@ -610,12 +657,24 @@ private static void Expect_InitializeAsyncOn(Mock service, Tas .WithStandardTaskBehavior(result) .Verifiable(Times.Once); + private static void Expect_InitializeAsyncAtLeastOnce(Mock service, Task? result = default) + => service + .Setup(x => x.InitializeAsync(It.IsAny(), It.IsAny())) + .WithStandardTaskBehavior(result) + .Verifiable(Times.AtLeastOnce); + private static void Expect_CleanupAsyncOn(Mock service, Task? result = default) => service .Setup(x => x.CleanUpAsync(It.IsAny(), It.IsAny())) .WithStandardTaskBehavior(result) .Verifiable(Times.Once); + private static void Expect_CleanupAsyncAtLeastOnce(Mock service, Task? result = default) + => service + .Setup(x => x.CleanUpAsync(It.IsAny(), It.IsAny())) + .WithStandardTaskBehavior(result) + .Verifiable(Times.AtLeastOnce); + private static void Expect_SetFatalErrorOn(Mock activity, params Exception[] expectedExceptions) => activity .Setup(x => x.SetFatalError(It.IsAny())) @@ -636,4 +695,801 @@ private static void Expect_SetFatalErrorOn(Mock contro }) .Verifiable(Times.Exactly(expectedExceptions.Length)); + private static void Expect_RecycleScopeAsyncOn(Mock provider, Times? times = null) + => provider + .Setup(x => x.RecycleScopeAsync()) + .WithStandardTaskBehavior() + .Verifiable(times ?? Times.Once()); + + private void Expect_GetServiceScopedLifecycleContainerOn(Mock provider, Times? times = null) + => provider + .Setup(x => x.GetService(typeof(ScopedLifecycleContainer))) + .Returns(() => new ScopedLifecycleContainer([MockService1.Object, MockService2.Object, MockService3.Object], MockLifecycleViewController.Object, MockLogger)) + .Verifiable(times ?? Times.Once()); + + [TestMethod] + public void ApplicationLifecycle_RecycleSignal_DuringReadyState_CallsRecycleScope() + { + // Arrange + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider, Times.Exactly(2)); + + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2); + Expect_InitializeAsyncOn(MockService3); + + Expect_RecycleScopeAsyncOn(MockScopeProvider); + + ApplicationRecycleSignal signal = new(); + ApplicationLifecycle sut = CreateSutWithSignal(signal); + + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Wait for first scope to enter steady state + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_ScopeReady(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + Expect_CleanupAsyncOn(MockService1); + Expect_CleanupAsyncOn(MockService2); + Expect_CleanupAsyncOn(MockService3); + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2); + Expect_InitializeAsyncOn(MockService3); + + // Act: fire recycle signal during steady state + signal.RequestRecycle(); + + // Assert: RecycleScopeAsync is called (and RecyclingScope is logged before it) + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_RecyclingScope(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + MockScopeProvider.Verify(x => x.RecycleScopeAsync(), Times.Once); + + // Arrange + Expect_CleanupAsyncOn(MockService1); + Expect_CleanupAsyncOn(MockService2); + Expect_CleanupAsyncOn(MockService3); + + // Stop to end the second scope + Task stopTask = sut.StopAsync(default); + stopTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(2)); + } + + [TestMethod] + public void ApplicationLifecycle_RecycleSignal_DuringReadyState_LoopsToNextScope() + { + // Arrange + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider, Times.Exactly(2)); + + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2); + Expect_InitializeAsyncOn(MockService3); + + ApplicationRecycleSignal signal = new(); + ApplicationLifecycle sut = CreateSutWithSignal(signal); + + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Wait for steady state + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_ScopeReady(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + Expect_CleanupAsyncOn(MockService1); + Expect_CleanupAsyncOn(MockService2); + Expect_CleanupAsyncOn(MockService3); + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2); + Expect_InitializeAsyncOn(MockService3); + Expect_RecycleScopeAsyncOn(MockScopeProvider); + + // Act: fire recycle + signal.RequestRecycle(); + + // Assert: loop continues — second scope's ScopeReleased eventually logged + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_RecyclingScope(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // WaitingForScope appears twice: once at start and once after Reset + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_Initializing(MockService1.Object.Name), TimeSpan.FromSeconds(2)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(2), because: "loop should re-enter a new scope and start initializing again"); + + // Arrange + Expect_CleanupAsyncOn(MockService1); + Expect_CleanupAsyncOn(MockService2); + Expect_CleanupAsyncOn(MockService3); + + // Act + Task stopTask = sut.StopAsync(default); + + // Assert + stopTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(2)); + + int waitingForScopeCount = MockLogger.CountMessages(log => log.ApplicationLifecycle_WaitingForScope()); + waitingForScopeCount.Should().Be(2, because: "the loop should iterate twice: initial scope and post-recycle scope"); + } + + [TestMethod] + public void ApplicationLifecycle_RecycleSignal_DuringInit_CancelsAndCallsRecycleScope() + { + // Arrange: MockService2 init hangs until the signal fires and cancels it + TaskCompletionSource hangingInitTcs = new(); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider, Times.Exactly(2)); + + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2, hangingInitTcs.Task); + Expect_InitializeAsyncOn(MockService3); + + ApplicationRecycleSignal signal = new(); + ApplicationLifecycle sut = CreateSutWithSignal(signal); + + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Wait for MockService2 to start initializing (confirming we are mid-init) + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_Initializing(MockService2.Object.Name), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + Expect_CleanupAsyncOn(MockService1); + Expect_CleanupAsyncOn(MockService2); + Expect_CleanupAsyncOn(MockService3); + Expect_RecycleScopeAsyncOn(MockScopeProvider); + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2, hangingInitTcs.Task); + Expect_InitializeAsyncOn(MockService3); + + // Act: fire recycle while init is in progress + signal.RequestRecycle(); + + // Assert: cleanup starts (proves signal was processed) + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + MockLogger.VerifyMessages(log => + { + log.ApplicationLifecycle_WaitingForScope(); + log.ApplicationLifecycle_Initializing(MockService1.Object.Name); + log.ApplicationLifecycle_Initialized(MockService1.Object.Name); + log.ApplicationLifecycle_Initializing(MockService2.Object.Name); + log.ApplicationLifecycle_Initializing(MockService3.Object.Name); + log.ApplicationLifecycle_Initialized(MockService3.Object.Name); + + log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name); + log.ApplicationLifecycle_CleanedUp(MockService1.Object.Name); + log.ApplicationLifecycle_CleaningUp(MockService2.Object.Name); + log.ApplicationLifecycle_CleanedUp(MockService2.Object.Name); + log.ApplicationLifecycle_CleaningUp(MockService3.Object.Name); + log.ApplicationLifecycle_CleanedUp(MockService3.Object.Name); + log.ApplicationLifecycle_RecyclingScope(); + + log.ApplicationLifecycle_WaitingForScope(); + log.ApplicationLifecycle_Initializing(MockService1.Object.Name); + log.ApplicationLifecycle_Initialized(MockService1.Object.Name); + log.ApplicationLifecycle_Initializing(MockService2.Object.Name); + log.ApplicationLifecycle_Initializing(MockService3.Object.Name); + log.ApplicationLifecycle_Initialized(MockService3.Object.Name); + }); + } + + [TestMethod] + public void ApplicationLifecycle_RecycleSignal_DoesNotReawaitPreInitializers() + { + // Arrange + Mock mockPreInit = CreatePreScopeInitializer(nameof(mockPreInit)); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider, Times.Exactly(2)); + + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2); + Expect_InitializeAsyncOn(MockService3); + + ApplicationRecycleSignal signal = new(); + ApplicationLifecycle sut = CreateSutWithSignal(signal, mockPreInit); + + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Wait for first scope to reach steady state + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_ScopeReady(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + Expect_CleanupAsyncOn(MockService1); + Expect_CleanupAsyncOn(MockService2); + Expect_CleanupAsyncOn(MockService3); + Expect_RecycleScopeAsyncOn(MockScopeProvider); + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2); + Expect_InitializeAsyncOn(MockService3); + + // Act: fire recycle signal + signal.RequestRecycle(); + + // Wait for recycle to complete and loop to continue + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_RecyclingScope(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Wait for the second scope to start initializing — confirms the loop re-entered without re-running pre-inits + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_WaitingForScope(), TimeSpan.FromSeconds(2)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(2)); + + // Assert: pre-initializer was called exactly once (Times.Once is verified by VerifyMocks) + mockPreInit.Verify(x => x.WaitAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [TestMethod] + public void ApplicationLifecycle_RecycleSignal_DuringCleanup_SecondSignalIsNoOp() + { + // Arrange: service1 cleanup hangs so we can observe the cleanup phase + TaskCompletionSource cleanupTcs = new(); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider, Times.Exactly(2)); + + Expect_InitializeAsyncAtLeastOnce(MockService1); + Expect_InitializeAsyncAtLeastOnce(MockService2); + Expect_InitializeAsyncAtLeastOnce(MockService3); + Expect_CleanupAsyncAtLeastOnce(MockService1, cleanupTcs.Task); + Expect_CleanupAsyncAtLeastOnce(MockService2); + Expect_CleanupAsyncAtLeastOnce(MockService3); + + Expect_RecycleScopeAsyncOn(MockScopeProvider); + + ApplicationRecycleSignal signal = new(); + ApplicationLifecycle sut = CreateSutWithSignal(signal); + + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_ScopeReady(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Act: first recycle during steady state + signal.RequestRecycle(); + + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Verify not recycled yet while cleanup is in progress + MockScopeProvider.Verify(x => x.RecycleScopeAsync(), Times.Never); + + // Second signal during cleanup — already cancelled, so this is a no-op + signal.RequestRecycle(); + + // Complete cleanup — recycle should proceed exactly once + cleanupTcs.SetResult(); + + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_RecyclingScope(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + MockScopeProvider.Verify(x => x.RecycleScopeAsync(), Times.Once); + + Task stopTask = sut.StopAsync(default); + stopTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(2)); + } + + [TestMethod] + public void ApplicationLifecycle_RecycleSignal_DuringReadyState_BlocksUntilCleanupCompletes() + { + // Arrange: service1 cleanup hangs so we can verify RecycleScopeAsync is not called prematurely + TaskCompletionSource cleanupTcs = new(); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider, Times.Exactly(2)); + + Expect_InitializeAsyncAtLeastOnce(MockService1); + Expect_InitializeAsyncAtLeastOnce(MockService2); + Expect_InitializeAsyncAtLeastOnce(MockService3); + Expect_CleanupAsyncAtLeastOnce(MockService1, cleanupTcs.Task); + Expect_CleanupAsyncAtLeastOnce(MockService2); + Expect_CleanupAsyncAtLeastOnce(MockService3); + + Expect_RecycleScopeAsyncOn(MockScopeProvider); + + ApplicationRecycleSignal signal = new(); + ApplicationLifecycle sut = CreateSutWithSignal(signal); + + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_ScopeReady(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Act: fire recycle — cleanup starts but is incomplete + signal.RequestRecycle(); + + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_CleaningUp(MockService1.Object.Name), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // RecycleScopeAsync must not be called while cleanup is still in progress + MockScopeProvider.Verify(x => x.RecycleScopeAsync(), Times.Never); + + // Complete cleanup — RecycleScopeAsync should now be called + cleanupTcs.SetResult(); + + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_RecyclingScope(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + MockScopeProvider.Verify(x => x.RecycleScopeAsync(), Times.Once); + + Task stopTask = sut.StopAsync(default); + stopTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(2)); + } + + [TestMethod] + public void ApplicationLifecycle_RecycleSignal_AfterRecycle_WaitsForInitializationBeforeReady() + { + // Arrange: after recycle, service2 init hangs in the second scope + int service2InitCalls = 0; + TaskCompletionSource service2SecondInitTcs = new(); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider, Times.Exactly(2)); + + Expect_InitializeAsyncAtLeastOnce(MockService1); + MockService2 + .Setup(x => x.InitializeAsync(It.IsAny(), It.IsAny())) + .Returns(delegate (IInvocation invocation) + { + TaskCompletionSource tcs = new(); + foreach (object arg in invocation.Arguments) + { + if (arg is CancellationToken ct) + { + ct.Register(() => tcs.TrySetCanceled()); + break; + } + } + if (Interlocked.Increment(ref service2InitCalls) == 1) + { + tcs.TrySetResult(); + } + else + { + service2SecondInitTcs.Task.ContinueWith(_ => tcs.TrySetResult(), TaskContinuationOptions.ExecuteSynchronously); + } + return tcs.Task; + }) + .Verifiable(Times.AtLeast(2)); + Expect_InitializeAsyncAtLeastOnce(MockService3); + Expect_CleanupAsyncAtLeastOnce(MockService1); + Expect_CleanupAsyncAtLeastOnce(MockService2); + Expect_CleanupAsyncAtLeastOnce(MockService3); + + Expect_RecycleScopeAsyncOn(MockScopeProvider); + + ApplicationRecycleSignal signal = new(); + ApplicationLifecycle sut = CreateSutWithSignal(signal); + + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Wait for first scope to reach steady state, then recycle + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_ScopeReady(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + signal.RequestRecycle(); + + // Wait for second scope init to start + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_Initializing(MockService2.Object.Name), TimeSpan.FromSeconds(2)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(2), because: "second scope should begin initializing after recycle"); + + // Assert: scope is not yet ready (still initializing service2) + MockLogger.CountMessages(log => log.ApplicationLifecycle_ScopeReady()) + .Should().Be(1, because: "ScopeReady should only appear once — the second scope is still initializing"); + } + + [TestMethod] + public void ApplicationLifecycle_RecycleSignal_ErrorDuringCleanup_ContinuesToRecycle() + { + // Arrange: service2 cleanup throws; lifecycle should log the error but still call RecycleScopeAsync + Exception cleanupError = new InvalidOperationException("Cleanup failure"); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider, Times.Exactly(2)); + + Expect_InitializeAsyncAtLeastOnce(MockService1); + Expect_InitializeAsyncAtLeastOnce(MockService2); + Expect_InitializeAsyncAtLeastOnce(MockService3); + Expect_CleanupAsyncAtLeastOnce(MockService1); + Expect_CleanupAsyncAtLeastOnce(MockService2, Task.FromException(cleanupError)); + Expect_CleanupAsyncAtLeastOnce(MockService3); + // SetFatalError is called once per failing cleanup; service2 cleanup fails in each scope iteration + Expect_SetFatalErrorOn(MockActivity, cleanupError, cleanupError); + + Expect_RecycleScopeAsyncOn(MockScopeProvider); + + ApplicationRecycleSignal signal = new(); + ApplicationLifecycle sut = CreateSutWithSignal(signal); + + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_ScopeReady(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Act + signal.RequestRecycle(); + + // Assert: error is logged and RecycleScopeAsync is still called despite the cleanup failure + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_CleaningUpFailed(MockService2.Object.Name, cleanupError), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_RecyclingScope(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + MockScopeProvider.Verify(x => x.RecycleScopeAsync(), Times.Once); + + Task stopTask = sut.StopAsync(default); + stopTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(2)); + } + + [TestMethod] + public void ApplicationLifecycle_RecycleSignal_AfterRecycle_ErrorDuringInit_ExitsLoop() + { + // Arrange: service2 init succeeds in first scope but fails in second scope after recycle + Exception initError = new InvalidOperationException("Init failure after recycle"); + int service2InitCalls = 0; + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider, Times.Exactly(2)); + + Expect_InitializeAsyncAtLeastOnce(MockService1); + MockService2 + .Setup(x => x.InitializeAsync(It.IsAny(), It.IsAny())) + .Returns(delegate (IInvocation invocation) + { + return Interlocked.Increment(ref service2InitCalls) == 1 + ? Task.CompletedTask + : Task.FromException(initError); + }) + .Verifiable(Times.AtLeast(2)); + Expect_InitializeAsyncOn(MockService3); // only initialized in first scope (second scope aborts before service3) + Expect_CleanupAsyncAtLeastOnce(MockService1); + Expect_CleanupAsyncAtLeastOnce(MockService2); + Expect_CleanupAsyncOn(MockService3); // only cleaned up in first scope + + Expect_SetFatalErrorOn(MockActivity, initError); + + Expect_RecycleScopeAsyncOn(MockScopeProvider); + + ApplicationRecycleSignal signal = new(); + ApplicationLifecycle sut = CreateSutWithSignal(signal); + + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Wait for first scope to reach steady state, then recycle + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_ScopeReady(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + signal.RequestRecycle(); + + // Wait for second scope to fail and exit the loop + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_ShuttingDown(), TimeSpan.FromSeconds(2)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(2), because: "init failure in second scope should exit the loop"); + + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_InitializingFailed(MockService2.Object.Name, initError), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + Task stopTask = sut.StopAsync(default); + stopTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(2)); + } + + [TestMethod] + public void ApplicationLifecycle_RecycleSignal_DuringPreInit_IsNoOp() + { + // Arrange: pre-init hangs; signal fires while it is waiting + TaskCompletionSource preInitTcs = new(); + Mock mockPreInit = CreatePreScopeInitializer(nameof(mockPreInit), preInitTcs.Task); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2); + Expect_InitializeAsyncOn(MockService3); + + ApplicationRecycleSignal signal = new(); + ApplicationLifecycle sut = CreateSutWithSignal(signal, mockPreInit); + + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Fire signal while pre-init is still waiting — no scope exists yet to recycle + signal.RequestRecycle(); + + // Complete pre-init — scope should be created normally despite the signal + preInitTcs.SetResult(); + + // Assert: scope reaches ready state (lifecycle continues normally after the no-op recycle) + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_ScopeReady(), TimeSpan.FromSeconds(2)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(2), because: "scope should be created normally after pre-init completes"); + } + + [TestMethod] + public void ApplicationLifecycle_StartAsync_WaitsForPreInitializers() + { + // Arrange + Mock mockPreInit = CreatePreScopeInitializer(nameof(mockPreInit)); + + ApplicationLifecycle sut = CreateSut(mockPreInit); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2); + Expect_InitializeAsyncOn(MockService3); + + // Act + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Wait for pre-initializer to be called + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_WaitingForScope(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Assert + MockLogger.VerifyMessages(log => + { + log.ApplicationLifecycle_WaitingForScope(); + log.ApplicationLifecycle_Initializing(MockService1.Object.Name); + log.ApplicationLifecycle_Initialized(MockService1.Object.Name); + log.ApplicationLifecycle_Initializing(MockService2.Object.Name); + log.ApplicationLifecycle_Initialized(MockService2.Object.Name); + log.ApplicationLifecycle_Initializing(MockService3.Object.Name); + log.ApplicationLifecycle_Initialized(MockService3.Object.Name); + log.ApplicationLifecycle_ScopeReady(); + }); + mockPreInit.Verify(); + } + + [TestMethod] + public void ApplicationLifecycle_StartAsync_PreInitializerDelayed_WaitsBeforeStartingScope() + { + // Arrange + TaskCompletionSource preInitTcs = new(); + Mock mockPreInit = CreatePreScopeInitializer(nameof(mockPreInit)); + + ApplicationLifecycle sut = CreateSut(mockPreInit); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2); + Expect_InitializeAsyncOn(MockService3); + + // Act + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Pre-initializer hasn't completed, so scope shouldn't be created yet + MockLogger.Messages.Should().NotContain(m => m.Contains("WaitingForScope")); + + // Complete pre-initializer + preInitTcs.SetResult(); + + // Assert + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_WaitingForScope(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + MockLogger.VerifyMessages(log => + { + log.ApplicationLifecycle_WaitingForScope(); + log.ApplicationLifecycle_Initializing(MockService1.Object.Name); + log.ApplicationLifecycle_Initialized(MockService1.Object.Name); + log.ApplicationLifecycle_Initializing(MockService2.Object.Name); + log.ApplicationLifecycle_Initialized(MockService2.Object.Name); + log.ApplicationLifecycle_Initializing(MockService3.Object.Name); + log.ApplicationLifecycle_Initialized(MockService3.Object.Name); + log.ApplicationLifecycle_ScopeReady(); + }); + + mockPreInit.Verify(); + } + + [TestMethod] + public void ApplicationLifecycle_StartAsync_MultiplePreInitializers_WaitsForAll() + { + // Arrange + Mock mockPreInit1 = CreatePreScopeInitializer(nameof(mockPreInit1)); + Mock mockPreInit2 = CreatePreScopeInitializer(nameof(mockPreInit2)); + Mock mockPreInit3 = CreatePreScopeInitializer(nameof(mockPreInit3)); + + ApplicationLifecycle sut = CreateSut(mockPreInit1, mockPreInit2, mockPreInit3); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2); + Expect_InitializeAsyncOn(MockService3); + + // Act + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Wait for scope to be ready + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_WaitingForScope(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Assert + MockLogger.VerifyMessages(log => + { + log.ApplicationLifecycle_WaitingForScope(); + log.ApplicationLifecycle_Initializing(MockService1.Object.Name); + log.ApplicationLifecycle_Initialized(MockService1.Object.Name); + log.ApplicationLifecycle_Initializing(MockService2.Object.Name); + log.ApplicationLifecycle_Initialized(MockService2.Object.Name); + log.ApplicationLifecycle_Initializing(MockService3.Object.Name); + log.ApplicationLifecycle_Initialized(MockService3.Object.Name); + log.ApplicationLifecycle_ScopeReady(); + }); + + mockPreInit1.Verify(); + mockPreInit2.Verify(); + mockPreInit3.Verify(); + } + + [TestMethod] + public void ApplicationLifecycle_StartAsync_PreInitializerFails_SetsActivityError() + { + // Arrange + Exception expectedError = new InvalidOperationException("PreInit failed"); + Mock mockPreInit = CreatePreScopeInitializer(nameof(mockPreInit), Task.FromException(expectedError)); + + Expect_SetFatalErrorOn(MockActivity, expectedError); + + ApplicationLifecycle sut = CreateSut(mockPreInit); + + // Act + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Assert - ExecuteTask should fault with unhandled error + MockLogger.VerifyMessages(log => + { + log.ApplicationLifecycle_PreinitializerFailed(mockPreInit.Object.Name, expectedError); + log.ApplicationLifecycle_ShuttingDown(); + }); + + mockPreInit.Verify(); + } + + [TestMethod] + public void ApplicationLifecycle_StartAsync_LastPreInitializerFails_StopsBeforeScope() + { + // Arrange + Exception expectedError = new InvalidOperationException("Last PreInit failed"); + Mock mockPreInit1 = CreatePreScopeInitializer(nameof(mockPreInit1)); + Mock mockPreInit2 = CreatePreScopeInitializer(nameof(mockPreInit2), Task.FromException(expectedError)); + + ApplicationLifecycle sut = CreateSut(mockPreInit1, mockPreInit2); + + // Act + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Assert - execution should fail before creating scope + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_PreinitializerFailed(mockPreInit2.Object.Name, expectedError), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + MockLogger.Messages.Should().NotContain(m => m.Contains("WaitingForScope")); + mockPreInit1.Verify(); + mockPreInit2.Verify(); + } + + [TestMethod] + public void ApplicationLifecycle_StartAsync_PreInitializerCreatesActivityForEach() + { + // Arrange + Mock mockPreInit = CreatePreScopeInitializer(nameof(mockPreInit)); + Mock mockActivity = new(); + + MockLifecycleViewController + .Setup(x => x.StartTask(Phrases.Startup_Preinitializing(mockPreInit.Object.Name))) + .Returns(mockActivity.Object) + .Verifiable(Times.Once); + + ApplicationLifecycle sut = CreateSut(mockPreInit); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2); + Expect_InitializeAsyncOn(MockService3); + + // Act + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Wait for pre-initializer to be called + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_WaitingForScope(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Assert - StartTask should have been called for the pre-initializer + MockLifecycleViewController.Verify(); + mockPreInit.Verify(); + + // Assert - Activity should be disposed after pre-initializer completes + mockActivity.Verify(x => x.Dispose(), Times.Once, "Activity should be disposed after pre-initializer completes"); + + MockLogger.VerifyMessages(log => + { + log.ApplicationLifecycle_WaitingForScope(); + log.ApplicationLifecycle_Initializing(MockService1.Object.Name); + log.ApplicationLifecycle_Initialized(MockService1.Object.Name); + log.ApplicationLifecycle_Initializing(MockService2.Object.Name); + log.ApplicationLifecycle_Initialized(MockService2.Object.Name); + log.ApplicationLifecycle_Initializing(MockService3.Object.Name); + log.ApplicationLifecycle_Initialized(MockService3.Object.Name); + log.ApplicationLifecycle_ScopeReady(); + }); + } + + [TestMethod] + public void ApplicationLifecycle_StartAsync_PreInitializerActivity_DisposesImmediatelyWhenCompleted() + { + // Arrange + TaskCompletionSource slowPreInitTcs = new(); + Mock fastPreInit = CreatePreScopeInitializer(nameof(fastPreInit)); + Mock slowPreInit = CreatePreScopeInitializer(nameof(slowPreInit), slowPreInitTcs.Task); + Mock fastActivity = new(); + Mock slowActivity = new(); + + int callCount = 0; + + // Mock StartTask to return different activities based on call order + MockLifecycleViewController + .Setup(x => x.StartTask(It.IsAny())) + .Returns(() => + { + if (callCount == 0) + { + callCount++; + return fastActivity.Object; + } + else if (callCount == 1) + { + callCount++; + return slowActivity.Object; + } + return MockActivity.Object; + }); + + ApplicationLifecycle sut = CreateSut(fastPreInit, slowPreInit); + + Expect_GetServiceScopedLifecycleContainerOn(MockServiceProvider); + + Expect_InitializeAsyncOn(MockService1); + Expect_InitializeAsyncOn(MockService2); + Expect_InitializeAsyncOn(MockService3); + + // Act + Task startTask = sut.StartAsync(default); + startTask.Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Give time for fast pre-initializer to complete and be disposed + Thread.Sleep(100); + + // Assert - Fast activity should be disposed even though slow activity is still pending + fastActivity.Verify(x => x.Dispose(), Times.Once, "Fast activity should be disposed immediately after completing"); + slowActivity.Verify(x => x.Dispose(), Times.Never, "Slow activity should not be disposed while still pending"); + + // Complete slow pre-initializer + slowPreInitTcs.SetResult(); + + // Wait for scope to start + MockLogger.WaitForMessageAsync(log => log.ApplicationLifecycle_WaitingForScope(), TimeSpan.FromSeconds(1)) + .Should().BeCompleteWithin(TimeSpan.FromSeconds(1)); + + // Assert - Now slow activity should be disposed too + slowActivity.Verify(x => x.Dispose(), Times.Once, "Slow activity should be disposed after completing"); + + // The first log message is for waiting for the slow preinitializer + MockLogger.VerifyMessages(log => + { + log.ApplicationLifecycle_WaitingForPreinitializer("slowPreInit"); + log.ApplicationLifecycle_WaitingForScope(); + log.ApplicationLifecycle_Initializing(MockService1.Object.Name); + log.ApplicationLifecycle_Initialized(MockService1.Object.Name); + log.ApplicationLifecycle_Initializing(MockService2.Object.Name); + log.ApplicationLifecycle_Initialized(MockService2.Object.Name); + log.ApplicationLifecycle_Initializing(MockService3.Object.Name); + log.ApplicationLifecycle_Initialized(MockService3.Object.Name); + log.ApplicationLifecycle_ScopeReady(); + }); + } } diff --git a/test/AdaptiveRemote.App.Tests/Services/MvvmPropertyIdleAdapterTests.cs b/test/AdaptiveRemote.App.Tests/Services/MvvmPropertyIdleAdapterTests.cs new file mode 100644 index 00000000..1381db56 --- /dev/null +++ b/test/AdaptiveRemote.App.Tests/Services/MvvmPropertyIdleAdapterTests.cs @@ -0,0 +1,127 @@ +using AdaptiveRemote.Mvvm; +using FluentAssertions; + +namespace AdaptiveRemote.Services; + +[TestClass] +public class MvvmPropertyIdleAdapterTests +{ + [TestMethod] + public void LastActivityTime_InitiallyMinValue_WhenPropertyIsFalse() + { + // Arrange + TestTarget target = new() { IsActive = false }; + TestIdleAdapter sut = new(target); + + // Act + DateTime result = sut.LastActivityTime; + + // Assert + result.Should().Be(DateTime.MinValue); + } + + [TestMethod] + public void LastActivityTime_Now_WhenPropertyIsTrue() + { + // Arrange + TestTarget target = new() { IsActive = true }; + TestIdleAdapter sut = new(target); + + // Act + DateTime result = sut.LastActivityTime; + + // Assert + result.Should().BeCloseTo(DateTime.Now, TimeSpan.FromSeconds(1)); + } + + [TestMethod] + public void LastActivityTime_Updates_WhenPropertyChangesToFalse() + { + // Arrange + TestTarget target = new() { IsActive = true }; + TestIdleAdapter sut = new(target); + DateTime before = DateTime.Now.AddSeconds(-1); + + // Act + target.IsActive = false; + DateTime result = sut.LastActivityTime; + + // Assert + result.Should().BeOnOrAfter(before); + } + + [TestMethod] + public void LastActivityTime_Resets_WhenPropertyChangesToTrue() + { + // Arrange + TestTarget target = new() { IsActive = false }; + TestIdleAdapter sut = new(target); + + // Act + target.IsActive = true; + DateTime result = sut.LastActivityTime; + + // Assert + result.Should().BeCloseTo(DateTime.Now, TimeSpan.FromSeconds(1)); + } + + [TestMethod] + public void Dispose_Unsubscribes_PropertyChanged() + { + // Arrange + TestTarget target = new() { IsActive = false }; + TestIdleAdapter sut = new(target); + + // Act + sut.Dispose(); + + // Assert + target.IsActive = true; + // Should not throw or update after dispose + sut.LastActivityTime.Should().Be(DateTime.MinValue); + } + + [TestMethod] + public void Throws_OnNullTarget() + { + // Arrange + Action act = () => _ = new TestIdleAdapter(null!); + + // Act & Assert + act.Should().Throw(); + } + + [TestMethod] + public void Throws_OnNullProperty() + { + // Arrange + TestTarget target = new(); + Action act = () => _ = new BrokenIdleAdapter(target); + + // Act & Assert + act.Should().Throw(); + } + + // Helpers + internal class TestTarget : MvvmObject + { + internal static readonly MvvmProperty IsActiveProperty = new(nameof(IsActive)); + internal bool IsActive + { + get => GetValue(IsActiveProperty); + set => SetValue(IsActiveProperty, value); + } + } + + private sealed class TestIdleAdapter : MvvmPropertyActivityDetector + { + public TestIdleAdapter(TestTarget target) + : base(target, TestTarget.IsActiveProperty) { } + } + + private sealed class BrokenIdleAdapter : MvvmPropertyActivityDetector + { + public BrokenIdleAdapter(TestTarget target) + : base(target, null!) { } + } +} diff --git a/test/AdaptiveRemote.App.Tests/Services/ProgrammaticSettings/PersistSettingsTests.cs b/test/AdaptiveRemote.App.Tests/Services/ProgrammaticSettings/PersistSettingsTests.cs index 98fc4ba0..1d0f9698 100644 --- a/test/AdaptiveRemote.App.Tests/Services/ProgrammaticSettings/PersistSettingsTests.cs +++ b/test/AdaptiveRemote.App.Tests/Services/ProgrammaticSettings/PersistSettingsTests.cs @@ -5,7 +5,9 @@ namespace AdaptiveRemote.Services.ProgrammaticSettings; [TestClass] public class PersistSettingsTests { - private static readonly string InputSettingsPath = Path.Combine("%LocalAppData%", "path", "to", "settings.ini"); + // Use a test-specific environment variable to ensure cross-platform compatibility + private const string TestEnvVarName = "ADAPTIVEREMOTE_TEST_SETTINGS_PATH"; + private static readonly string InputSettingsPath = Path.Combine($"%{TestEnvVarName}%", "path", "to", "settings.ini"); private static readonly string ResolvedSettingsPath = Environment.ExpandEnvironmentVariables(InputSettingsPath); private readonly MockLogger MockLogger = new(); @@ -25,6 +27,20 @@ public TestContext? TestContext private static readonly char[] LineSeparators = ['\r', '\n']; + [ClassInitialize] + public static void ClassInitialize(TestContext context) + { + // Set the test-specific environment variable for all tests in this class + Environment.SetEnvironmentVariable(TestEnvVarName, Path.Combine(Path.GetTempPath(), "AdaptiveRemoteTests")); + } + + [ClassCleanup] + public static void ClassCleanup() + { + // Clean up the test-specific environment variable + Environment.SetEnvironmentVariable(TestEnvVarName, null); + } + [TestCleanup] public void VerifyMocks() { diff --git a/test/AdaptiveRemote.App.Tests/TestUtilities/MockFileSystem.cs b/test/AdaptiveRemote.App.Tests/TestUtilities/MockFileSystem.cs index 7ee04e45..bde965d1 100644 --- a/test/AdaptiveRemote.App.Tests/TestUtilities/MockFileSystem.cs +++ b/test/AdaptiveRemote.App.Tests/TestUtilities/MockFileSystem.cs @@ -94,6 +94,7 @@ public void Expect_OpenRead_ForPath(string path) .Verifiable(Times.Once); public void Expect_OpenRead_IsNotCalledForPath(string path) => Setup(x => x.OpenRead(path)) + .Throws(new AssertFailedException($"OpenRead should not be called with path: {path}")) .Verifiable(Times.Never); public void Expect_OpenRead_IsNotCalled() => Setup(x => x.OpenRead(It.IsAny())) diff --git a/test/AdaptiveRemote.App.Tests/TestUtilities/MockLogger.cs b/test/AdaptiveRemote.App.Tests/TestUtilities/MockLogger.cs index da8e1165..167204a7 100644 --- a/test/AdaptiveRemote.App.Tests/TestUtilities/MockLogger.cs +++ b/test/AdaptiveRemote.App.Tests/TestUtilities/MockLogger.cs @@ -31,10 +31,16 @@ exception is AssertInconclusiveException || } string message = $"{logLevel}[{eventId.Id}]: {formatter(state, exception)}"; + if (exception is not null) + { + message += $"\n {exception.GetType().Name}: {exception.Message}"; + } + foreach ((string find, string replace) in ReplaceStrings) { message = message.Replace(find, replace); } + lock (_lock) { _messages.Add(message); @@ -42,15 +48,8 @@ exception is AssertInconclusiveException || OutputWriter?.WriteLine(message); } - public void VerifyMessages(Action expected) - { - MockLogger expectedLog = new(); - expectedLog.ReplaceStrings.AddRange(ReplaceStrings); - MessageLogger messageLogger = new(expectedLog); - expected(messageLogger); - - VerifyMessages(expectedLog._messages.ToArray()); - } + public void VerifyMessages(Action expected) + => VerifyMessages(GetMessageLogMessages(expected)); public void VerifyMessages(params string[] expected) { @@ -102,6 +101,16 @@ public void VerifyMessages(params string[] expected) } } + private string[] GetMessageLogMessages(Action expected) + { + MockLogger expectedLog = new(); + expectedLog.ReplaceStrings.AddRange(ReplaceStrings); + MessageLogger messageLogger = new(expectedLog); + expected(messageLogger); + + return expectedLog._messages.ToArray(); + } + private static List GetRemaining(IEnumerator iter, ref int count) { List remaining = new(); @@ -161,6 +170,19 @@ internal async Task WaitForMessageAsync(string expectedMessage, TimeSpan timeout } } + internal int CountMessages(Action messages) + => CountMessages(GetMessageLogMessages(messages)); + + public int CountMessages(params string[] messages) + { + int count = 0; + foreach (string expected in messages) + { + count += _messages.Count(m => m.StartsWith(expected)); + } + return count; + } + internal void ClearMessages() { _messages.Clear(); diff --git a/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/CloudLayoutUpdate.feature b/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/CloudLayoutUpdate.feature new file mode 100644 index 00000000..898bc4d7 --- /dev/null +++ b/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/CloudLayoutUpdate.feature @@ -0,0 +1,118 @@ +Feature: Cloud layout update + The application loads the remote control layout from a cloud asset (compiled layout service). + The layout is cached locally. On startup, the cache is checked first; if present + the app becomes Ready immediately while a background refresh runs. If the server + returns a different layout after a scope recycle, the UI updates. If the compiled + layout service changes while the app is running, the layout reloads and an idle-deferred recycle occurs. + +@cloud-layout +Scenario: App starts successfully from compiled layout service when cache is empty + Given the application is not running + And the local layout cache is empty + And the compiled layout service serves the primary layout + When I start the application + Then I should see the application in the Ready phase + And I should see a message in the logs: + """ + Downloading asset 'layout' + """ + And I should not see the Guide button + And I should not see any error messages in the logs + +@cloud-layout +Scenario: App starts successfully from local cache when cache is present + Given the application is not running + And the local layout cache contains the primary layout + And the compiled layout service serves the primary layout + When I start the application + Then I should see the application in the Ready phase + And I should see a message in the logs: + """ + Loaded asset 'layout' from cache + """ + And I should see a message in the logs: + """ + Asset 'layout' is up to date + """ + And I should not see the Guide button + And I should not see any error messages in the logs + +@cloud-layout +Scenario: App shows updated layout after background refresh detects server change + Given the application is not running + And the local layout cache contains the primary layout + And the compiled layout service serves the updated layout + And the idle cooldown is 2 seconds + When I start the application + Then I should see the application in the Ready phase + And I should see a message in the logs: + """ + Loaded asset 'layout' from cache + """ + And I should see a message in the logs: + """ + Asset 'layout' updated from server; scheduling recycle + """ + And I should see the application recycle + And I should see the "Guide" button exists + And I should not see any error messages in the logs + +@cloud-layout +Scenario: App shows no update when cached layout matches server layout + Given the application is not running + And the local layout cache contains the primary layout + And the compiled layout service serves the primary layout + When I start the application + Then I should see the application in the Ready phase + And I should see a message in the logs: + """ + Asset 'layout' is up to date + """ + And I should not see a message containing 'recycling' in the logs + And I should not see the Guide button + And I should not see any error messages in the logs + +@cloud-layout +Scenario: Layout updates after compiled layout service changes while app is running + Given the application is not running + And the local layout cache contains the primary layout + And the compiled layout service serves the primary layout + When I start the application + Then I should see the application in the Ready phase + And I should not see the Guide button + When the compiled layout service is updated to the updated layout + Then I should see a message in the logs: + """ + Layout service reported a change; re-downloading asset 'layout' + """ + And I should see the application recycle + And I should see the application in the Ready phase + And I should see the "Guide" button exists + And I should not see any error messages in the logs + +@cloud-layout +Scenario: App enters fatal error state when cache is empty and compiled layout service is unavailable + Given the application is not running + And the local layout cache is empty + And the compiled layout service is unavailable + When I start the application + Then I should see a fatal startup error message + +@cloud-layout +Scenario: App continues with cached layout when background server download fails + Given the application is not running + And the local layout cache contains the primary layout + And the compiled layout service is unavailable + When I start the application + Then I should see the application in the Ready phase + And I should see a message in the logs: + """ + Loaded asset 'layout' from cache + """ + And I should see a message in the logs: + """ + Failed to download latest 'layout' from server; keeping cached version + """ + And I should not see the Guide button + And I should not see any error messages in the logs + diff --git a/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/ConversationModalUI.feature b/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/ConversationModalUI.feature index 72b56596..ad15bc86 100644 --- a/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/ConversationModalUI.feature +++ b/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/ConversationModalUI.feature @@ -5,6 +5,7 @@ Feature: Conversation Modal UI Scenario: Speech synthesis displays modal message box Given the application is in the Ready phase + Then I should not see a modal message When I say "Hey Remote" Then I should see a modal message containing """ diff --git a/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/LayoutButtons.feature b/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/LayoutButtons.feature new file mode 100644 index 00000000..34480200 --- /dev/null +++ b/test/AdaptiveRemote.EndToEndTests.Host.Wpf/Features/Shared/LayoutButtons.feature @@ -0,0 +1,41 @@ +Feature: Layout button verification + As a user + I want all expected buttons from the layout to be present and accessible + So that I can control my TV and AV equipment + +Scenario: Layout CSS rules are present + Given the application is in the Ready phase + Then the stylesheet selector '#ROOT' should define 'display' as 'grid' + And the stylesheet selector '#ROOT' should define 'grid-template-rows' as '6fr 3fr 1fr' + And the stylesheet selector '#ROOT' should define 'grid-template-columns' as '3fr 2fr' + And the stylesheet selector '#ROOT' should define 'grid-gap' as '20px' + +Scenario: All expected buttons from layout are present + Given the application is in the Ready phase + # DPAD group + Then I should see the 'Up' button exists + And I should see the 'Down' button exists + And I should see the 'Left' button exists + And I should see the 'Right' button exists + And I should see the 'Select' button exists + And I should see the 'Back' button exists + And I should see the 'Power' button exists + # WELL group + And I should see the 'TiVo' button exists + And I should see the 'Netflix' button exists + And I should see the 'Info' button exists + # PLAYBACK group + And I should see the 'Play' button exists + And I should see the 'Pause' button exists + And I should see the 'Record' button exists + And I should see the 'Skip' button exists + And I should see the 'Replay' button exists + # CHANNELANDVOLUME group + And I should see the 'Channel Up' button exists + And I should see the 'Channel Down' button exists + And I should see the 'Mute' button exists + And I should see the 'Volume Up' button exists + And I should see the 'Volume Down' button exists + # GUTTER group + And I should see the 'Learn' button exists + And I should see the 'Exit' button exists diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/CloudLayoutSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/CloudLayoutSteps.cs new file mode 100644 index 00000000..c00ff86a --- /dev/null +++ b/test/AdaptiveRemote.EndToEndTests.Steps/CloudLayoutSteps.cs @@ -0,0 +1,110 @@ +using AdaptiveRemote.EndtoEndTests; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Reqnroll; + +namespace AdaptiveRemote.EndToEndTests.Steps; + +[Binding] +public class CloudLayoutSteps : StepsBase +{ + private static readonly string PrimaryLayoutPath = Path.Combine( + Path.GetDirectoryName(typeof(CloudLayoutSteps).Assembly.Location)!, + "Layout", "primary-layout.json"); + + private static readonly string UpdatedLayoutPath = Path.Combine( + Path.GetDirectoryName(typeof(CloudLayoutSteps).Assembly.Location)!, + "Layout", "updated-layout.json"); + + private string CachePath => Environment.CloudCachePath + ?? throw new InvalidOperationException("CloudCachePath is not configured."); + + private string StubFilePath => Environment.CloudStubFilePath + ?? throw new InvalidOperationException("CloudStubFilePath is not configured."); + + [Given(@"the local layout cache is empty")] + public void GivenTheLocalLayoutCacheIsEmpty() + { + if (Directory.Exists(CachePath)) + { + foreach (string file in Directory.GetFiles(CachePath)) + { + File.Delete(file); + } + } + } + + [Given(@"the local layout cache contains the primary layout")] + public void GivenTheLocalLayoutCacheContainsThePrimaryLayout() + { + WriteToCacheFile(PrimaryLayoutPath); + } + + [Given(@"the local layout cache contains the updated layout")] + public void GivenTheLocalLayoutCacheContainsTheUpdatedLayout() + { + WriteToCacheFile(UpdatedLayoutPath); + } + + [Given(@"the compiled layout service serves the primary layout")] + public void GivenTheCompiledLayoutServiceServesThePrimaryLayout() + { + File.Copy(PrimaryLayoutPath, StubFilePath, overwrite: true); + } + + [Given(@"the compiled layout service serves the updated layout")] + public void GivenTheCompiledLayoutServiceServesTheUpdatedLayout() + { + File.Copy(UpdatedLayoutPath, StubFilePath, overwrite: true); + } + + [Given(@"the compiled layout service is unavailable")] + public void GivenTheCompiledLayoutServiceIsUnavailable() + { + if (File.Exists(StubFilePath)) + { + File.Delete(StubFilePath); + } + } + + [Given(@"the idle cooldown is (\d+) seconds?")] + public void GivenTheIdleCooldownIsSeconds(int seconds) + { + Environment.SetIdleCooldownSeconds(seconds); + } + + [When(@"the compiled layout service is updated to the updated layout")] + public void WhenTheCompiledLayoutServiceIsUpdatedToTheUpdatedLayout() + { + File.Copy(UpdatedLayoutPath, StubFilePath, overwrite: true); + } + + [Then(@"I should not see the Guide button")] + public void ThenIShouldNotSeeTheGuideButton() + { + bool exists = Host.UI.WaitForButtonExists("Guide", TimeSpan.FromSeconds(2)); + Assert.IsFalse(exists, "Guide button should not be visible in the primary layout."); + } + + [Then(@"I should see a fatal startup error message")] + public void ThenIShouldSeeAFatalStartupErrorMessage() + { + Host.Application.WaitForPhase(LifecyclePhase.FatalError, timeout: TimeSpan.FromSeconds(60)); + } + + // Stop the host after cloud-layout scenarios so fatal-error or incomplete states don't leak + // into subsequent tests that reuse the running host. + [AfterScenario("cloud-layout")] + public void AfterScenario_StopHostAfterCloudLayoutTest() + { + Environment.StopHostIfRunning(); + } + + private void WriteToCacheFile(string sourceFixturePath) + { + Directory.CreateDirectory(CachePath); + string cacheFile = Path.Combine(CachePath, "layout.cache"); + File.Copy(sourceFixturePath, cacheFile, overwrite: true); + } +} + diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/Hooks/EnvironmentSetupHooks.cs b/test/AdaptiveRemote.EndToEndTests.Steps/Hooks/EnvironmentSetupHooks.cs index 97a118d9..3319a919 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/Hooks/EnvironmentSetupHooks.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/Hooks/EnvironmentSetupHooks.cs @@ -4,6 +4,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using Reqnroll; using Reqnroll.BoDi; +using System.Reflection; namespace AdaptiveRemote.EndToEndTests.Steps.Hooks; @@ -18,7 +19,21 @@ public static void OnBeforeTestRun_SetUpEnvironment(IObjectContainer container, { _lazyLogger = new Lazy(() => loggerFactory.CreateLogger()); - container.RegisterInstanceAs(_startedEnvironment ??= container.Resolve()); + _startedEnvironment ??= container.Resolve(); + container.RegisterInstanceAs(_startedEnvironment); + + // Configure cloud asset paths once for the entire test run. + string assemblyDir = Path.GetDirectoryName(typeof(EnvironmentSetupHooks).Assembly.Location)!; + string testRunTempDir = Path.Combine(assemblyDir, "cloud-test-run"); + string cachePath = Path.Combine(testRunTempDir, "cache"); + string stubFilePath = Path.Combine(testRunTempDir, "stub.json"); + + Directory.CreateDirectory(testRunTempDir); + // Write the full layout so non-cloud scenarios see the complete button set. + string updatedLayout = File.ReadAllText(Path.Combine(assemblyDir, "Layout", "updated-layout.json")); + File.WriteAllText(stubFilePath, updatedLayout); + + _startedEnvironment.SetCloudAssetPaths(cachePath, stubFilePath); } [AfterTestRun] diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/LogVerificationSteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/LogVerificationSteps.cs index c3eaab79..07998dd1 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/LogVerificationSteps.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/LogVerificationSteps.cs @@ -11,6 +11,7 @@ public class LogVerificationSteps : StepsBase private static readonly Dictionary _lastLineRead = new(); [Then("I should not see any warning or error messages in the logs")] + [Then("I should not see any warnings or errors in the logs")] public void ThenIShouldNotSeeAnyWarningsOrErrorsInTheLogFile() { IEnumerable warningAndErrorLines = FilterLogLines(IsWarningOrError); @@ -52,6 +53,64 @@ public void ThenIShouldSeeAnErrorInTheLogs(string expectedErrorMessage) "Host log error message does not match the expected text"); } + [Then("I should see a message in the logs:")] + public void ThenIShouldSeeAMessageInTheLogs(string expectedMessage) + { + string? matchingLine = null; + + WaitHelpers.ExecuteWithRetries(() => + { + Assert.IsNotNull(Environment.HostLogs, "Host log path was not set."); + + using Stream logStream = File.Open(Environment.HostLogs, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using StreamReader reader = new(logStream); + string logContent = reader.ReadToEnd(); + string[] logLines = logContent.Split(System.Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + matchingLine = logLines.FirstOrDefault(l => l.Contains(expectedMessage, StringComparison.Ordinal)); + return matchingLine is not null; + }); + + Assert.IsNotNull(matchingLine, "Host log does not contain the expected message: {0}", expectedMessage); + } + + [Then(@"I should not see a message containing '(.*)' in the logs")] + public void ThenIShouldNotSeeAMessageContainingInTheLogs(string fragment) + { + Assert.IsNotNull(Environment.HostLogs, "Host log path was not set."); + + string logContent; + using (Stream logStream = File.Open(Environment.HostLogs, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + { + logContent = new StreamReader(logStream).ReadToEnd(); + } + + string[] logLines = logContent.Split(System.Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + IEnumerable matching = logLines.Where(l => l.Contains(fragment, StringComparison.OrdinalIgnoreCase)); + Assert.IsFalse( + matching.Any(), + "Host log unexpectedly contains '{0}':\n{1}", + fragment, + string.Join("\n", matching)); + } + + [Then(@"I should see the application recycle")] + public void ThenIShouldSeeTheApplicationRecycle() + { + Assert.IsNotNull(Environment.HostLogs, "Host log path was not set."); + + string? matchingLine = null; + WaitHelpers.ExecuteWithRetries(() => + { + using Stream logStream = File.Open(Environment.HostLogs, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + string logContent = new StreamReader(logStream).ReadToEnd(); + string[] logLines = logContent.Split(System.Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + matchingLine = logLines.FirstOrDefault(l => l.Contains("Recycling application scope", StringComparison.Ordinal)); + return matchingLine is not null; + }); + + Assert.IsNotNull(matchingLine, "Host log does not contain 'Recycling application scope'."); + } + private IEnumerable FilterLogLines(Func lineFilter) { Assert.IsNotNull(Environment.HostLogs, "Host log path was not set."); @@ -97,3 +156,4 @@ private static bool IsWarningOrError(string line) || line.Contains("] Warning [", StringComparison.Ordinal); } } + diff --git a/test/AdaptiveRemote.EndToEndTests.Steps/UISteps.cs b/test/AdaptiveRemote.EndToEndTests.Steps/UISteps.cs index f94acc3b..6d22fe60 100644 --- a/test/AdaptiveRemote.EndToEndTests.Steps/UISteps.cs +++ b/test/AdaptiveRemote.EndToEndTests.Steps/UISteps.cs @@ -16,13 +16,19 @@ public void WhenIClickOnTheButton(string buttonLabel) [Then(@"I should see the {string} button is enabled")] public void ThenIShouldSeeTheButtonIsEnabled(string buttonLabel) { - Assert.IsTrue(Host.UI.WaitForButtonEnabled(buttonLabel, enabled: true), "Button {0} was not enabled", buttonLabel); + Assert.IsTrue(Host.UI.WaitForButtonEnabled(buttonLabel, enabled: true), "Button '{0}' was not enabled", buttonLabel); } [Then(@"I should see the {string} button is disabled")] public void ThenIShouldSeeTheButtonIsDisabled(string buttonLabel) { - Assert.IsTrue(Host.UI.WaitForButtonEnabled(buttonLabel, enabled: false), "Button {0} was not disabled", buttonLabel); + Assert.IsTrue(Host.UI.WaitForButtonEnabled(buttonLabel, enabled: false), "Button '{0}' was not disabled", buttonLabel); + } + + [Then(@"I should see the {string} button exists")] + public void ThenIShouldSeeTheButtonExists(string buttonLabel) + { + Assert.IsTrue(Host.UI.WaitForButtonExists(buttonLabel), "Button '{0}' did not exist", buttonLabel); } [Then(@"I should see the {string} button is programmed")] @@ -60,4 +66,18 @@ public void ThenIShouldNotSeeAModalMessage() { Host.UI.WaitForNoModalMessage(); } + + [Then(@"the stylesheet selector {string} should define {string} as {string}")] + public void ThenTheStylesheetSelectorShouldDefineAs(string selector, string propertyName, string expectedValue) + { + string? actualValue = Host.UI.GetStylesheetRulePropertyValue(selector, propertyName); + Assert.AreEqual( + expectedValue, + actualValue, + "Expected selector '{0}' to define '{1}' as '{2}', but was '{3}'.", + selector, + propertyName, + expectedValue, + actualValue ?? ""); + } } diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/AdaptiveRemote.EndtoEndTests.TestServices.csproj b/test/AdaptiveRemote.EndtoEndTests.TestServices/AdaptiveRemote.EndtoEndTests.TestServices.csproj index a5241338..849d25d4 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/AdaptiveRemote.EndtoEndTests.TestServices.csproj +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/AdaptiveRemote.EndtoEndTests.TestServices.csproj @@ -7,6 +7,12 @@ AdaptiveRemote.EndtoEndTests + + + PreserveNewest + + + diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/ApplicationTestService.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/ApplicationTestService.cs index 2fdb8b12..8a9afcd5 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/ApplicationTestService.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/ApplicationTestService.cs @@ -1,5 +1,6 @@ using AdaptiveRemote.Models; using AdaptiveRemote.Services.Testing; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; namespace AdaptiveRemote.EndtoEndTests; @@ -11,16 +12,21 @@ namespace AdaptiveRemote.EndtoEndTests; /// public class ApplicationTestService : IApplicationTestService { - private readonly Services.IRemoteDefinitionService _remoteDefinitionService; + private readonly IServiceProvider _serviceProvider; private readonly LifecycleView _lifecycleView; private readonly IHostApplicationLifetime _applicationLifetime; + // Resolved lazily so that GetCurrentPhaseAsync works in FatalError state, + // where the layout asset is absent and IRemoteDefinitionService cannot be built. + private Services.IRemoteDefinitionService RemoteDefinitionService + => _serviceProvider.GetRequiredService(); + public ApplicationTestService( - Services.IRemoteDefinitionService remoteDefinitionService, + IServiceProvider serviceProvider, LifecycleView lifecycleView, IHostApplicationLifetime applicationLifetime) { - _remoteDefinitionService = remoteDefinitionService; + _serviceProvider = serviceProvider; _lifecycleView = lifecycleView; _applicationLifetime = applicationLifetime; } @@ -28,7 +34,7 @@ public ApplicationTestService( public async Task InvokeCommandAsync(string commandName, CancellationToken cancellationToken) { // Find the Exit command by walking the remote tree - Command command = FindCommandByName(_remoteDefinitionService.RemoteRoot, commandName) + Command command = FindCommandByName(RemoteDefinitionService.RemoteRoot, commandName) ?? throw new InvalidOperationException($"{commandName} command not found in remote definition service"); if (command.ExecuteAsync is null) @@ -51,7 +57,7 @@ public async Task StopApplicationAsync(CancellationToken cancellationToken) public Task GetIsListeningAsync(CancellationToken cancellationToken) { // Find the ConversationView by walking the remote tree - ConversationView? conversationView = FindConversationView(_remoteDefinitionService.RemoteRoot) + ConversationView? conversationView = FindConversationView(RemoteDefinitionService.RemoteRoot) ?? throw new InvalidOperationException("ConversationView not found in remote definition service"); // Use GetValue to access the internal property diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/AdaptiveRemoteHost.Builder.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/AdaptiveRemoteHost.Builder.cs index 99409c9f..2890f018 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/AdaptiveRemoteHost.Builder.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/AdaptiveRemoteHost.Builder.cs @@ -13,6 +13,9 @@ public partial class AdaptiveRemoteHost { public class Builder { + private const string ResourceInUseExceptionMessage = "The requested resource is in use."; + private const int StartupRetryCount = 3; + private AdaptiveRemoteHostSettings _settings; private readonly ILoggerFactory _loggerFactory; private readonly HostApplicationLoggerProvider _hostLoggerProvider; @@ -48,7 +51,35 @@ public AdaptiveRemoteHost Start(Func retryLogger = _loggerFactory.CreateLogger(); + + // Only HostStartupException instances whose CapturedOutput contains ResourceInUseExceptionMessage + // are retried. Any other exception (e.g. Inconclusive, process-launch failure) propagates immediately. + for (int attempt = 1; attempt <= StartupRetryCount; attempt++) + { + try + { + return StartWithSettings(effectiveSettings); + } + catch (HostStartupException ex) when (ex.CapturedOutput.Contains(ResourceInUseExceptionMessage, StringComparison.OrdinalIgnoreCase)) + { + if (attempt == StartupRetryCount) + { + throw; + } + + retryLogger.LogWarning( + "Host startup attempt {Attempt}/{MaxAttempts} failed with '{ExceptionMessage}'. Retrying...", + attempt, StartupRetryCount, ResourceInUseExceptionMessage); + Thread.Sleep(Random.Shared.Next(1000, 3000)); + } + } + + // Unreachable: the loop always exits via return (success) or throw (final-attempt failure + // re-throws via the catch block, or a non-retryable exception propagates immediately). + // Required by the compiler since it cannot prove the loop body always returns or throws. + throw new InvalidOperationException("Unexpected state: startup retry loop exited without returning or throwing."); } private AdaptiveRemoteHost StartWithSettings(AdaptiveRemoteHostSettings _settings) @@ -268,7 +299,7 @@ Failed to connect to the test control endpoint on port {ControlPort} within {Sta logger.LogError(processException, "Failed to kill the host process. {ErrorMessage}", processException.Message); } - throw; + throw new HostStartupException(ex.Message, ex, standardOutputAndError.ToString()); } } @@ -280,6 +311,16 @@ private static int GetAvailablePort() listener.Stop(); return port; } + + private sealed class HostStartupException(string message, Exception innerException, string capturedOutput) + : Exception(message, innerException) + { + /// + /// The captured stdout/stderr from the host process at the time of the startup failure. + /// Used by to identify retryable crashes (e.g., WebView2 resource-in-use). + /// + internal string CapturedOutput { get; } = capturedOutput; + } } } diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/ISimulatedEnvironment.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/ISimulatedEnvironment.cs index 4a216938..55f25d0c 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/ISimulatedEnvironment.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/ISimulatedEnvironment.cs @@ -34,4 +34,20 @@ public interface ISimulatedEnvironment : IDisposable /// Commands not present in this dictionary are not programmed and should be disabled. /// IReadOnlyDictionary TestIrPayloads { get; } + + /// + /// Gets the cloud asset cache directory path configured for the current test run, or null if not configured. + /// + string? CloudCachePath { get; } + + /// + /// Gets the stub layout file path configured for the current test run, or null if not configured. + /// + string? CloudStubFilePath { get; } + + /// + /// Overrides the idle cooldown for the next host start. + /// Appends a command-line arg that supersedes the default configured in SetCloudAssetPaths. + /// + void SetIdleCooldownSeconds(int seconds); } diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/SimulatedEnvironment.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/SimulatedEnvironment.cs index 68d170a4..b6597130 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/SimulatedEnvironment.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/Host/SimulatedEnvironment.cs @@ -31,6 +31,8 @@ public sealed class SimulatedEnvironment : ISimulatedEnvironment private string? _currentLogLocation; // Settings file path is determined lazily from the TestResults directory when SetLogLocation is first called. private string? _testSettingsPath; + private string? _cloudCachePath; + private string? _cloudStubFilePath; public SimulatedEnvironment(SimulatedTiVoDeviceBuilder tivoBuilder, SimulatedBroadlinkDeviceBuilder broadlinkBuilder, AdaptiveRemoteHost.Builder hostBuilder) { @@ -68,6 +70,12 @@ public SimulatedEnvironment(SimulatedTiVoDeviceBuilder tivoBuilder, SimulatedBro /// public IReadOnlyDictionary TestIrPayloads => _testIrPayloads; + /// + public string? CloudCachePath => _cloudCachePath; + + /// + public string? CloudStubFilePath => _cloudStubFilePath; + public AdaptiveRemoteHost Host { get @@ -146,6 +154,27 @@ public void StopHostIfRunning() } } + public void SetCloudAssetPaths(string cachePath, string stubFilePath) + { + _cloudCachePath = cachePath; + _cloudStubFilePath = stubFilePath; + + _hostBuilder.ConfigureSettings(s => s.AddCommandLineArgs( + $"--cloud:CachePath=\"{cachePath}\" --cloud:StubFilePath=\"{stubFilePath}\" --cloud:IdleCooldownSeconds=0")); + } + + public void SetIdleCooldownSeconds(int seconds) + { + if (seconds < 0) + { + throw new ArgumentOutOfRangeException(nameof(seconds), seconds, "Idle cooldown must be non-negative."); + } + + // Appends the arg; the configuration system uses last-wins for duplicate keys, + // so this overrides the value set in SetCloudAssetPaths. + _hostBuilder.ConfigureSettings(s => s.AddCommandLineArgs($"--cloud:IdleCooldownSeconds={seconds}")); + } + public void SetLogLocation(string logLocation) { // Ensure settings file is created (adds --programmatic arg) before adding log arg diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/IApplicationTestServiceExtensions.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/IApplicationTestServiceExtensions.cs index 9d3e5a2f..802367c9 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/IApplicationTestServiceExtensions.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/IApplicationTestServiceExtensions.cs @@ -11,7 +11,7 @@ public static void WaitForPhase(this IApplicationTestService testService, Lifecy bool result = WaitHelpers.ExecuteWithRetries(() => { currentPhase = testService.GetCurrentPhase(); - return currentPhase >= expectedPhase; + return currentPhase == expectedPhase || currentPhase == LifecyclePhase.FatalError; }, timeout); currentPhase.Should().Be(expectedPhase, diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs index e3565d4d..25e43f4f 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/IUITestServiceExtensions.cs @@ -61,6 +61,32 @@ public static bool WaitForButtonEnabled(this IUITestService service, string labe return WaitHelpers.WaitForState(button.IsEnabledAsync, enabled, timeout); } + /// + /// Waits for a button with the specified label to exist. + /// + /// The UI test service. + /// The exact visible text of the button (case-sensitive, trimmed). + /// Optional timeout for the operation. + /// True if the button exists within the timeout, false otherwise. + public static bool WaitForButtonExists(this IUITestService service, string label, int timeoutInSeconds = DefaultUITimeoutInSeconds) + => service.WaitForButtonExists(label, TimeSpan.FromSeconds(timeoutInSeconds)); + + /// + /// Waits for a button with the specified label to exist. + /// + /// The UI test service. + /// The exact visible text of the button (case-sensitive, trimmed). + /// Timeout for the operation. + /// True if the button exists within the timeout, false otherwise. + public static bool WaitForButtonExists(this IUITestService service, string label, TimeSpan timeout) + { + return WaitHelpers.ExecuteWithRetries(async ct => + { + using IUIButtonTestObject? button = await service.FindButtonByLabelAsync(label, ct); + return button is not null; + }, timeout); + } + /// /// Clicks a button with the specified label in the UI (synchronous wrapper). /// @@ -270,6 +296,28 @@ public static bool WaitForButtonProgrammed(this IUITestService service, string l return WaitHelpers.WaitForState(button.IsProgrammedAsync, programmed, timeout); } + /// + /// Gets a stylesheet rule property value using default timeout (synchronous wrapper; blocks the calling thread). + /// + /// The UI test service. + /// The exact CSS selector text to match. + /// The CSS property name to read. + /// Optional timeout for the operation. + /// The property value if found; otherwise null. + public static string? GetStylesheetRulePropertyValue(this IUITestService service, string selector, string propertyName, int timeoutInSeconds = DefaultUITimeoutInSeconds) + => service.GetStylesheetRulePropertyValue(selector, propertyName, TimeSpan.FromSeconds(timeoutInSeconds)); + + /// + /// Gets a stylesheet rule property value using explicit timeout (synchronous wrapper; blocks the calling thread). + /// + /// The UI test service. + /// The exact CSS selector text to match. + /// The CSS property name to read. + /// Timeout for the operation. + /// The property value if found; otherwise null. + public static string? GetStylesheetRulePropertyValue(this IUITestService service, string selector, string propertyName, TimeSpan timeout) + => WaitHelpers.WaitForAsyncTask(ct => service.GetStylesheetRulePropertyValueAsync(selector, propertyName, ct), timeout); + private static IUIButtonTestObject GetButtonByLabel(this IUITestService service, string label, TimeSpan timeout) { IUIButtonTestObject? button = null; diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/Layout/primary-layout.json b/test/AdaptiveRemote.EndtoEndTests.TestServices/Layout/primary-layout.json new file mode 100644 index 00000000..95a9e8a6 --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/Layout/primary-layout.json @@ -0,0 +1,57 @@ +{ + "id": "00000000-0000-0000-0000-000000000001", + "rawLayoutId": "00000000-0000-0000-0000-000000000001", + "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": "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": "#ROOT #DPAD { grid-row: 1; grid-column: 1; display: grid; grid-template-rows: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr); grid-gap: 10px; } #ROOT #DPAD #UP { grid-row: 1; grid-column: 2; } #ROOT #DPAD #DOWN { grid-row: 3; grid-column: 2; } #ROOT #DPAD #LEFT { grid-row: 2; grid-column: 1; } #ROOT #DPAD #RIGHT { grid-row: 2; grid-column: 3; } #ROOT #DPAD #SELECT { grid-row: 2; grid-column: 2; } #ROOT #DPAD #BACK { grid-row: 3; grid-column: 1; } #ROOT #DPAD #POWER { grid-row: 1; grid-column: 1; } #ROOT #DPAD #POWERON { display: none; } #ROOT #DPAD #POWEROFF { display: none; } #ROOT #WELL { grid-row: 1; grid-column: 2; } #ROOT #PLAYBACK { grid-row: 2; grid-column: 1; display: grid; grid-template-rows: repeat(3, 1fr); grid-template-columns: repeat(5, 1fr); grid-gap: 10px; } #ROOT #PLAYBACK #REPLAY { grid-row: 1; grid-column: 1; } #ROOT #PLAYBACK #PLAY { grid-row: 1; grid-column: 2; } #ROOT #PLAYBACK #PAUSE { grid-row: 1; grid-column: 3; } #ROOT #PLAYBACK #RECORD { grid-row: 1; grid-column: 4; } #ROOT #PLAYBACK #SKIP { grid-row: 1; grid-column: 5; } #ROOT #CHANNELANDVOLUME { grid-row: 2; grid-column: 2; display: grid; grid-template-rows: 36pt 1fr 1fr; grid-template-columns: 2fr 1fr 2fr; grid-gap: 10px; } #ROOT #CHANNELANDVOLUME #CHANNELUP { grid-row: 2; grid-column: 1; } #ROOT #CHANNELANDVOLUME #CHANNELDOWN { grid-row: 3; grid-column: 1; } #ROOT #CHANNELANDVOLUME #VOLUMEUP { grid-row: 2; grid-column: 3; } #ROOT #CHANNELANDVOLUME #VOLUMEDOWN { grid-row: 3; grid-column: 3; } #ROOT #CHANNELANDVOLUME #MUTE { grid-row: 2; grid-column: 2; grid-row-end: span 2; }", + "compiledAt": "2026-04-19T00:00:00+00:00" +} diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/Layout/updated-layout.json b/test/AdaptiveRemote.EndtoEndTests.TestServices/Layout/updated-layout.json new file mode 100644 index 00000000..ef7f2eb7 --- /dev/null +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/Layout/updated-layout.json @@ -0,0 +1,58 @@ +{ + "id": "00000000-0000-0000-0000-000000000002", + "rawLayoutId": "00000000-0000-0000-0000-000000000002", + "userId": "stub", + "isActive": true, + "version": 2, + "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": "#ROOT #DPAD { grid-row: 1; grid-column: 1; display: grid; grid-template-rows: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr); grid-gap: 10px; } #ROOT #DPAD #UP { grid-row: 1; grid-column: 2; } #ROOT #DPAD #DOWN { grid-row: 3; grid-column: 2; } #ROOT #DPAD #LEFT { grid-row: 2; grid-column: 1; } #ROOT #DPAD #RIGHT { grid-row: 2; grid-column: 3; } #ROOT #DPAD #SELECT { grid-row: 2; grid-column: 2; } #ROOT #DPAD #BACK { grid-row: 3; grid-column: 1; } #ROOT #DPAD #POWER { grid-row: 1; grid-column: 1; } #ROOT #DPAD #POWERON { display: none; } #ROOT #DPAD #POWEROFF { display: none; } #ROOT #WELL { grid-row: 1; grid-column: 2; } #ROOT #PLAYBACK { grid-row: 2; grid-column: 1; display: grid; grid-template-rows: repeat(3, 1fr); grid-template-columns: repeat(5, 1fr); grid-gap: 10px; } #ROOT #PLAYBACK #REPLAY { grid-row: 1; grid-column: 1; } #ROOT #PLAYBACK #PLAY { grid-row: 1; grid-column: 2; } #ROOT #PLAYBACK #PAUSE { grid-row: 1; grid-column: 3; } #ROOT #PLAYBACK #RECORD { grid-row: 1; grid-column: 4; } #ROOT #PLAYBACK #SKIP { grid-row: 1; grid-column: 5; } #ROOT #CHANNELANDVOLUME { grid-row: 2; grid-column: 2; display: grid; grid-template-rows: 36pt 1fr 1fr; grid-template-columns: 2fr 1fr 2fr; grid-gap: 10px; } #ROOT #CHANNELANDVOLUME #CHANNELUP { grid-row: 2; grid-column: 1; } #ROOT #CHANNELANDVOLUME #CHANNELDOWN { grid-row: 3; grid-column: 1; } #ROOT #CHANNELANDVOLUME #VOLUMEUP { grid-row: 2; grid-column: 3; } #ROOT #CHANNELANDVOLUME #VOLUMEDOWN { grid-row: 3; grid-column: 3; } #ROOT #CHANNELANDVOLUME #MUTE { grid-row: 2; grid-column: 2; grid-row-end: span 2; } #ROOT #CHANNELANDVOLUME #GUIDE { grid-row: 1; grid-column: 2; }", + "compiledAt": "2026-04-19T00:00:00+00:00" +} diff --git a/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs b/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs index fedf7ea7..fcf6e6e1 100644 --- a/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs +++ b/test/AdaptiveRemote.EndtoEndTests.TestServices/PlaywrightUITestService.cs @@ -80,12 +80,29 @@ private ILocator GetButtonLocatorByLabel(string label) { // Use Playwright's getByRole with exact match - it will throw meaningful errors // if there are no matches or ambiguous matches + + // TODO: Find by aria name instead return label switch { - "Channel Down" => GetButtonLocatorByLabel("Down").Nth(1), - "Channel Up" => GetButtonLocatorByLabel("Up").Nth(1), - "Volume Down" => GetButtonLocatorByLabel("Down").Nth(2), - "Volume Up" => GetButtonLocatorByLabel("Up").Nth(2), + // DPAD buttons (Nth(0) - first occurrence) + "Up" => CurrentPage.GetByRole(AriaRole.Button, new() { Name = "Up", Exact = true }).Nth(0) + .Describe("button with label 'Up' on DPAD"), + "Down" => CurrentPage.GetByRole(AriaRole.Button, new() { Name = "Down", Exact = true }).Nth(0) + .Describe("button with label 'Down' on DPAD"), + "Left" => CurrentPage.GetByRole(AriaRole.Button, new() { Name = "Left", Exact = true }).Nth(0) + .Describe("button with label 'Left' on DPAD"), + "Right" => CurrentPage.GetByRole(AriaRole.Button, new() { Name = "Right", Exact = true }).Nth(0) + .Describe("button with label 'Right' on DPAD"), + // Channel buttons (Nth(1) - second occurrence) + "Channel Down" => CurrentPage.GetByRole(AriaRole.Button, new() { Name = "Down", Exact = true }).Nth(1) + .Describe("button with label 'Down' for Channel"), + "Channel Up" => CurrentPage.GetByRole(AriaRole.Button, new() { Name = "Up", Exact = true }).Nth(1) + .Describe("button with label 'Up' for Channel"), + // Volume buttons (Nth(2) - third occurrence) + "Volume Down" => CurrentPage.GetByRole(AriaRole.Button, new() { Name = "Down", Exact = true }).Nth(2) + .Describe("button with label 'Down' for Volume"), + "Volume Up" => CurrentPage.GetByRole(AriaRole.Button, new() { Name = "Up", Exact = true }).Nth(2) + .Describe("button with label 'Up' for Volume"), _ => CurrentPage.GetByRole(AriaRole.Button, new() { Name = label, Exact = true }) .Describe($"button with label '{label}'") }; @@ -156,6 +173,38 @@ private ILocator GetTextLocator(string text) } } + public async Task GetStylesheetRulePropertyValueAsync( + string selector, + string propertyName, + CancellationToken cancellationToken = default) + { + return await CurrentPage.EvaluateAsync( + @"({ selector, propertyName }) => { + for (const stylesheet of Array.from(document.styleSheets)) { + let rules; + try { + rules = stylesheet.cssRules; + } catch { + continue; + } + + for (const rule of Array.from(rules)) { + if (rule.type !== CSSRule.STYLE_RULE) { + continue; + } + + if (rule.selectorText === selector) { + const value = rule.style.getPropertyValue(propertyName); + return value ? value.trim() : null; + } + } + } + + return null; + }", + new { selector, propertyName }); + } + public void Dispose() { GC.SuppressFinalize(this); diff --git a/test/_doc_EndToEndTests.md b/test/_doc_EndToEndTests.md index 61affeca..10894366 100644 --- a/test/_doc_EndToEndTests.md +++ b/test/_doc_EndToEndTests.md @@ -63,11 +63,19 @@ The three host projects are minimal, sharing most of their functionality from ot **Running Tests:** ```bash +# First-time setup: Build the Headless host so the generated Playwright script exists +dotnet build src/AdaptiveRemote.Headless/AdaptiveRemote.Headless.csproj + +# Install Playwright browsers (required for Headless E2E tests) +pwsh src/AdaptiveRemote.Headless/bin/Debug/net10.0/playwright.ps1 install chromium + +# Run accessibility tests dotnet test test/AdaptiveRemote.EndToEndTests.Host.Headless \ --filter "FullyQualifiedName~AccessibilityCompliance" ``` **Notes:** + - **REQUIRED:** Playwright browsers must be installed before running Headless E2E tests. Without them, tests will fail with JSON-RPC connection errors. - The color contrast accessibility test is only available in the Headless host. It is not available in WPF or Console hosts due to WebView2 limitations (the accessibility checker crashes WebView2). - Headless host is recommended for CI/CD as it requires no graphical environment. - Violations include rule ID, impact level, description, help text, and HTML snippet.