The documentation comes from the Markdown files in the source code, so is always up-to-date but available only in English. Enjoy!
UI tests use xUnit.v3 + Playwright with Signum proxy abstractions.
Every test class should use a shared base (like SouthwindTestClass) and call BrowseAsync:
public class YourTestClass : SouthwindTestClass
{
[Fact]
public async Task SomeTestAsync()
{
// Arrange data
await BrowseAsync("System", async b =>
{
// b is SouthwindBrowser
// Act + Assert through proxies
});
}
}Keep test methods as async Task.
A proxy wraps Playwright interactions for a page/control and exposes domain actions.
BrowserProxy is the root proxy for browser/session navigation.
Typical responsibilities:
LoginAsync / LogoutAsync
SearchPageAsync(...)
FramePageAsync<T>(...)
FindRoute(...), NavigateRoute(...)
Inherit from BrowserProxy in your app (SouthwindBrowser) and add custom app navigation methods.
ModalProxy is the base modal proxy for Bootstrap modals.
It implements IAsyncDisposable (and IDisposable) so modal lifecycle/close behavior is handled automatically.
Common modal proxies:
FrameModalProxy<T>: entity frame modal (lines + operations)SearchModalProxy: search control modalSelectorModalProxy: type/value selection modalAutoLineModalProxy: dynamic single-line modalMessageModalProxy: confirmation/info modalErrorModalProxy: error modalYou can create strongly-typed modal proxies from an ILocator using NewAsync(...).
NewAsync waits for the modal main content to load before returning.
Example:
ILocator locator = await page.CaptureModalAsync(() => someButton.ClickAsync());
var modal = await FrameModalProxy<OrderEntity>.NewAsync(locator);Start with the generic capture primitive:
CaptureModalAsync(this IPage, Func<Task>): executes an action and captures the newly opened modal (ILocator).Convenience capture methods:
CaptureOnClickAsync()CaptureOnDoubleClickAsync()OperationClickCaptureAsync(...)Methods that already return strongly-typed proxies (typical flows):
EntityBaseProxy.ViewAsync(...)EntityBaseProxy.CreateModalAsync<T>()SearchControlProxy.CreateAsync<T>()ResultTableProxy.EntityClickAsync<T>(...)EntityButtonContainerExtensions.ConstructFromAsync<F, T>(...)Use .Then(...) to chain async proxy operations safely. It handles disposal automatically and expresses modal nesting clearly.
Then is an extension method on Task<T> for chaining async proxy operations.
Use modal casting helpers when chaining from a captured ILocator:
.Then(loc => loc.AsSearchModal()).Then(loc => loc.AsFrameModal<T>())Why preferred:
Guideline: prefer .Then(...) over using in modal-heavy tests.
AVOID using var ... without an explicit block (Dispose is called too late).
Example:
await page.OperationClickCaptureAsync(OrderOperation.Ship)
.Then(a => a.AsSearchModal())
.Then(async sm =>
{
await sm.SearchAsync();
await sm.Results.EntityClickAsync<OrderEntity>(0)
.Then(async orderModal =>
{
await orderModal.OperationClickCaptureAsync(OrderOperation.Save)
.Then(loc => loc.AsFrameModal<OrderEntity>())
.Then(async nextModal =>
{
await nextModal.OkWaitClosedAsync();
});
});
});ILineContainer / ILineContainer<T> represent a UI container with typed lines.
Core members:
Element: root locator for line resolutionRoute: PropertyRoute used for strongly-typed navigationCommon implementations:
FramePageProxy<T>FrameModalProxy<T>LineContainer<T>EntityTableRow<T>LineContainerExtensions adds strongly-typed line access:
TextBoxLine(...), EntityLine(...), EntityTable(...), etc.AutoLine(...) for automatic proxy selectionAutoLineValueAsync(...) strongly-typed get/setBaseLineProxy is the root abstraction for all line proxies.
Main concrete line proxies include:
CheckboxLineProxy, DateTimeLineProxy, EnumLineProxy, FileLineProxy, GuidBoxLineProxy, HtmlEditorLineProxy, NumberLineProxy, TextAreaLineProxy, TimeLineProxy
EntityBaseProxy, TextBaseLineProxy)Example:
await b.FramePageAsync<OrderEntity>()
.Then(async order =>
{
await order.AutoLineValueAsync(o => o.Reference, "SO-1001");
await order.AutoLineValueAsync(o => o.OrderDate, Clock.Now);
var reference = await order.AutoLineValueAsync(o => o.Reference);
await order.EntityTable(o => o.Details).CreateRowAsync<OrderDetailEmbedded>()
.Then(async row =>
{
await row.AutoLineValueAsync(d => d.Quantity, 5);
});
});IEntityButtonContainer / IEntityButtonContainer<T> represent proxies with operation buttons.
EntityButtonContainerExtensions provides helpers like:
OperationButtonAsync(...), OperationEnabledAsync(...)
OperationClickAsync(...), OperationClickCaptureAsync(...)
ExecuteAsync(...), DeleteAsync(...)
ConstructFromAsync<F, T>(...)Example:
await b.FramePageAsync<OrderEntity>(order.ToLite())
.Then(async page =>
{
await page.ExecuteAsync(OrderOperation.Save);
await page.ConstructFromAsync<OrderEntity, InvoiceEntity>(OrderOperation.InvoiceFrom)
.Then(async invoiceModal =>
{
await invoiceModal.OkWaitClosedAsync();
});
});Proxy classes should have a comment pointing to the equivalent .tsx file.
There are typically three ways of writing custome proxies and making them discoverable and strongly-typed:
Add methods to SouthwindBrowser for page-level proxies. Example:
public class SouthwindBrowser : BrowserProxy
{
public SouthwindBrowser(IPage page) : base(page) { }
public async Task<SalesDashboardPageProxy> SalesDashboardAsync()
{
await Page.GotoAsync(Url("sales/dashboard"));
return await SalesDashboardPageProxy.NewAsync(Page);
}
}
// Proxy for SalesDashboardPage.tsx
public class SalesDashboardPageProxy : IAsyncDisposable
{
public IPage Page { get; }
SalesDashboardPageProxy(IPage page) { Page = page; }
public static async Task<SalesDashboardPageProxy> NewAsync(IPage page)
{
await page.Locator(".sales-dashboard").WaitVisibleAsync();
return new SalesDashboardPageProxy(page);
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}Often add extension methods on ILineContainer<SomeEntity> (or SearchControlProxy).
// Proxy for InvoiceCalculations.tsx
public class CalculationsProxy
{
public ILocator Element { get; }
public CalculationsProxy(ILocator element) { Element = element; }
public Task<string?> GetTotalAsync()
=> Element.Locator(".calc-total").TextContentAsync();
public async Task ApplyDiscountAsync(string percent)
{
await Element.Locator(".calc-discount").FillAsync(percent);
await Element.Locator(".calc-apply").ClickAsync();
}
}
public static class InvoiceLineContainerExtensions
{
public static CalculationsProxy Calculations(this ILineContainer<InvoiceEntity> c)
=> new CalculationsProxy(c.Element.Locator(".invoice-calculations"));
}Example (simple case: extension only, no new proxy class):
public static class InvoiceSimpleExtensions
{
public static async Task RecalculateAsync(this ILineContainer<InvoiceEntity> c)
{
await c.Element.Locator("button.sf-recalculate").ClickAsync();
}
}© Signum Software. All Rights Reserved.
Powered by Signum Framework