Skip to main content

Web

ProtoTest.Web gives each test a browser session behind page objects, with Playwright or Selenium underneath.

[ProtoTest]
public async Task Valid_credentials_sign_in()
{
var login = Proto.Context.Web().Page<LoginPage>();
await login.OpenAsync("/login");

await login.Form.Flow("Sign in")
.Fill(form => form.Username, "matthias")
.Fill(form => form.Password, "correct horse")
.Click(form => form.Submit)
.RunAsync();

await login.Form.Status.Should.HaveTextAsync("Signed in");
}

Run it with dotnet test. A green run prints Passed Valid_credentials_sign_in, and the trace lands at TestResults/prototest-{runId}.prototrace with a web.session.initialize operation holding every step the browser took. The page objects behind the test live under The tasks; the two backends that can run them are compared under Which backend.

What it adds​

ProtoTest.Web is a browser-testing model for pages, components, elements, tables, flows and login that doesn't depend on any particular browser driver. A backend package plugs a real driver in underneath:

  • ProtoTest.Web.Playwright - launches and manages browsers for you.
  • ProtoTest.Web.Selenium - drives any IWebDriver you create.

Your page objects and tests stay the same for both. The backends differ underneath, most visibly in locator translation, but everything in this section is backend-neutral.

Which backend​

Default to Playwright. It launches and manages browsers for you, downloads the browser on a clean machine with InstallBrowsers, probes for it without launching one for skips, records the native Playwright trace, supports downloads, and translates the wider set of locator combinations. Pick Selenium when the browsers are driven through WebDriver already: a grid, a driver setup, or a browser you provide yourself through your own IWebDriver factory.

QuestionPlaywrightSelenium
DefaultStart here for a new suiteWhen WebDriver drives your browsers already
Browser installInstallBrowsers = true downloads the selected browser before the first launch; Channel ("msedge", "chrome") uses an installed system browser insteadYou create the IWebDriver, so the browser must already be installed on the machine
Missing browser[RequiresPlaywrightBrowser] probes without launching and skips with a reason naming the install optionsNo probe; gate on the browser capability and handle the first session use (see Skip)
Sessions and poolingOne test-scoped pool; sessions with identical launch options share a browser process inside the testOne driver per session, no pooling, so sessions share no cookies or storage
Channels and system browsersChannel names an installed browser; Linux system libraries come from Playwright's own toolingAny driver your factory builds, with its own options and grid address
DownloadsDownloadAsync registers the file as a test attachmentNo download API in the WebDriver protocol; the call fails with WebBackendCapabilityException before the trigger runs
Locator parityARIA role resolution and native text matching, with the wider And combinationsImplicit HTML role mappings instead of ARIA resolution, XPath 1.0 deepest-match text, and only HasText as the right-hand side of And with no By.Css left side (see Locators)
Diagnostics artifactThe native Playwright trace (playwright-{session}-trace.zip, kept per TraceRetention), plus console, page-error and failed-request eventsProtoTest's own selenium-{session}-diagnostics.json with every actionability attempt (kept per DiagnosticTraceRetention); there is no second report system
What both shareThe same page objects and tests, the same web.* trace vocabulary with a web.backend.execute child per driver call, and the same failure screenshot, page HTML and location filesThe same

Install​

dotnet add package ProtoTest.Web.Playwright
# or
dotnet add package ProtoTest.Web.Selenium

Either backend brings ProtoTest.Web with it. The packages target net8.0, net9.0 and net10.0; the project templates default to net10.0, so pass --framework net8.0 or --framework net9.0 when a suite targets an older baseline.

Browsers​

Playwright launches Chromium by default on Windows, Linux, and macOS. Set Channel ("msedge", "chrome") to use an installed system browser instead. InstallBrowsers = true downloads the selected browser through the Playwright driver before the first launch, so a clean machine or CI runner needs no separate step. It is ignored when Channel names a system browser:

builder.AddWeb(options =>
{
options.Browser = PlaywrightBrowser.Chromium; // the default
options.InstallBrowsers = true; // download it when missing
});

On Linux, the operating-system libraries a bundled browser needs come from Playwright's own tooling; ProtoTest only runs the driver's install command for the browser binary.

Selenium takes the driver factory you provide, so the browser itself must already be installed on the machine.

A machine may have no browser at all. Gate Playwright tests with the opt-in skip condition from the Playwright package (see Skip):

[RequiresPlaywrightBrowser] // the configured browser
[RequiresPlaywrightBrowser(PlaywrightBrowser.Firefox)]
[RequiresPlaywrightBrowser(channel: "msedge")]
public async Task ...() { ... }

It probes the installed browser before the lifecycle starts, without launching one, and skips with a reason naming Playwright and the install options; InstallBrowsers = true never skips. Selenium has no equivalent probe (see Skip).

A session uses its application address from ProtoTest:Applications:{application}:BaseUrl. It appends Endpoints:{endpoint} when the session names one. Infrastructure that started an application with the run advertises that same setting, so a browser journey can run against a standalone instance, an application image in its own container or an in-process loopback listener without fixture code (ASP.NET Core has both recipes).

Compose​

Registration is the same AddWeb(...) call regardless of backend; the reference you install selects the backend. On a host builder:

public static IProtoHostBuilder AddWeb(
this IProtoHostBuilder builder,
Action<PlaywrightWebOptions>? configure = null); // Playwright

public static IProtoHostBuilder AddWeb(
this IProtoHostBuilder builder,
Func<IWebDriver> createDriver,
Action<SeleniumWebOptions>? configure = null); // Selenium

On an application builder, the form the sample suite uses, so the application's REST and GraphQL clients can share one address:

public static IProtoApplicationBuilder AddWeb(
this IProtoApplicationBuilder application,
Action<PlaywrightWebOptions>? configure = null);

public static IProtoApplicationBuilder AddWeb(
this IProtoApplicationBuilder application,
Func<IWebDriver> createDriver,
Action<SeleniumWebOptions>? configure = null);

The shipped backends compose shared helpers from ProtoTest.Web, so a hand-written backend behaves the same way. See Writing a backend.

Sessions are not declared at registration: a test names the sessions it needs with Proto.Context.Web(name) (below). AddWebBackend keeps the first registration. A host resolves exactly one IWebBackendFactory. Zero or more than one throws InvalidOperationException. For Playwright, every AddWeb(...) call still runs its configure callback while only the first supplies the skip-probe defaults; the Selenium application overload guards the whole call so a repeat is a no-op.

Options and keys​

Both backends also read their options from configuration, so CI can run headless on another browser without code changes. Values are applied in this order, later winning:

  1. your AddWeb(...) callback,
  2. ProtoTest:Web:Playwright or ProtoTest:Web:Selenium, the run's backend options.

Started infrastructure settings are merged over static configuration before binding, so they win over the same key in appsettings.json. Selenium's ActionTimeout/PollInterval and Playwright's ActionTimeout are validated after binding; a non-positive value throws ArgumentOutOfRangeException. TimeSpan values bind as "hh:mm:ss(.fffffff)" and enums bind by name.

Playwright options​

Key names below are relative to ProtoTest:Web:Playwright (see PlaywrightWebOptions):

KeyTypeDefaultNotes
BrowserPlaywrightBrowserChromiumChromium, Firefox or Webkit
Headlessbooltrue
SlowMofloat? (ms)nulldelay between actions, for watching a run
Channelstring?nulle.g. "msedge" or "chrome" to use an installed browser
InstallBrowsersboolfalsedownload the selected browser before the first launch when it is missing
ActionTimeoutTimeSpan5 show long a read or action waits for its element before failing with the documented resolution/actionability exception
Locale, TimezoneId, UserAgent, ViewportWidth, ViewportHeight, StorageStatePathstring/int?unsetthe context settings ProtoTest models; a viewport needs both dimensions
MaxTraceByteslong32 MiB (33554432)the largest attached native trace; 0 reads without a cap
TraceRetentionPlaywrightTraceRetentionOnWebFailureOff, OnWebFailure, Always
CorrelateTraceGroupsbooltruegroup Playwright trace actions under ProtoTest operations
ConsoleCapturePlaywrightConsoleCaptureWarningsAndErrorsOff, Errors, WarningsAndErrors, All
CapturePageErrorsbooltrueuncaught page exceptions
CaptureRequestFailuresbooltruefailed network requests

Selenium options​

Key names below are relative to ProtoTest:Web:Selenium (see SeleniumWebOptions):

KeyTypeDefaultNotes
ActionTimeoutTimeSpan5 show long an action retries until the element is actionable
PollIntervalTimeSpan50 mshow often it re-checks
WaitForStableBoundsbooltruewait until the element stops moving before clicking
CheckClickObstructionbooltruefail if something covers the element
DiagnosticTraceRetentionSeleniumDiagnosticTraceRetentionOnWebFailureOff, OnWebFailure, Always

Demanding a session​

A test creates a session by asking for it in code. Demand it in code with the attribute:

[WebSession("Admin", Application = "BackOffice", Endpoint = "Admin", DiscoverRoutes = true,
Open = "/back-office")]

or inside the test with the accessor:

var session = Proto.Context.Web("Admin", application: "BackOffice", endpoint: "Admin");
MemberEffect
namethe session name; context.Web() uses the application's Web: binding, then "Default"
Applicationthe ProtoTest:Applications:{application} section that supplies the address and owns the session's coverage; defaults to the test's application, then the session name
Endpointjoins ProtoTest:Applications:{application}:Endpoints:{endpoint} to the base URL, like an HTTP client
DiscoverRoutesrecords the session's Vue Router routes as page inventory after the first navigation
Opennavigates the session during setup; a relative value resolves against the application address

Configuration holds the environment, never the session: addresses under ProtoTest:Applications:{application}, backend options under ProtoTest:Web:{backend}.

From configuration​

{
"ProtoTest": {
"Web": {
"Playwright": {
"Browser": "Firefox",
"Headless": true,
"TraceRetention": "Always",
"Locale": "nl-BE",
"ViewportWidth": 1280,
"ViewportHeight": 720
}
},
"Applications": {
"ControlPlane": { "BaseUrl": "https://ops.example.test" }
}
}
}

The context settings ProtoTest models (Locale, TimezoneId, UserAgent, ViewportWidth, ViewportHeight, StorageStatePath) bind from configuration. Anything else on Playwright's BrowserNewContextOptions is set in code through the ConfigureContext action, which runs after the settings above:

builder.AddWeb(options => options.ConfigureContext = context =>
context.ColorScheme = ColorScheme.Dark);

Options are bound once, the first time a session opens a browser.

Sessions​

Proto.Context.Web(sessionName = null, application = null, endpoint = null, discoverRoutes = false) returns the test's WebSession. The browser is created lazily on the first operation, so a test that never touches the browser never starts one, and the session is completed and disposed at teardown. application defaults to the application selected for the test; sessionName defaults to the application's Web client name, then "Default".

public sealed class WebSession : IAsyncDisposable
{
string Name { get; }
string Application { get; }
Uri? BaseUrl { get; }
string BackendName { get; }
TPage Page<TPage>() where TPage : WebPage, new();
TBackend GetBackend<TBackend>() where TBackend : class, IWebBackend;
ValueTask<TBackend> GetBackendAsync<TBackend>(CancellationToken cancellationToken = default)
where TBackend : class, IWebBackend;
ValueTask WaitUntilAsync(
Func<CancellationToken, ValueTask<bool>> condition,
TimeSpan? timeout = null, // default 5 s
string? description = null, // defaults to the predicate's source text
CancellationToken cancellationToken = default);
}

WaitUntilAsync polls until the condition holds or the timeout passes, throwing WebAssertionException with the expectation when it doesn't. Inside the predicate, "element not found yet" and "not actionable yet" (WebElementResolutionException, WebActionabilityException) are treated as "not yet"; every other exception fails the wait immediately.

Several sessions in one test​

Sessions are per-test: the builder registers only the backend, and a test names the sessions it needs. Each named session is an isolated browser context (Playwright) or driver (Selenium), created lazily on first use and closed at teardown. Pages are cached per session, so Web("Admin").Page<T>() returns the same object each time.

builder.AddWeb(); // register the backend once
var admin = Proto.Context.Web("Admin").Page<BackOfficePage>();
var customer = Proto.Context.Web("Customer").Page<StorefrontPage>();

await admin.Orders.RowMatching(By.HasText("42")).Approve.ClickAsync();

// The customer waits until the admin's change is reflected, then verifies it.
await customer.Orders.RowMatching(By.HasText("42")).Status.Should.HaveTextAsync("Approved");

The Should* methods on an element poll until they pass, so they double as cross-session waits. For any other condition, including one that spans sessions, use WaitUntilAsync; its description defaults to the predicate's source text:

await customer.WaitUntilAsync(async ct =>
await customer.Orders.RowMatching(By.HasText("42")).Status.TextAsync(ct) == "Approved");

await customer.WaitUntilAsync(
async _ => await customer.Total.TextAsync() == "€0.00",
timeout: TimeSpan.FromSeconds(10));

Sessions can also be declared on the test, so setup creates (and optionally navigates) them before the body. [WebSession] defaults to Order = -10, so it runs before [LoginAs]:

[WebSession("Admin", Open = "/back-office")]
[WebSession("Customer")]
[LoginAs<BackOfficeLogin>("billing.admin", Session = "Admin")]
public async Task ...() { ... }

Application = "ControlPlane" on the attribute names the application the session targets, defaulting to the test's application and then the session name. A relative Open resolves against the session's application address, ProtoTest:Applications:{application}:BaseUrl, optionally joined with the named endpoint.

Dropping down to the driver​

When you need something the model doesn't offer, get the native backend:

var backend = await Proto.Context.Web().GetBackendAsync<PlaywrightWebBackend>();
await backend.Page.SetContentAsync(html);

GetBackendAsync creates the browser if needed; the synchronous GetBackend throws until the backend exists, and asking for the wrong type throws WebBackendCapabilityException.

The tasks​

A page object describes the page; the sign-in test at the top of this page drives it. The smallest page objects behind that test are below; the sample's full journey is in WebJourney.cs:

public sealed class LoginPage : WebPage
{
public LoginForm Form => Component<LoginForm>(By.TestId("login"));
}

public sealed class LoginForm : WebComponent
{
public WebElement Username => Element(By.Label("Username"));
public WebElement Password => Element(By.Label("Password"));
public WebElement Submit => Element(By.Role(WebRole.Button, "Sign in"));
public WebElement Status => Element(By.Role(WebRole.Status));
}

The driving test is the one at the top of this page. The flow behind it records one web.flow operation with a step per action, and the final read records assert.web.

Going further​

  • Pages and components - page objects, scoping, lazy collections and tables.
  • Page coverage - visited versus verified, report rows, and the inventory sources.
  • Locators - roles, labels, text, And, and how each backend translates them.
  • Actions and assertions - Should/ShouldNot, polling, redaction.
  • Flows - name a sequence of steps so the trace reads as one operation.
  • Logging in - an application-owned IWebLoginStrategy applied with [LoginAs].
  • Waits and middleware - your application's notion of ready, applied once.
  • Diagnostics and artifacts - screenshots, console output, traces and the full trace reference.
  • ASP.NET Core - when the application is hosted in-process, its page inventory comes with it.

In the trace and coverage​

Every operation is a ProtoTest trace entry; a passing assertion is also what makes a page count as covered:

WhatRecorded as
Navigate, click, fill, check, select, press, reads, countweb.navigate, web.click, web.fill, web.check, web.select_option, web.press, web.read_text, web.read_value, web.is_visible, web.is_enabled, web.is_checked, web.count
Assertionsassert.web with web.expectation, web.assert.negated and web.assert.timeout
Wait-untilweb.wait.until
Flowsweb.flow with web.flow.step_count
Loginweb.login during setup
Session lifetimeweb.session.initialize / web.session.complete
Backend callchild web.backend.execute, linked by web.correlation_id
Coverageobservations web.page.visited, web.page.verified, web.page.available

The complete tables for common attributes, backend events, artifacts and the Selenium diagnostics schema live on Diagnostics and artifacts.

Page coverage​

Coverage for a browser journey is measured in pages, not lines: a page counts as covered only when a test verified something on it. The visit-to-verified states, the report rows, and the inventory sources live on Page coverage.

Skip​

  • AddWeb(...) registers a browser capability named Playwright or Selenium, so [RequiresCapability(ProtoCapabilityKinds.Browser, CapabilityName = "…")] proves the backend is composed. The required capability name must match the backend package.
  • [RequiresPlaywrightBrowser] is the stronger gate: it probes the installed browser without launching one and skips with a reason. A recognized channel is accepted as-is, because only a real launch can resolve a system browser; InstallBrowsers = true always passes.
  • Selenium ships no browser probe. Combine the capability gate with a try/catch around the first session that uses the browser, as shown in skip conditions.
  • The core web package registers no skip condition of its own: sessions are per-test, and the driver is only created on first use.

Limits​

  • One backend per host. Resolving zero or more than one IWebBackendFactory throws InvalidOperationException, and AddWebBackend keeps the first registration.
  • HasText cannot stand alone: it is a filter and must be composed with And. Selenium additionally accepts only HasText as the right-hand side and rejects a By.Css left side; Playwright accepts more combinations. See Locators.
  • WaitUntilAsync only absorbs WebElementResolutionException and WebActionabilityException; any other exception fails it immediately.
  • Scanner: a relative Source may not escape the test assembly's base directory; there is no Nuxt 2 underscore-dynamic support, only absolute route literals are collected, and there is no runtime React discovery.
  • Vue discovery latches after the first non-null route table, so a router that later adds routes in the same session is not re-read.
  • Page origin: an external redirect contributes no visited or verified coverage, and a backend that cannot report an address still passes the test.
  • Playwright: the browser pool is scoped to one test; identical launch options share a process only inside that test. Reads and actions use Playwright's own auto-waiting, bounded by ActionTimeout (5 s by default); a timeout becomes the same resolution or actionability exception Selenium raises. InstallBrowsers does nothing when Channel is set, trace groups are serialized by a semaphore and skipped when TraceRetention = Off, console/page-error/request-failure text is truncated at 4096 characters, and the skip probe starts the Playwright driver.
  • Selenium: one driver per session, no pooling, so sessions do not share cookies or storage; native failures surface as WebActionabilityException after ActionTimeout; CheckAsync and SelectOptionAsync verify the selected state after the click, so a click the page ignored fails like Playwright's auto-wait instead of passing silently; SelectOptionAsync requires exactly one option carrying the requested value.

Writing a backend​

A hand-written backend composes the same helpers the shipped backends use, so its behavior matches theirs:

IProtoHostBuilder AddWebBackend(this IProtoHostBuilder builder, IWebBackendFactory factory);

IProtoHostBuilder AddWebMiddleware<TMiddleware>(this IProtoHostBuilder builder)
where TMiddleware : class, IWebOperationMiddleware;

IProtoHostBuilder AddWebWait<TCondition>(
this IProtoHostBuilder builder,
WebWaitTiming timing,
TimeSpan? timeout = null, // default 5 s
TimeSpan? pollInterval = null, // default 50 ms
params WebOperationKind[] operations) // default Navigate, Click, Fill
where TCondition : class, IWebWaitCondition;
  • WebBackendOptions.Resolve<TOptions>(context, configure, validate) - the options precedence (code callback, started infrastructure, ProtoTest:Web:{backend} section, validation).
  • WebBackendErrors - the resolution and actionability failures (NotPresent, NotActionable, MultipleMatch) with the documented wording.
  • WebFailureArtifacts.CaptureAsync(…) - the screenshot, DOM and location attachments with the documented naming rule; TraceArtifactFailure records a capture that itself failed.
  • WebProbeLoop with WebProbe - the retry loop whose observation form treats WebElementResolutionException and WebActionabilityException as "not yet", polling at the backend's PollInterval.
  • WebBackendDefaults - the 5 s action timeout and 50 ms poll interval defaults; IWebBackend.PollInterval returns WebBackendDefaults.DefaultPollInterval unless the backend has its own interval option.
  • WebArtifactNames.SafeName - the lowercased, dash-sanitized identifier rule for artifact file names.
  • WebMediaTypes.Guess - the best-effort media type for a downloaded file, with WebMediaTypes.Default as the fallback.

Next​