Runic.Application.Testing
0.16.0
dotnet add package Runic.Application.Testing --version 0.16.0
NuGet\Install-Package Runic.Application.Testing -Version 0.16.0
<PackageReference Include="Runic.Application.Testing" Version="0.16.0" />
<PackageVersion Include="Runic.Application.Testing" Version="0.16.0" />
<PackageReference Include="Runic.Application.Testing" />
paket add Runic.Application.Testing --version 0.16.0
#r "nuget: Runic.Application.Testing, 0.16.0"
#:package Runic.Application.Testing@0.16.0
#addin nuget:?package=Runic.Application.Testing&version=0.16.0
#tool nuget:?package=Runic.Application.Testing&version=0.16.0
Runic.Application.Testing
Test a generated Runic Window contract and its routed Views without a browser or
native window. RunicWindowTestHost<TViewModel> owns an in-memory transport, a real
WindowContentSession, and the generated root Bridge attachment, so tests run the
real ViewModels and generated .NET handlers. The caller owns the ViewModel and its
dependency injection scope.
Testing
Build the application's own services, create a scope for the window, and create the host from it, with a test clock in place of the system clock:
var clock = new FakeTimeProvider(); // Microsoft.Extensions.Time.Testing
await using var services = new ServiceCollection()
.AddSingleton<TimeProvider>(clock)
.AddNotes() // the application's registrations, including AddRunicViews()
.BuildServiceProvider();
await using var scope = services.CreateAsyncScope();
using var host = RunicWindowTestHost.Create<ShellViewModel>(scope.ServiceProvider,
new RunicWindowTestHostOptions { TimeProvider = clock });
RunicWindowTestHost.Create resolves what the Desktop and CS-WebUI windows resolve
from the window's scope: the root ViewModel, its generated Bridge attachment
(AddRunicViews() registers it), and, when registered, the IRunicViewLocator, the
IRunicModelContext (for example from AddRunicReactiveModelContext() or
AddRunicNavigation()) and the ILoggerFactory. A locator, context or logger factory
set in the options is used instead. It names the missing registration when the
ViewModel or its Bridge is not registered. The scope and its ViewModels stay yours;
dispose the host before the scope. host.Services is the provider it resolved them
from.
The services need two registrations: the ViewModel, as the application registers it
(for example services.AddScoped<ShellViewModel>()), and the generated Bridge, which the
application's generated AddRunicViews() adds. Register both with the application's
own registration method when it has one, so the test runs the same composition. A
ViewModel the test builds by hand needs no registration: pass it to Create, which
still takes the Bridge, locator, model context and logger factory from the services
(since 0.14):
await using var services = new ServiceCollection()
.AddRunicViews() // the application's generated Bridge registrations
.BuildServiceProvider();
var shell = new ShellViewModel(new InMemoryNotes());
using var host = RunicWindowTestHost.Create(services, shell);
A Window opened on a real host has the same scope: window.Services (since 0.14) is the
provider of the Window's dependency injection scope, which resolved its ViewModel, so a
test can read the scoped services the ViewModel received.
The constructors take the ViewModel and the attachment directly, for a window that is not composed with dependency injection:
using Runic.Application.Views.Generated.Bridges; // the generated ShellBridge
using var host = new RunicWindowTestHost<ShellViewModel>(shell,
(transport, content, vm) => new ShellBridge(transport, vm, content: content),
new RunicWindowTestHostOptions { ViewLocator = locator, TimeProvider = clock });
The generated bridges are internal, so this needs InternalsVisibleTo from the
application to the test project. A test project that sees two applications' internals
can find the same bridge name in both: name one by an extern alias of its reference,
or create the host with RunicWindowTestHost.Create instead.
Then drive the Window by ViewModel member, as the generated TypeScript client does:
var sidebar = await host.Root.ViewAsync<SidebarViewModel>(vm => vm.Sidebar);
(await sidebar.ExecuteAsync(vm => vm.OpenNotesCommand)).ThrowIfFailed();
var document = await host.Root.ViewAsync<DocumentViewModel>(vm => vm.Main);
var editor = await document.ViewAsync<EditorViewModel>(vm => vm.CurrentPane);
(await editor.SetAsync(vm => vm.Title, "Groceries")).ThrowIfFailed();
(await editor.ExecuteAsync(vm => vm.TagCommand, new TagInput("shopping", Pinned: true))).ThrowIfFailed();
var save = await editor.StartAsync(vm => vm.SaveCommand);
Assert.True((await editor.SnapshotAsync()).IsExecuting(vm => vm.SaveCommand));
clock.Advance(TimeSpan.FromMilliseconds(250)); // storage waits on the injected clock
Assert.Equal(RunicOperationStatusKind.Succeeded, (await save.WaitAsync()).Kind);
Assert.Equal("Saved Groceries", (await editor.SnapshotAsync()).Read(vm => vm.SavedMessage));
Asynchronous and synchronous drivers
Every request has an asynchronous method that never blocks a thread (since 0.14):
SnapshotAsync, SetAsync, SetJsonAsync, WriteAsync, WritePathAsync, ExecuteAsync,
CanExecuteAsync, StartAsync, MountAsync, UnmountAsync, ViewAsync,
ViewsAsync and TrackAsync on a driver, GetStatusAsync, CancelAsync and
ReadStreamAsync on an operation, VerifyAsync on a tracker and RequestAsync on
the transport. Concurrent requests on one connection share its hello.
The synchronous methods (Snapshot, Set, Write, Start, Mount, Request, ...)
remain for short tests. They block the calling thread until the reply, while the
window's own asynchronous work, such as a model turn or an awaited command, continues
on thread-pool threads. A suite that runs many tests in parallel on the thread pool,
as TUnit and xUnit do, can starve the pool this way: each blocked test holds a thread
that the replies it waits for need, and the pool adds threads slowly. Use the
asynchronous methods in such suites.
dotnet new runic-app --tests creates an application with a TUnit test project
that does this for its Window; the
CommunityToolkit Notes example
is a larger xUnit project.
Skip the frontend build
A test project that references the application project builds the application, and
by default that build installs and builds the frontend too. The host needs only the
.NET assembly, so pass RunicApplicationFrontendBuildEnabled=false to the reference, as the
template's test project does:
<ProjectReference Include="../MyApp/MyApp.csproj" AdditionalProperties="RunicApplicationFrontendBuildEnabled=false" />
The generated TypeScript clients are still written. In a solution that also builds the application itself, MSBuild then builds the application project twice, once with each setting; build the test project on its own, or leave the property out and let the frontend build's up-to-date check skip unchanged frontends.
Deterministic time and ids
RunicWindowTestHost.Create and both constructors give the window a
FakeTimeProvider
from Microsoft.Extensions.TimeProvider.Testing, which this package depends on, unless
options supply another TimeProvider. Its time passes only through Advance or
SetUtcNow, which fire due timers, CancellationTokenSource timeouts and
Task.Delay(TimeSpan, TimeProvider) in order; a timer due now fires at once. The
window uses it for interaction deadlines and DrainAsync timeouts; register
the same instance in the application's services for its own time. host.Time
returns it.
Work that waits on the clock does not finish by itself:
ExecuteAsyncof an awaited command that waits on the clock completes only after the test advances it. Start the call, advance the clock, then await the call:var save = editor.ExecuteAsync(vm => vm.SaveCommand); clock.Advance(TimeSpan.FromMilliseconds(250)); (await save).ThrowIfFailed();Startreturns at admission, so an operation can be inspected while it runs.Only code that takes the
TimeProviderfollows it.Task.Delay(TimeSpan),DateTime.UtcNowand ReactiveUI's schedulers (Throttle,Timer,RxApp.MainThreadScheduler) keep using real time. Give such code the clock, for example through a scheduler built on it, or test it with ReactiveUI'sTestScheduler.
Content ids are 1, 2, ... in presentation order, so routes are content1,
content2, ... and snapshots are stable. Set CreateId for another sequence. The
drivers number presentation ids test-presentation1, ..., operation ids
request1, ... and checked-write request ids write1, ... from one counter.
For real time, set TimeProvider = TimeProvider.System in the options.
File watchers
RunicPathWatchTester drives the RunicPathWatchers started with its
Options (see Watching files):
their debounce and polling run on its FakeTimeProvider, Time. Give the
options to the ViewModel that starts the watcher:
using var watching = new RunicPathWatchTester();
using var overview = new OverviewViewModel(context, watching.Options);
File.WriteAllText(Path.Combine(checkout, "flake.lock"), lockText);
var change = await watching.NextChangeAsync(); // after the callback ran in the model context
// A path the test cannot change: report it as if the platform had.
await watching.TriggerAsync("/run/current-system");
NextChangeAsyncreturns the next change not returned yet, once its callback has run. While none is reported, it advancesTimeone debounce interval at a time, so events that arrive from a real file system are delivered. It throwsTimeoutExceptionafter 10 seconds of real time. Each of the three waits also takes aCancellationToken.TriggerAsync(path)marks the targets with that path changed in every active watcher, whether or not they changed, and returnsNextChangeAsync. It throwsInvalidOperationExceptionwhen no active watcher has such a target.DrainAsyncadvancesTimefor 300 ms of real time and returns what was reported meanwhile, to assert that a burst produced one change or nothing more.ActiveWatcherscounts the watchers that are started and not disposed, to check that a ViewModel stops its watcher.Disposestops the watchers that are still active and detaches the tester from its options, so a watcher a test did not dispose does not outlive it. Each change'sEntriesnames the paths that changed (see Watching files).
Drivers
host.Root drives the root ViewModel. View<T>(vm => vm.Content) and
Views<T>(vm => vm.Items) follow the content references in the current snapshot
and check that they present T; host.View<T>(reference) drives a reference you
already have. A RunicViewDriver<T> has:
Snapshot(): aRunicViewState<T>withRead(vm => vm.Title)for scalars (a string, Boolean, Int32, Int64, double, decimal, GUID or enum),Getfor the raw JSON of other values,Reference,References,Keysof a[RunicCollection],CanExecute,IsExecutingandFieldVersion.Readof a record, DTO or collection throwsNotSupportedExceptionnaming the property; useGet(vm => vm.Item).GetProperty("wireField")instead.Set(vm => vm.Title, value), encoded as the generated client encodes it: anint,boolor non-nullablestringas is, and other scalars as wire JSON (Int64anddecimalas strings, enums by wire name,nullfor nullable values). Since 0.14 a DTO, list or union is encoded as for a typed command input (below);SetJsontakes wire JSON.ExecuteAsync(vm => vm.SaveCommand, argumentJson), which waits for an asynchronous command, andCanExecute(command, argumentJson)for commands with an argument.ExecuteAsync(command, argument),CanExecuteAsync(command, argument)andStartAsync(command, argument)take the input as a .NET value instead (since 0.14) and encode it as the generated client does: scalars asSetencodes them, a record or class as an object of its properties by wire name (camelCase, or its[RunicAlias]or[JsonPropertyName], without[RunicIgnore]and[JsonIgnore]members), a[RunicUnion]case with its$case, lists as arrays, string-keyed dictionaries as objects and a[RunicCodec]type by its codec. Astringinput binds to the wire JSON overload unless you name the type:ExecuteAsync<string>(vm => vm.SearchCommand, "milk"); a string that is not JSON throwsArgumentExceptionthat says so. A command whose[RunicCommandKey]resolver is asynchronous answers only after the resolver, so useCanExecuteAsyncandStartAsyncfor it (since 0.11);CanExecuteandStartthrowInvalidOperationExceptionfor such a command instead of blocking the test thread, which may own the fake clock or the model turn the resolver waits for. A reply hasSucceeded,ErrorKind,ErrorReason(since 0.11:unavailable,invalid-inputorunresolved-keyfor arejectedcall),ErrorMessage,Revision,ReleasedandSuperseded. A Views protocol 4 reply names therevisionthat includes the call's effects instead of carrying state, so the driver readsStatewith asnapshotrequest after the reply, when the reply has a revision.Releasedistruewhen the call released its own route, as a command that navigates away does, andSupersededwhen asetwas not applied because a later edit of the member was admitted first.ThrowIfFailed()throws for a failed call, asNavigationResultdoes. A command's declared failure ([RunicFailure]) replies withErrorKinddomain-failedand the encoded failure asFailure, such as{"$case":"titleRequired"}. A call of a released View or a closed window repliesreleased.Start(command, argumentJson, requestId)for a recoverable operation. ItsAdmissionis aRunicOperationAdmissionKind(Accepted,Duplicate, ...). It hasGetStatus(),WaitAsync()andCancel(), which returns aRunicOperationCancelKind(CancellationRequested,NotRunning, ...). A status has aRunicOperationStatusKindKind(Running,Succeeded,Failed,DomainFailed,Cancelled,Expired, orUnknownfor a request the window does not know),Result,ErrorMessageand, for aDomainFailedoperation,Failure.ReadStream(cursor)reads a streamed operation's retained items aftercursoras the generated client'sstream(cursor)does: aRunicOperationStreamPagewith the statusKind, the encodedItems, theCursorof the next page,Completed, andDeliveryKindandDeliveryMessagewhen items were dropped (stream-overflow, ...). The delivery kinds, likeBridgeOperationDeliveryFailureKindin .NET and TypeScript, are open: a minor release may add one.Write(vm => vm.Title, value, expectedVersion)for a checked write. It uses the current value and version as the baseline unlessexpectedVersionis given, so a stale version tests theConflictreceipt. A receipt has aRunicFieldWriteReceiptKindKind,ErrorMessage,Value,VersionandState, read like a reply's.WritePath(vm => vm.Lines, ["7", "quantity"], value, expectedVersion)writes a field path below a member, as the page'sInvoiceFields.lines.at(7).quantitydoes (field-paths/1, since 0.16): the row's key and the cell for a[RunicCollection]with a[RunicRowEditor], or member names such as["street"]for an object member. The baseline is the path's value and, unlessexpectedVersionis given, the state's revision; an older revision after another write of the cell tests theConflictreceipt. Test connections listfield-paths/1in theirhello.Mount(presentation)sendsopenfor a presentation of the route, as a page that shows the View does, and returns the presentation id (test-presentation{n}when omitted);Unmount(presentation)sendsclose. Both take optional client and connection keys and throwInvalidOperationExceptionnaming the route's reply unless it accepted the request, for example when another client's keys close it (ignored) or the route was released.TakePublications()andNextPublicationAsync()return this route's pushes in order, each aRunicViewPublicationwhoseKindisState(withStateandFields),Frame(withBaseRevision, the memberSets(RunicMemberSet), the keyed collectionChanges(RunicCollectionChange) and the textTexts(RunicTextChange)), orFailure.Revisionnames the revision of each.
Several pages of one window
Each driver sends its requests on the transport's test connection,
connection-1 of client client-1. host.Root.ForConnection(connectionKey, clientKey) (since 0.16) returns a driver of the same route on another
connection, as a second browser tab of a multi-client CS-WebUI window. The
drivers it returns from View<T>, and the operations it starts, use that
connection too. A connection sends its hello with its first request, and
host.Transport.CloseConnection(key) closes it as a page that leaves does.
The window's IRunicConnections (see
Who is connected, and who called)
then lists the connections, and a command runs with its driver's connection as
Caller. host.Connections reads the same list. A window opened with
RunicWindowTestHost.Create(services) binds the IRunicConnections of
services, such as the scope the ViewModel took it from. The window gives each
connection a key of its own that no later connection reuses (connection-1#1
on this transport), and a driver's Connection returns it:
var bob = host.Root.ForConnection("bob-tab", clientKey: "bob");
(await host.Root.ExecuteAsync(vm => vm.ClaimCommand)).ThrowIfFailed();
(await bob.ExecuteAsync(vm => vm.ClaimCommand)).ThrowIfFailed();
Assert.Equal([host.Root.Connection, bob.Connection], host.Connections.Current);
host.Transport.CloseConnection("bob-tab");
await host.Content.ModelContext!.InvokeAsync(() => { }); // Disconnected runs in a model turn
Assert.Single(host.Connections.Current);
Connected and Disconnected are raised in a turn of the window's model
context: a command's reply comes after the Connected of its connection, and
after CloseConnection a test awaits a model turn before it reads the list.
Navigation
A NavigationRegion<TContent> slot is an ordinary content slot, so the same host
and drivers apply. Create the navigator with the window's model context and pass
that context as RunicWindowTestHostOptions.ModelContext; a generated Bridge
rejects a session whose context is not the navigator's. A NavigationSelection
slot is a nullable number: read the selected index with
Snapshot().Get(vm => vm.Tabs), which is JSON null for an empty selection.
using Runic.Navigation; // RunicNavigator and RunicModelContext
await using var context = new RunicModelContext();
await using var navigator = new RunicNavigator(new() { ModelContext = context, TimeProvider = clock });
var shell = new ShellViewModel(navigator);
using var host = new RunicWindowTestHost<ShellViewModel>(shell, attach,
new RunicWindowTestHostOptions { ModelContext = context, TimeProvider = clock });
// A scripted guard: the test page's CanDepartAsync returns decision.Task, which
// holds the transition in Guarding until the test decides.
var decision = new TaskCompletionSource<bool>();
editor.Departure = decision.Task;
var back = shell.Main.BackAsync();
decision.SetResult(true);
await back;
await navigator.WhenIdleAsync();
var home = host.Root.View<HomeViewModel>(vm => vm.Main); // the resumed entry
RunicNavigator.WhenIdleAsync()waits until no transition or retirement is running.navigator.UnretiredEntryCountcounts entries that have not finished retiring (zero afterDisposeAsync), andhost.Content.RetainedContentModelCount(), an extension on the window'sWindowContentSession, counts the content models whose model-context leases the window holds.- Pass a
FakeTimeProviderasRunicNavigatorOptions.TimeProviderto drive the close timeout and the overrun warning (event 1067) without waiting.
With AddRunicNavigation(), create the window scope with CreateAsyncScope(),
because the navigator is only IAsyncDisposable. RunicWindowTestHost.Create gives
the window the scope's IRunicModelContext, which the navigator shares:
await using var scope = services.CreateAsyncScope();
using var host = RunicWindowTestHost.Create<ShellViewModel>(scope.ServiceProvider,
new RunicWindowTestHostOptions { TimeProvider = clock });
Saving and restoring navigation (since 0.16). A restart is a new window
scope. navigator.SavePath() returns the window's
navigation path
as the JSON an application stores, and RestorePathAsync(saved) restores it in
the new window's navigator, waits until it is idle and reads the path back.
IsExact is true when nothing was left out and the restored path is the
saved one:
var saved = scope.ServiceProvider.GetRequiredService<RunicNavigator>().SavePath();
host.Dispose();
await scope.DisposeAsync();
await using var restarted = services.CreateAsyncScope();
using var again = RunicWindowTestHost.Create<ShellViewModel>(restarted.ServiceProvider);
var restore = await restarted.ServiceProvider.GetRequiredService<RunicNavigator>().RestorePathAsync(saved);
await Assert.That(restore.IsExact).IsTrue(); // otherwise restore.Result.Issues says what was left out
Back and the URL (since 0.16). host.NavigateBackAsync() sends the
window's Back, as the mouse's Back button does in a page that called
installBackNavigation(). host.OpenNavigationPathAsync(path) opens a route
path as a browser's Back, Forward or address bar does in a page that called
installNavigationPath(). Both return a RunicNavigationReply with the
Outcome (null when no route matched) and the window's Path afterwards,
which the page's URL shows. host.NavigationPathPushes lists the path changes
the page was told about, with their history operation:
using var path = RunicPresentation.SyncNavigationPath(shell.Main);
var refused = await host.OpenNavigationPathAsync("home"); // a browser Back that a dirty editor refuses
await Assert.That(refused.Outcome?.Rejection).IsEqualTo(NavigationRejection.Guard);
await Assert.That(refused.Path).IsEqualTo("editors/a"); // the URL the page puts back
await Assert.That(host.NavigationPathPushes[^1]).IsEqualTo(new RunicNavigationPathPush("editors/a", "push"));
A unit test of one page that takes its NavigationEntryContext, such as a
dialog that completes its request, needs no window. RunicTestNavigationEntry
(since 0.11) pushes the page on a test navigator with its own
RunicModelContext, as a region does in the application, and exposes the
context, the request's completion and the transitions that change the entry's
state:
await using var dialog = await RunicTestNavigationEntry.CreateAsync(
entry => new ConfirmNavigationViewModel(entry, "Discard the unsaved edits?"));
await dialog.Content.ConfirmCommand.ExecuteAsync(null);
await Assert.That(await dialog.Completion).IsEqualTo(new NavigationCompletion<object?>.Completed(true));
await Assert.That(dialog.Context.State).IsEqualTo(NavigationEntryState.Retired);
- The navigator constructs the content through
create, so its navigation hooks, guards andINavigationAwarecalls run as in the application, and it owns and disposes the content when the entry retires. Passservicesfor the entry'sServices. - The entry is pushed for an
objectresult:CompletionreportsCompleteAsyncwith any value, andDismissedwhen the entry leaves otherwise. RetainAsyncpushes another entry over it,ActivateAsyncgoes back to it, andRetireAsyncclears the region. Each returns theNavigationResult, so a guard's veto is visible.NavigatorandRegionare there for anything else; disposing the object closes the navigator.
Write navigation tests so they are deterministic:
- Hold a transition in a phase with a
TaskCompletionSourcethat a guard,InitializeAsyncorResumeAsyncawaits. Signal a second one from the hook when the test must know that the hook started. Then the test decides when the hook continues, instead of racing it. - Don't await a request whose guard waits for the test. Start it, wait for the
observable effect, answer, then await it. For example, start the Back, wait
until the dialog region's
Currentis set, execute the dialog's command through its driver, and then await the Back. A command driver'sStartreturns at admission, which suits a command that navigates. - Wait for region changes with
PropertyChanged, not with delays. Every change is raised inside a model turn. await navigator.WhenIdleAsync()before you assert on retirement: departing entries retire after the commit turn, and owned content is disposed then.- Assert lifetimes through the counters.
navigator.UnretiredEntryCountfalls as entries retire, and is zero afterDisposeAsync.RetainedContentModelCount()falls when owned content's leases are released. AssertRetiredstates and disposal counts of your own test content too. - Make overruns and timeouts happen by advancing the
FakeTimeProvider: 5 seconds for event 1067, andCloseTimeoutfor disposal. - To provoke a race, drive overlapping requests from a seeded
Random, and assert invariants that don't depend on timing. For example, every request ends, an entry retires at most once, and nothing is left unretired afterDisposeAsync.
The Notes tests
cover a guard that asks in a dialog region with PushForResult, resuming a
retained entry, retirement of a parent's child regions, racing navigation and
closing the window while the dialog asks; ConfirmNavigationTests
tests the dialog page on its own with RunicTestNavigationEntry.
A region collection
(since 0.16) reads as a RunicRegionCollectionState, and the drivers send the
page's select and close requests by key:
var tabs = (await host.Root.SnapshotAsync()).Regions(vm => vm.Documents);
// tabs.Keys, tabs.Regions[i].Content (a PageReference or null), tabs.Selected
var closed = await host.Root.CloseRegionAsync(vm => vm.Documents, tabs.Keys[0]);
// closed.RegionOutcome: Committed, or Rejected with Guard when a page refused
SelectRegionAsync and CloseRegionAsync return a RunicCallReply with the
state after the request; a key that no region has fails with ErrorReason
unresolved-key. The test connection's hello lists region-collections/1
for these requests; it adds a request kind only, so the other messages are
those of the base protocol.
Interactions
A test can act as the page: RunicViewDriver.MountPresentation() (since 0.16) opens a
presentation that answers the ViewModel's questions and interactions with the
handlers the test registers, and receives its view requests:
using var page = await host.Root.MountPresentationAsync();
page.Handle(vm => vm.ConfirmDiscard, request => request.Title == "Draft"); // a ViewQuestion<,>
page.Handle<DiscardNoteRequest, bool>(vm => vm.ConfirmLeave, request => true); // a ReactiveUI Interaction<,>
(await host.Root.ExecuteAsync(vm => vm.DiscardCommand)).ThrowIfFailed();
var focus = await page.NextViewRequestAsync();
Assert.Equal(new FocusTarget("title"), focus.Input<FocusTarget>());
Assert.Empty(page.TakeViewRequests(vm => vm.Focus));
The presentation handles every ViewRequest<T> of the ViewModel from the
moment it opens, so the requests reach it by their delivery; one that
Mount() opened handles none. Handle registers one more handler, as the
generated client's interactions.<name>.handle does, and replaces an earlier
one of the same member; HandleAsync takes an asynchronous answer, which is
cancelled when .NET cancels the request. An answer that throws answers
failed. RunicViewRequest.Input<T>() reads the input with System.Text.Json's
web defaults; InputJson is the wire JSON. Pass clientKey and
connectionKey to MountPresentation to act as one of several pages: a command sent
on that connection reaches only its presentation. Dispose the presentation to
stop answering, or call Unmount().
A test can also answer a ReactiveUI Interaction, such as the question of a
ReactiveLeaveConfirmation.WithInteraction guard, with a .NET handler on the
ViewModel. ReactiveUI asks the latest handler first, so a handler the test
registers answers before a fallback registered in the ViewModel's constructor;
dispose it to restore the fallback. From the
ReactiveUI WPF navigation tests:
var answers = new Queue<bool>([false, true]);
var prompts = new List<string>();
using var handler = detail.ConfirmDiscard.RegisterHandler(context =>
{
prompts.Add(context.Input);
context.SetOutput(answers.Dequeue());
});
A driver's Mount() opens a presentation without registering any
interaction handler, so with only mounted presentations a question never waits
for a browser: it goes to the .NET handlers. With no handler that answers, the
departure fails closed with ReactiveUI's UnhandledInteractionException, as
in the application.
The browser's side is tested with the frontend: the generated typed mock's
interactions.<name>.request(input) resolves with the answer of the handler
the View registered with interactions.<name>.handle(...), and
requests.<name>.raise(input) calls the View's requests.<name>.handle(...)
handler; see the
Testing guide.
Which View is asked for which departure, and the fallback when none is
mounted, are covered by the SDK's own tests and described in
Leave confirmation in a web View.
A CommunityToolkit ViewModel asks the page with a ViewQuestion<TInput, TOutput>,
which Handle answers as above; a Toolkit guard that asks in a dialog region
is answered by executing the dialog's command through its driver, as in
Navigation.
The Window's presentation
A ViewModel that takes IRunicWindowPresentation presents, hides or minimizes
its Window and reacts to its activity. AddRunicTestWindowPresentation()
registers a recording RunicTestWindowPresentation per scope as the host of the
scope's presentation, in place of the real one, so the application's own
registration method runs unchanged. The ViewModel gets the same per-Window
presentation an application does, with the fake as its host: a change reaches
the ViewModel in a turn of the window's model context after the request that
caused it completed, a handler that throws is logged (event 1130) without
stopping the others, and once the scope is disposed requests report
OwnerClosed. host.Presentation returns the fake;
host.SetWindowActivityAsync changes its activity and waits until the
ViewModel received it, and host.FlushWindowPresentationAsync waits for the
changes a command's requests caused:
await using var services = new ServiceCollection().AddInbox().AddRunicTestWindowPresentation().BuildServiceProvider();
await using var scope = services.CreateAsyncScope();
using var host = RunicWindowTestHost.Create<InboxViewModel>(scope.ServiceProvider);
(await host.Root.ExecuteAsync(vm => vm.HideToTrayCommand)).ThrowIfFailed();
Assert.Equal([new RunicWindowPresentationRequest(RunicWindowPresentationRequestKind.SetVisible) { Value = false }],
host.Presentation!.Requests);
await host.SetWindowActivityAsync(new RunicWindowActivity(IsActive: false, IsMinimized: false, IsVisible: true));
Assert.Equal(TimeSpan.FromMinutes(1), host.ViewModel.PollInterval);
The fake behaves like a desktop window: PresentAsync shows, restores and
activates it, MinimizeAsync minimizes it, SetVisibleAsync(false) hides it,
and SetVisibleAsync(true) shows it again without restoring it, so a minimized
window stays minimized, as Win32's SW_SHOW leaves it. IsFullScreen,
IsDocumentEdited and ColorScheme follow their requests, and PresentCount
counts the presentations. Set Features to test a host that cannot do
something (the request then reports BackendUnavailable and changes nothing),
and Respond to answer with another result, such as a desktop that refuses the
focus.
AddRunicTestWindowPresentation(immediate: true) makes the fake itself the
scope's presentation, and a ViewModel built by hand can take a
new RunicTestWindowPresentation(). Used directly, the fake raises its changes
at once on the calling thread, inside the request, and keeps answering after
the scope ends; a change its own handlers cause is still raised after them.
The Window's presentation
A ViewModel that takes IRunicWindowPresentation presents, hides or minimizes
its Window and reacts to its activity. AddRunicTestWindowPresentation()
registers a recording RunicTestWindowPresentation per scope as the host of the
scope's presentation, in place of the real one, so the application's own
registration method runs unchanged. The ViewModel gets the same per-Window
presentation an application does, with the fake as its host: a change reaches
the ViewModel in a turn of the window's model context after the request that
caused it completed, a handler that throws is logged (event 1130) without
stopping the others, and once the scope is disposed requests report
OwnerClosed. host.Presentation returns the fake;
host.SetWindowActivityAsync changes its activity and waits until the
ViewModel received it, and host.FlushWindowPresentationAsync waits for the
changes a command's requests caused:
await using var services = new ServiceCollection().AddInbox().AddRunicTestWindowPresentation().BuildServiceProvider();
await using var scope = services.CreateAsyncScope();
using var host = RunicWindowTestHost.Create<InboxViewModel>(scope.ServiceProvider);
(await host.Root.ExecuteAsync(vm => vm.HideToTrayCommand)).ThrowIfFailed();
Assert.Equal([new RunicWindowPresentationRequest(RunicWindowPresentationRequestKind.SetVisible) { Value = false }],
host.Presentation!.Requests);
await host.SetWindowActivityAsync(new RunicWindowActivity(IsActive: false, IsMinimized: false, IsVisible: true));
Assert.Equal(TimeSpan.FromMinutes(1), host.ViewModel.PollInterval);
The fake behaves like a desktop window: PresentAsync shows, restores and
activates it, MinimizeAsync minimizes it, SetVisibleAsync(false) hides it,
and SetVisibleAsync(true) shows it again without restoring it, so a minimized
window stays minimized, as Win32's SW_SHOW leaves it. IsFullScreen,
IsDocumentEdited and ColorScheme follow their requests, and PresentCount
counts the presentations. Set Features to test a host that cannot do
something (the request then reports BackendUnavailable and changes nothing),
and Respond to answer with another result, such as a desktop that refuses the
focus.
AddRunicTestWindowPresentation(immediate: true) makes the fake itself the
scope's presentation, and a ViewModel built by hand can take a
new RunicTestWindowPresentation(). Used directly, the fake raises its changes
at once on the calling thread, inside the request, and keeps answering after
the scope ends; a change its own handlers cause is still raised after them.
Windows and their close decision
RunicWindowTestHost.Create drives one ViewModel. To test a Window, its close
guard and the Windows its ViewModels open, open it with
RunicWindowTestHost.CreateWindowAsync<TWindow>(services) (since 0.16). It
opens a real RunicWindow headless, as a host does: in a new scope of the
application's root services, with its parameter, result and close guard, and with
the application's own IRunicWindowService (AddRunicWindowService()), so
FindOrOpenAsync, ActivateAsync and OpenForResultAsync open test Windows
too.
await using var services = new ServiceCollection().AddMyApplication().BuildServiceProvider();
await using var shell = await RunicWindowTestHost.CreateWindowAsync<ShellWindow>(services);
// A guarded editor keeps unsaved changes.
(await shell.Root<ShellViewModel>().ExecuteAsync(vm => vm.OpenDocumentCommand, "\"notes.md\"")).ThrowIfFailed();
var editor = shell.OpenedWindows.Single();
Assert.Equal("notes.md", editor.Key);
Assert.False(await editor.RequestUserCloseAsync());
// A modal dialog that returns a value.
var choosing = shell.Root<ShellViewModel>().ExecuteAsync(vm => vm.ChooseAccountCommand);
var dialog = await shell.WaitForOpenedWindowAsync<AccountWindow>();
Assert.True(shell.IsBlocked);
(await dialog.Root<AccountViewModel>().ExecuteAsync(vm => vm.ChooseCommand, "\"Savings\"")).ThrowIfFailed();
(await choosing).ThrowIfFailed();
RequestUserCloseAsync()is the user's close button: a Window withGuardCloseasksCanCloseAsync, and the result tells whether it closed. It shares one decision withIRunicWindow.RequestCloseAsync, as on Runic Desktop.OpenedWindowslists the Windows opened after this one, including closed ones, each withWindow,ViewModel,Parameter,Key,Owner,Modality,IsOpenand itsRoot<TViewModel>()driver;WaitForOpenedWindowAsync<TWindow>()waits for one, for example a dialog whose command is still waiting for its result.Host<TViewModel>()is the Window'sRunicWindowTestHost<TViewModel>.- Each Window has a
RunicTestWindowPresentation(Presentation), the one its scope registers or a new one, so activation shows asPresentCount. - As on Runic Desktop: an owned Window closes when its owner closes, hides and
shows with its owner's
SetVisibleAsync, and an owner's close asks its owned Windows' guards first, innermost first.IsBlockedistruewhile a window-modal Window it owns is open. The test host does not pass where the desktop cannot: on a blocked WindowRequestUserCloseAsync()and its page's commands, operation starts, sets and writes throwInvalidOperationException(snapshots still read it); the application'sRequestCloseAsyncstill works. DisposeAsyncof the WindowCreateWindowAsyncreturned closes the whole test application; that of an entry ofOpenedWindowscloses only that Window and the Windows it owns.- All Windows share one clock (a
FakeTimeProviderunless the options name another) and one id source. Disposing the result closes and releases every Window. A command that closes its own Window is cancelled by the close, as on every host, so don't assert its reply.
Collections and frames
Track() follows a route from a snapshot through its pushes, as the generated
client does: newer full states replace the state, frames must continue the tracked
revision (BaseRevision), and their keyed collection changes must name the row
keys they change; failure notices are collected. A frame the client could not
apply throws, naming the problem: a lost or reordered frame, more than 4,096
changes, a set beside another change of the same member, a member the View
does not have, a missing or unexpected checked-field version, a text change
whose length does not match, an index outside the rows, a row key that does not
match, an item count that does not match the keys, or an empty or duplicate key.
FullStates counts the full states and Deltas the frames it applied.
var tracker = host.Root.View<HomeViewModel>(vm => vm.Main).Track();
library.Record("Groceries", "Milk, eggs");
var state = await tracker.WaitUntilAsync(state => state.Keys(vm => vm.RecentNotes)[0] == "Groceries");
Assert.Equal(0, tracker.FullStates); // only keyed changes were sent
Assert.Contains(tracker.Changes, change => change.Kind == RunicCollectionChangeKind.Move);
tracker.Verify(); // the frames match a fresh snapshot
Publications are delivered off the calling thread, so waits use real time,
RunicWindowTestHostOptions.PublicationTimeout (five seconds by default). A route's
frames go to its tracker or to TakePublications, not both.
Platform services
A ViewModel that shows a file dialog, a message box or a context menu, or uses
the clipboard, takes IFileDialogs, IDesktopMessageBoxes, IDesktopClipboard
or IWindowPlatformServices in its constructor. services.AddRunicTestPlatform()
registers scripted, recording fakes for them: one RunicTestPlatform per window
scope and one RunicTestNotifications for the application. It replaces the
registrations of AddRunicPlatformServices() whether it runs before or after
it. RunicWindowTestHost.Create returns the scope's platform as Platform:
services.AddRunicTestPlatform();
await using var scope = provider.CreateAsyncScope();
using var host = RunicWindowTestHost.Create<EditorViewModel>(scope.ServiceProvider);
host.Platform!.FileDialogs.EnqueueOpen("notes.txt", "Hello");
var saved = host.Platform.FileDialogs.EnqueueSave("notes-copy.txt");
host.Platform.MessageBoxes.EnqueueAnswer("save");
(await host.Root.ExecuteAsync(vm => vm.OpenCommand)).ThrowIfFailed();
(await host.Root.ExecuteAsync(vm => vm.SaveAsCommand)).ThrowIfFailed();
Assert.Equal("Hello", saved.ReadAllText());
Assert.Equal("Import notes", host.Platform.FileDialogs.Calls[0].OpenOptions?.Title);
Each fake takes the next queued response and records the call:
- File dialogs.
EnqueueOpen(file)answers an open-file or open-document dialog,EnqueueOpenFiles(...)an open-files dialog,EnqueueSave(name)a save dialog,EnqueueOpenDirectory(path)a directory dialog, andEnqueueDismissed(),EnqueueUnavailable(reason)andEnqueueFailed(code)any dialog. A dialog with an empty queue is dismissed; a response for another kind of dialog throwsInvalidOperationExceptionfrom the dialog call.Callslists each dialog with its options and outcome. - Files and leases. A
RunicTestFileis in memory. Its leases follow the real rules: a read lease is consumed once, a save lease has one open write transaction at a time (ResourceBusyotherwise), a commit happens once, and it reportsNotCommitted(Conflict)when the file no longer holds what the application last saw.file.Write(...)orfile.Delete()acts as another writer;file.EnqueueCommitResult(...)scripts a failed or uncertain commit, andEnqueueCommitResult(new FileCommitResult.CommitUnknown(code), applied: true)an uncertain one whose write landed, as a real lease can report;file.WriteUnavailableacts as a read-only grant.ReadAllText(),ReadAllBytes()andCommittedWritesshow what the application saved. - Message boxes.
EnqueueAnswer(buttonId)chooses a button. An unscripted box is answered as Esc would be: with its cancel button, ornull. - Context menus.
ContextMenus.EnqueueChoice(commandId)chooses an item ofShowContextMenuAsync; a disabled item throws, since the user could not choose it. An unscripted menu is dismissed. - Clipboard.
Clipboard.SetText(...)orSetContent(...)is what another application copied;ContentandWritesare what the application copied. Writes, limits and watches follow the real clipboard. - Notifications.
Shown,CurrentandRemovedrecord the application's notifications, andActivate(id, actionId)raisesActivated. - Launcher and announcements.
LaunchesandAnnouncementsrecord files and URIs handed to native applications and screen-reader announcements.
The fakes check their arguments with the real services' rules. Disposing the host
closes the platform, as closing a window does: every service then reports
OwnerClosed, file leases are released and clipboard watches end. Monitors, the
menu bar, taskbar badges and progress, and drags report BackendUnavailable. A
ViewModel built by the test can take a new RunicTestPlatform() directly.
Query collections
A driver reads a RunicQueryCollection as a page does: QueryRowsAsync(vm => vm.Rows, start, count) returns the
range's keys and rows (connectionKey: reads it as another page), SetQueryAsync(vm => vm.Rows, query) changes the
query and returns the state after it, and IndexOfAsync(vm => vm.Rows, key) finds a row. RunicTestRowSource<TRow, TKey>
serves a list through RunicQueryFields, waits Latency on the host's clock, and records Loads, RowsLoaded,
Counts, Searches and MaximumConcurrency, so a test can assert what scrolling and sorting loaded:
var source = new RunicTestRowSource<Order, int>(orders, fields, order => order.Id);
using var host = RunicWindowTestHost.Create(services, new OrdersViewModel(source, context));
var rows = await host.Root.QueryRowsAsync(vm => vm.Orders, 500, 40);
Assert.Equal(1, source.Loads.Count);
(await host.Root.SetQueryAsync(vm => vm.Orders, new RunicQuery([new RunicSortKey("amount", Descending: true)]))).ThrowIfFailed();
The test connection's hello lists query-collections/1.
Lower level
host.Transport is the InMemoryBridgeTransport, the window's
IBridgeConnectionTransport.
Request(json) and RequestAsync(json) send a Views protocol 4 request on a connection
(hello first), CloseConnection closes it, and Messages/TakeMessages() list the
pushes the window sent, such as
host.Transport.Request($$"""{"kind":"snapshot","route":"{{host.RootRoute}}"}""") for
the raw reply that host.Root.Snapshot() parses. Each RunicBridgeMessage has the
push's Json, the ConnectionKey of a push to one connection, or null for a
broadcast, and, read from the JSON, its Kind (state, frame, failure,
operation, ...) and Route (since 0.14), so
host.Transport.TakeMessages().Where(message => message.Route == editor.Route) selects one
route's pushes. DefaultConnectionKey (connection-1) and DefaultClientKey
(client-1) are the keys of requests that name none. The verb is Take, as on the drivers,
because the pushes are removed; Drain names DrainAsync, which waits for accepted
work. A driver's Mount and Unmount (open and close) with client and
connection keys and Transport.CloseConnection test View lifetimes and client
disconnects. Await host.DrainAsync(timeout) before disposing
a host that has accepted background operations. It stops admission, as a closing
window does. With a fake clock its timeout passes only when the clock advances, and
its token stops only the wait.
The host does not instantiate the application's native Window wrapper or run the
generated TypeScript client or a rendering framework. Test the frontend with the
generated typed mocks of @runic-artifex/views/mock; see its
Testing guide.
To drive the whole application in a browser, use RunicServeOnlyApplication
(below); to run script in the embedded Runic Desktop View from an in-process
test, use DesktopBridgeWindow<TViewModel>.Surface.ExecuteJavaScriptAsync. See the
Runic.Application.Views.Desktop automation
section.
Custom window hosts
Runic Desktop, CS-WebUI, WPF and this package carry the Views protocol over an
IBridgeTransport from Runic.Application.Views. A host of your own, such as
one that serves pages over its own WebSocket, implements the same interface;
the protocol it carries is in the
Views protocol. The window
calls Serve once with its IBridgeRequestHandler and sends every push
through BroadcastAsync. A transport that can push to one connection outside a
request also implements IBridgeConnectionTransport.SendAsync; without it, the
window keeps each connection's targeted pushes in a mailbox (1,024 pushes and
4 MiB at most), and the page's host script drains it with receive requests,
as CS-WebUI's does. The transport:
- passes each request to
HandleAsyncas aBridgeRequestwith the message and the client and connection keys it authenticated, never keys read from the message, in the order the connection received them, without waiting for earlier replies: areceiveor an awaited command stays open for long; - sends the replies and pushes of one connection in the order the window sent
them, and completes
BroadcastAsyncandSendAsynconce the push is queued on every connection ahead of later sends; - copies a push that it keeps after
BroadcastAsyncorSendAsynccompleted: the message is valid only until then, because the window returns its pooled memory and reuses it for later pushes; - answers a request while no handler is served with the
releasedreply, and reports each closed connection once withConnectionClosed. A reconnect is a new connection with a new connection key.
using System.Collections.Concurrent;
using Runic.Application.Views;
// PageSocket is the host's own connection: it writes replies and pushes in call order, and its
// Closing token is cancelled when the connection closes.
public sealed class SocketBridgeTransport : IBridgeConnectionTransport
{
private static readonly byte[] Released =
"""{"ok":false,"error":{"kind":"released","message":"This View was released."}}"""u8.ToArray();
private readonly ConcurrentDictionary<string, PageSocket> _pages = new();
private IBridgeRequestHandler? _handler;
public IDisposable Serve(IBridgeRequestHandler handler)
{
if (Interlocked.CompareExchange(ref _handler, handler, null) is not null)
throw new InvalidOperationException("The transport already serves a handler.");
return new Lease(() => Interlocked.CompareExchange(ref _handler, null, handler));
}
public async ValueTask BroadcastAsync(ReadOnlyMemory<byte> message, CancellationToken cancellationToken = default)
{
foreach (var page in _pages.Values) await page.PushAsync(message, cancellationToken);
}
public async ValueTask<bool> SendAsync(string connectionKey, ReadOnlyMemory<byte> message,
CancellationToken cancellationToken = default)
{
if (!_pages.TryGetValue(connectionKey, out var page)) return false;
await page.PushAsync(message, cancellationToken);
return true;
}
public void Opened(PageSocket page) => _pages[page.ConnectionKey] = page;
// The socket's read loop calls this for each request, in arrival order, with a buffer of its own.
public void Received(PageSocket page, long requestId, ReadOnlyMemory<byte> message)
{
if (Volatile.Read(ref _handler) is not { } handler) { _ = page.ReplyAsync(requestId, Released); return; }
_ = ReplyAsync(page, requestId, handler.HandleAsync(
new BridgeRequest(message, page.ClientKey, page.ConnectionKey), page.Closing));
}
public void Closed(PageSocket page)
{
if (_pages.TryRemove(page.ConnectionKey, out _)) Volatile.Read(ref _handler)?.ConnectionClosed(page.ConnectionKey);
}
private static async Task ReplyAsync(PageSocket page, long requestId, ValueTask<ReadOnlyMemory<byte>> reply) =>
await page.ReplyAsync(requestId, await reply);
private sealed class Lease(Action dispose) : IDisposable
{
public void Dispose() => dispose();
}
}
The page's script installs __runicBridge with request, onMessage and
onConnectionChange, as the
host bridge describes, and
the window attaches its generated Bridge to the transport with
new WindowContentSession(transport, options) and the factory that
AddRunicViews() registers, as the constructors above take it.
Test the window side over InMemoryBridgeTransport, which implements the same
contract: RunicWindowTestHost runs the window on it, and a host's own
composition can take new InMemoryBridgeTransport() in place of its transport
and drive it with Request, CloseConnection and TakeMessages. Test the
transport itself with a stub IBridgeRequestHandler that records the
requests, keys and ConnectionClosed calls it receives and replies in an order
the test chooses, so a test can check that replies and pushes keep their order
and that a slow reply does not hold up the next request.
Compatibility
- The enums say in their documentation whether they are closed or open.
RunicCollectionChangeKindandRunicPublicationKindare closed. The operation, admission, cancellation and receipt kinds are open: a minor release may add a value, so aswitchover one needs a default arm.ErrorKindandErrorReasonare strings because BridgeError kinds and reasons are open sets that Runic.Application.Views owns. - The package has no test-framework types. Failures throw
InvalidOperationExceptionorTimeoutException, so it works with TUnit, xUnit, NUnit and MSTest alike, asRunic.CommandLine.Testingand ASP.NET Core'sWebApplicationFactorydo. - It uses internals of
Runic.Application.Views, so it references that package at exactly its own version. Keep both at the same version. - The constructors'
attachparameter,Func<IBridgeTransport, WindowContentSession, TViewModel, IDisposable>, is the factory the generatedAddRunicViews()registers. It changes only together with the generated code. RetainedContentModelCount()counts every content model whose model-context lease the window holds, not only navigation entries. It is a test-only view of window internals, whichWindowContentSessiondoes not expose.
Blobs and uploads
A window of RunicWindowTestHost serves its blobs below http://runic.test/
(Views feature blobs/1). ReadBlobAsync(url) returns what the host serves
for a blob URL of the state or a result, as RunicServedBlob: status, headers
and content, with range for a byte range and head for HEAD. A replaced or
released URL answers 404, and ServedBlobCount counts the URLs the window
still serves. UploadAsync(bytes, fileName, contentType) simulates a page's
upload with the window's limits (RunicWindowTestHostOptions.MaximumUploadBytes)
and returns the RunicUpload that a command input takes;
PendingUploadCount counts the uploads no command claimed.
var upload = await host.UploadAsync("hello"u8.ToArray(), "notes.txt", "text/plain");
await host.Root.ExecuteAsync(vm => vm.AttachCommand, upload);
var avatar = host.Root.Snapshot().Get(vm => vm.Avatar).GetProperty("url").GetString()!;
var served = await host.ReadBlobAsync(avatar); // served.StatusCode, served.ContentType, served.Content
A ViewModel test without a window uses RunicUpload.FromBytes and
RunicBlob.FromBytes, and reads a blob with RunicBlob.OpenReadAsync.
The whole application in a browser
RunicWindowTestHost runs the ViewModels without a page. To test the real page
against the real ViewModels, start the built application in serve-only mode with
RunicServeOnlyApplication and point a browser at its URL. It works with an
application on Runic Desktop or CS-WebUI and with any browser driver. With
Playwright for .NET and TUnit:
[Test]
public async Task IncrementsTheCount()
{
// The application, built next to the tests, for example through a ProjectReference.
var start = RunicServeOnlyApplication.CreateStartInfo(
Path.Combine(AppContext.BaseDirectory, "MyApp.dll"));
await using var app = await RunicServeOnlyApplication.StartAsync(start);
using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync();
var page = await browser.NewPageAsync();
await page.GotoAsync(app.Url.ToString());
await page.GetByRole(AriaRole.Button, new() { Name = "Increment" }).ClickAsync();
await Assertions.Expect(page.GetByTestId("count")).ToHaveTextAsync("1");
var children = app.GetDescendantProcesses(); // while the application runs
await browser.CloseAsync();
await Assert.That(await app.StopAsync()).IsEqualTo(0);
await Assert.That(children.All(child => child.HasExited())).IsTrue();
}
StartAsync sets RUNIC_APPLICATION_SERVE_ONLY=1, removes DISPLAY and
WAYLAND_DISPLAY, so no window can open, and waits up to 30 seconds (its
timeout) for the first RUNIC_APPLICATION_URL= line. It reports an
application that exits or stays silent with its standard output and standard
error, which StandardOutput and StandardError also return. Add arguments
or environment variables to the ProcessStartInfo before starting it;
CreateStartInfo runs a .dll with dotnet. StartAsync uses the file name,
arguments, working directory and environment and leaves the ProcessStartInfo
unchanged, so it can start the application again.
An application that opens more than one Window writes a URL for each.
Urls lists those written so far, in the order the Windows opened, and
WaitForWindowUrlAsync waits for one, by its position or by its Window type:
var settings = await app.WaitForWindowUrlAsync("SettingsWindow", TimeSpan.FromSeconds(10));
var second = await app.WaitForWindowUrlAsync(1, TimeSpan.FromSeconds(10));
The type is the Window class's full name or its name without the namespace,
as the host names it on its RUNIC_APPLICATION_WINDOW= line from 0.16 on.
A closed generic Window type has no spaces there: its generic definition's
name with its arguments' names without namespaces, such as
DocumentWindow`1[Report].
A wait that runs out throws TimeoutException and leaves the application
running. A wait on an application that has exited throws
InvalidOperationException.
StopAsync writes a line to standard input, which ends serving on every
platform, and returns the exit code. When the application has not exited within
its timeout (30 seconds), its process tree is killed and TimeoutException
is thrown. The line is written once: another StopAsync, for example after a
cancelled one, waits for the same exit with its own timeout, and after a kill
it throws InvalidOperationException. Disposing stops the application the same
way but kills a hanging one without an exception, so call StopAsync when a
test should fail on a shutdown that hangs. To check that the application ends
the processes it started, such as a long-running tool it cancels on shutdown,
list them with GetDescendantProcesses while it runs and call HasExited()
on each after StopAsync. A process whose parent has exited is no longer listed
as a descendant. Each RunicDescendantProcess is a snapshot of the process id
and start time that holds no process handle, so there is nothing to dispose.
HasExited() looks the process up again and also reports a process whose id
the system has since given to another process as exited.
A test process that exits without disposing the application still ends it on a best-effort basis: on Windows the application runs in a job object that ends with the test process, even when the test process is killed, and elsewhere the application is killed when the test process exits normally. A test process that is killed itself on Linux or macOS, for example with SIGKILL or by a crash, can leave the application running.
The page connects to the real Bridge, and the serve-only surface admits one
browser. A test in TypeScript can follow the same protocol, described in the
Runic.Application.Views.Desktop automation
section: start the application with RUNIC_APPLICATION_SERVE_ONLY=1, read the
RUNIC_APPLICATION_URL= line, and write a line to its standard input to stop it.
Platform services in a browser test
The application in a serve-only browser test runs in its own process, so a test
scripts its platform fakes through files. The application opts in from a test
build: call services.AddRunicServeOnlyTestPlatform() after its own
AddRunicPlatformServices(). It does nothing unless the process runs with both
RUNIC_APPLICATION_SERVE_ONLY=1 and RUNIC_TEST_PLATFORM_DIRECTORY set. Then
it registers the fakes only when the build opted in as well: the application's
entry assembly must carry [assembly: RunicServeOnlyTestPlatform], or the call
throws InvalidOperationException. It also throws when the directory is
missing or, outside Windows, when users other than its owner can write to it
(RunicServeOnlyTestPlatform.Create() makes it 0700). When the fakes are
active, the first window logs warning 1158 ServeOnlyTestPlatformActive in the
category Runic.Application.Testing. Production builds must not reference this
package, so keep the call, the attribute and the reference behind a test-only
build, for example:
<ItemGroup Condition="'$(Configuration)' == 'E2E'">
<PackageReference Include="Runic.Application.Testing" />
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)' == 'E2E'">
<DefineConstants>$(DefineConstants);RUNIC_E2E</DefineConstants>
</PropertyGroup>
<ItemGroup Condition="'$(Configuration)' == 'E2E'">
<AssemblyAttribute Include="Runic.Application.Testing.RunicServeOnlyTestPlatformAttribute" />
</ItemGroup>
services.AddRunicPlatformServices();
#if RUNIC_E2E
services.AddRunicServeOnlyTestPlatform();
#endif
The test creates a RunicServeOnlyTestPlatform, which owns a temporary directory,
points the application at it and queues responses before the page action:
using var platform = RunicServeOnlyTestPlatform.Create();
var start = platform.Configure(RunicServeOnlyApplication.CreateStartInfo(appPath));
await using var app = await RunicServeOnlyApplication.StartAsync(start);
await page.GotoAsync(app.Url.ToString());
platform.EnqueueSaveFile("report.csv");
await page.GetByRole(AriaRole.Button, new() { Name = "Export" }).ClickAsync();
var commit = await platform.WaitForEventAsync(e => e.Operation == "commit");
Assert.Equal("a,b\n", commit.GetText("content"));
EnqueueOpenFile, EnqueueSaveFile, EnqueueOpenDirectory,
EnqueueFileDialogDismissed, EnqueueMessageBoxAnswer,
EnqueueContextMenuChoice and SetClipboard script the fakes. ReadEvents()
and WaitForEventAsync(match) return each call the application made, such as
fileDialogs.saveFile with its title, fileDialogs.commit with the file and
its base64 content, clipboard.write, messageBoxes.show with the button
chosen and notifications.show. Every window of the application shares one
platform, since the script is one queue; closing a window does not close it.
A scripted line that the application cannot apply, such as malformed JSON or an
unknown service, does not fail the application's call: it is recorded as a
script.error event with the message, and WaitForEventAsync then throws
InvalidOperationException with that message.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.14)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.14)
- Microsoft.Extensions.TimeProvider.Testing (>= 10.10.0 && < 11.0.0)
- Runic.Application.Views (= 0.16.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.16.0 | 358 | 11/4/2026 |
| 0.15.1 | 1,403 | 11/2/2026 |
| 0.15.0 | 225 | 10/31/2026 |
| 0.14.0 | 1,036 | 10/30/2026 |
| 0.13.0 | 1,989 | 10/29/2026 |
| 0.12.1 | 602 | 10/27/2026 |
| 0.12.0 | 206 | 10/27/2026 |
| 0.11.0 | 1,765 | 10/25/2026 |
| 0.10.0 | 3,482 | 10/22/2026 |
| 0.9.1 | 601 | 10/21/2026 |
| 0.9.0 | 171 | 10/21/2026 |
| 0.8.0-preview.6 | 984 | 10/19/2026 |
| 0.8.0-preview.5 | 328 | 10/17/2026 |
| 0.8.0-preview.4 | 806 | 10/17/2026 |
| 0.8.0-preview.3 | 648 | 10/15/2026 |
| 0.8.0-preview.2 | 115 | 10/15/2026 |
| 0.8.0-preview.1 | 140 | 10/15/2026 |
| 0.7.0-preview.9 | 400 | 10/14/2026 |
| 0.7.0-preview.8 | 95 | 10/12/2026 |
| 0.7.0-preview.7 | 64 | 10/12/2026 |