Smoke Mode
Set HERMES_SMOKE_TEST=1 and a Hermes Blazor app tests itself: it starts, shows its window, renders, runs your checks, prints a verdict and exits with 0 or 1. It is built for CI runs against packaged builds, where the question is whether this exact build starts and works on each platform. Without the variable, nothing is created or run.
Quick Reference
HERMES_SMOKE_TEST=1 ./MyAppHERMES_SMOKE_START: MyApp 1.2.0 macOS arm64
HERMES_SMOKE_MILESTONE: window-shown 412ms
HERMES_READY:880.25
HERMES_SMOKE_MILESTONE: first-render 880ms
HERMES_SMOKE_CHECK_PASS: myapp/database 12ms
HERMES_SMOKE_RESULT: PASSED (1 checks)Any Hermes Blazor app gets this with no code changes. Checks and gates, described below, let the app test more than startup.
What a Run Checks
A run passes only when all of these hold before the budget (60 seconds by default) runs out:
- The window was shown (
window-shown) and every root component rendered once (first-render) - Every gate was completed (see Gates)
- Every check passed
- No errors were captured. Any of these fails the run:
| Source | Captured when |
|---|---|
blazor | A component throws an unhandled exception |
log | Anything logs at Error or Critical through ILogger |
dispatcher | An exception escapes work on the UI dispatcher |
unobserved-task | A faulted task is never awaited |
unhandled | An exception would crash the process |
hermes | Hermes logs an error of its own, for example a hosted service failing to start or the WebView process crashing |
dialog | The app tries to open a native dialog |
gate | A gate is failed with FailGate |
A run that runs out of time names what it was still waiting for, for example timed out waiting for first-render.
Adding Checks
A check is a named piece of work that runs once, after the first render and every gate. Throw to fail it.
using Hermes.Blazor.Diagnostics;
public sealed class DatabaseCheck(AppDatabase database) : IHermesSmokeCheck
{
public string Name => "myapp/database";
public async Task RunAsync(CancellationToken cancellationToken)
{
await database.PingAsync(cancellationToken);
}
}builder.Services.AddHermesSmokeCheck<DatabaseCheck>();- Checks are resolved from the page's service scope, so they see the same scoped services as the UI
- Checks are never created outside smoke mode, so registering them costs nothing in normal runs
- Each check has 10 seconds by default; override
Timeoutto change it - Name checks by layer, for example
myapp/database, so the verdict shows where a failure came from
Waiting for Initialization: Gates
Some apps finish starting after the first render, for example by loading a workspace in the background. Register a gate, and the checks wait until the app completes it:
builder.Services.AddHermesSmokeGate("myapp/workspace-loaded");public sealed class WorkspaceLoader(IHermesSmokeSession smoke)
{
public async Task LoadAsync()
{
try
{
await LoadWorkspaceAsync();
smoke.CompleteGate("myapp/workspace-loaded");
}
catch (Exception ex)
{
smoke.FailGate("myapp/workspace-loaded", ex.Message);
throw;
}
}
}IHermesSmokeSession can always be injected. Outside smoke mode every member does nothing, so app code never needs to check whether a smoke run is in progress.
A gate that is never completed fails the run with timed out waiting for myapp/workspace-loaded.
Check Sources for Framework Layers
A framework that wraps Hermes behind its own check interface can supply checks through IHermesSmokeCheckSource instead of registering each one:
public sealed class FrameworkCheckSource : IHermesSmokeCheckSource
{
public IEnumerable<IHermesSmokeCheck> GetChecks(IServiceProvider scopedServices) =>
scopedServices.GetServices<IFrameworkCheck>().Select(check => new FrameworkCheckAdapter(check));
}builder.Services.AddHermesSmokeCheckSource<FrameworkCheckSource>();Environment Variables
| Variable | Meaning |
|---|---|
HERMES_SMOKE_TEST | 1 turns smoke mode on. Any other value, or no value, leaves it off |
HERMES_SMOKE_TEST_TIMEOUT | Budget for the whole run in seconds (default 60) |
HERMES_SMOKE_TEST_RESULT | Path for a JSON copy of the verdict |
HERMES_SMOKE_TEST_EXIT | 0 keeps the app running after the verdict, for looking at a run by hand |
HermesSmokeTest in Hermes.Contracts exposes the parsed settings (IsEnabled, Timeout, ResultPath, ExitWhenDone), for example to skip background work such as update checks during a smoke run.
Output
Every line starts with a fixed prefix, so CI can pick them out of other console output.
| Line | Meaning |
|---|---|
HERMES_SMOKE_START: <app> <version> <platform> <arch> | Smoke mode is on |
HERMES_SMOKE_MILESTONE: <name> <ms>ms | A startup milestone was reached |
HERMES_READY:<ms> | First render, in the format older Hermes versions printed |
HERMES_SMOKE_CHECK_PASS: <name> <ms>ms | A check passed |
HERMES_SMOKE_CHECK_FAIL: <name> <ms>ms - <reason> | A check failed or did not run |
HERMES_SMOKE_ERROR: <source>: <type>: <message> | An error was captured |
HERMES_SMOKE_WARNING: <message> | Something worth knowing that does not fail the run |
HERMES_SMOKE_RESULT: PASSED (<n> checks) | The verdict |
HERMES_SMOKE_RESULT: FAILED (<f>/<n> checks failed, <e> error(s)[, timed out waiting for <x>]) | The verdict |
The process exits with 0 for PASSED and 1 for FAILED. The HERMES_SMOKE_RESULT line is the authority; treat the exit code as a cross-check.
With HERMES_SMOKE_TEST_RESULT set, the same verdict is written as JSON:
{
"schema": 1,
"app": "MyApp",
"version": "1.2.0",
"platform": "macOS",
"architecture": "arm64",
"result": "passed",
"durationMs": 905,
"timedOutWaitingFor": null,
"milestones": [
{ "name": "window-shown", "elapsedMs": 412 },
{ "name": "first-render", "elapsedMs": 880 }
],
"checks": [
{ "name": "myapp/database", "status": "passed", "durationMs": 12, "error": null }
],
"errors": []
}A check's status is passed, failed or not-run. Each error carries source, type, message and stackTrace.
Behaviour During a Smoke Run
- Native dialogs are not shown. A dialog would block an unattended run, so each attempt fails the run and returns the answer that commits to nothing:
nullfrom file and folder pickers,Nofor yes/no questions,Cancelotherwise - Notification permission prompts are suppressed, for the same reason
- macOS: a passing run closes its window normally. A failed run exits straight away with code 1, because closing the last window makes AppKit end the process with code 0
Running in CI
Launch the packaged app with HERMES_SMOKE_TEST=1, capture its standard output, and fail the job unless it prints HERMES_SMOKE_RESULT: PASSED and exits with 0. The Mythetech shared desktop workflows do this on Windows, macOS and Linux, and only upload a release after every platform passes.
Migrating from SmokeTestReporter
SmokeTestReporter is obsolete. Smoke mode reports the first render and the verdict itself, so remove calls to SmokeTestReporter.ReportFirstRender.
HERMES_SMOKE_TEST=1 used to print HERMES_READY and keep the app running unless HERMES_SMOKE_TEST_EXIT=1 was also set. It now ends the app after the verdict; set HERMES_SMOKE_TEST_EXIT=0 to keep it running.
