Smoke Checks
Hermes smoke mode lets a packaged desktop app test itself: with HERMES_SMOKE_TEST=1 it starts, renders, runs its checks, prints a verdict and exits with 0 or 1. The Framework builds on it with general checks for its own services, a way to register app checks without referencing Hermes, a switch for turning off side effects during a smoke run, and a ready signal that also works outside smoke runs.
Quick Reference
builder.Services.AddDesktopServices(DesktopHost.Hermes); // connects the Framework to Hermes smoke mode
builder.Services.AddAsyncInitialization();
builder.Services.AddSmokeChecks()
.WithSmokeCheck<WorkspaceStoreCheck>();HERMES_SMOKE_START: MyApp 1.2.0 Windows x64
HERMES_SMOKE_MILESTONE: window-shown 512ms
HERMES_READY:1690.12
HERMES_SMOKE_MILESTONE: first-render 1690ms
HERMES_SMOKE_CHECK_PASS: framework/initialization 0ms
HERMES_SMOKE_CHECK_PASS: framework/storage 1ms
HERMES_SMOKE_CHECK_PASS: framework/plugins 0ms
HERMES_SMOKE_CHECK_PASS: myapp/workspace-store 8ms
HERMES_SMOKE_RESULT: PASSED (4 checks)Registration is cheap and always allowed. Outside a smoke run no check is created, and the order of these calls does not matter.
General Checks
AddSmokeChecks() adds a check for each part of the Framework the app uses:
| Check | Runs when the app registers | Passes when |
|---|---|---|
framework/initialization | AddAsyncInitialization() | Every initialization hook succeeded. Hooks log and swallow their own failures so the app keeps starting; this check names each failed hook and its exception |
framework/storage | A settings storage, such as AddDesktopSettingsStorage | The settings store opens and can be read. The Framework's storages log their own failures instead of throwing, so the check probes them directly. It never writes, so a local smoke run leaves your real settings untouched |
framework/plugins | AddPluginFramework() | Plugin loading the app started at startup completed within 30 seconds. An app that loads no plugins at startup passes |
Theming and layout need no check of their own: Hermes already fails the run if the first render never happens or a component throws.
App Checks
using Mythetech.Framework.Infrastructure.Smoke;
public sealed class WorkspaceStoreCheck(IWorkspaceStore store) : ISmokeCheck
{
public string Name => "myapp/workspace-store";
public async Task RunAsync(CancellationToken cancellationToken)
{
await store.ListRecentAsync(cancellationToken);
}
}- Throw to fail the check
- Checks are resolved from the page's service scope, so they see the same scoped services as the UI
- Each check has 10 seconds by default; override
Timeoutto change it - Prefix names with the app, for example
myapp/..., so the verdict shows which layer failed
Waiting for Startup: ApplicationReady
AsyncInitializationHost publishes ApplicationReady on the message bus once every initialization hook has run, on every launch. Run the host after the first render so that by then the UI is up as well:
@inject IAsyncInitializationHost InitializationHost
@code {
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
await InitializationHost.InitializeAsync();
}
}In a smoke run the checks wait for ApplicationReady, so they test the app after startup, not during it. An app that registers AddAsyncInitialization() but never runs the host fails with timed out waiting for app-ready. Apps without an initialization host, or without a message bus to publish ApplicationReady on, do not wait. Checks start as soon as ApplicationReady is published, so they cannot rely on work its consumers do.
Outside smoke runs, consume ApplicationReady for anything that should happen once the app has started, such as dismissing a splash screen:
public class SplashScreenDismisser(SplashState splash) : IConsumer<ApplicationReady>
{
public Task Consume(ApplicationReady message)
{
splash.Hide();
return Task.CompletedTask;
}
}message.Hooks (and IAsyncInitializationHost.Results) hold each hook's name, order, duration and exception. IsInitialized becomes true once the run has finished, and calling InitializeAsync again waits for a run in progress. A cancelled run records the hooks it skipped as cancelled.
An app with its own startup sequence instead of IAsyncInitializationHost can publish ApplicationReady itself when it is ready.
Switching Off Side Effects
A smoke run starts the real app, so switch off anything that should not happen on a CI runner, such as telemetry, crash reporting or restoring the last workspace:
- In services and components, inject
ISmokeTestContextand checkIsEnabled. Desktop apps get one backed by Hermes fromAddDesktopServices;AddWebAssemblyServicesandAddSmokeChecks()register a disabled one, so shared components can inject it everywhere - In
Program.cs, before the container exists, readHermesSmokeTest.IsEnabledfromHermes.Contracts.Diagnostics
The privacy consent prompt needs no work: HasSeenPrivacyDialog() returns true in a smoke run, so apps that check it before showing PrivacyConsentDialog never show it there, and nothing is written to the privacy settings.
Running in CI
Launch the packaged app with HERMES_SMOKE_TEST=1 and fail the job unless it prints HERMES_SMOKE_RESULT: PASSED and exits with 0. The Mythetech shared desktop workflows do this on every platform before releasing. See Hermes smoke mode for the environment variables, output lines and result file.
