Browsers
The web module needs Playwright's browsers on the machine, and the package carries the driver rather than the browsers themselves. A launch that finds nothing to drive installs what it needs and launches again:
{
"Web": { "InstallBrowsers": "Auto" }
}
Auto is the default, so a new machine and a fresh agent image both run a web test without a
separate step. It costs nothing on a machine that already has the browser — the install only happens
off a launch that has already failed, once per run, under the same lock that launches the browser —
and the download is reported into the test's own log rather than the console.
Never fails instead, in Playwright's own words. That is what an air-gapped agent wants, and what an
image that was supposed to bake the browsers in should say when it did not. Always installs before
launching every time, which costs a driver round trip per run to confirm what is usually already
there.
To install by hand — under Never, or to warm an image before the suite runs:
pwsh Checkout.Web.Tests/bin/Debug/net10.0/playwright.ps1 install chromium
Without PowerShell, invoke the bundled driver directly:
Checkout.Web.Tests/bin/Debug/net10.0/.playwright/node/darwin-arm64/node Checkout.Web.Tests/bin/Debug/net10.0/.playwright/package/cli.js install chromium
Which browser#
{
"Web": { "Browser": "chromium" }
}
chromium, firefox and webkit are Playwright's own builds, downloaded into its cache. chrome
and edge are the browsers the machine has installed, driven through Chromium — which is what
"does it work in real Edge" actually asks, and what Playwright's bundled Chromium cannot answer.
Their pre-release channels are named the way the vendors name them: chrome-beta, chrome-dev,
chrome-canary, edge-beta, edge-dev, edge-canary. safari is accepted as a name for WebKit.
A channel can also be set on its own, which is how a pipeline moves an otherwise unchanged suite onto the agent's browser:
{
"Web": { "Browser": "chromium", "Channel": "msedge" }
}
Channel wins when both are given. Anything that is neither an engine nor a channel fails the run
against Web:Browser rather than reaching Playwright, whose failure would name a browser the
consumer will not find in their settings file.
InstallBrowsers covers the engines only. Installing one fills Playwright's own cache; installing a channel
runs the vendor's installer against the machine, which is more than an unattended recovery from a
failed launch should do by itself — so a missing Chrome or Edge fails with the path Playwright looked
in, and installing it takes either Always or a line in the image build.
Emulating a device#
{
"Web": { "Device": "iPhone 15" }
}
The names are Playwright's own — iPhone 15, Pixel 7, iPad Mini landscape, Desktop Safari —
and are matched however they were typed. One name sets the viewport, the user agent, the device scale
factor and the touch and mobile flags together, which is the combination that makes a page serve its
mobile layout rather than a narrow desktop one.
Emulation is what the page is told about itself, not which engine renders it: a device Playwright
associates with WebKit is still driven by whatever Browser says. One setting quietly deciding
another is how a run ends up in an engine nobody chose, and the mobile layout — the reason to emulate
a phone at all — does not depend on the engine. Name webkit as well when the engine is the point.
A device that is not in the registry fails the run against Web:Device.
More than one user in a scenario#
Browser.StartAsync() is the test's own session. A name opens another one beside it, in the same browser and sharing nothing else — its own context, its own cookies, its own storage:
var alice = await Browser.StartAsync("alice");
var bob = await Browser.StartAsync("bob");
await alice.Page.GetByRole(AriaRole.Button, new() { Name = "Share" }).ClickAsync();
await Assert.That(bob.Page.GetByText("Alice shared a document")).ToBeVisibleAsync();
Every member of Browser takes the name, so Browser.PageAsync("bob") and Browser.GoToAsync("/inbox", "bob") reach the same session; asking for a name twice in one test hands back the session that name already has. Each is disposed with the test, and each files its artifacts under its own name — failure-alice.png, trace-bob.zip — so two users failing in one scenario do not overwrite each other's evidence.
Page objects for two users#
Page objects read IPageContext.Page on every use, and that property is assignable — so acting as another user is pointing the context at their page:
context.Page = (await Browser.StartAsync("bob")).Page;
await signIn.SignInAsync("bob", password); // every page object is now Bob's
context.Page = alice.Page; // and back again
There is one context per test and every page object follows it together, which is what keeps this to an assignment rather than a second set of objects to keep track of. The cost is that the context points at one page at a time, so three things are the suite's own to handle:
- Two users acting at the same moment — a wait armed on Bob's page while Alice clicks — needs Bob's
IPagedirectly, since one property cannot be two pages. - A page object holding state holds it for the test, not for a user. Two users sharing one instance share whatever it remembers.
- Restoring after a failure. An assertion that throws between the two assignments leaves the context on the wrong user for the rest of the scenario, including its
[AfterTest]hook. Put the restore in afinally, or a Reqnroll[AfterScenario], where it matters.
A suite where that first cost bites often — two users acting at once, rather than a scenario handing control between them — can give each user an actor instead of sharing one context. See Screenplay, which drives these same named sessions.
A test that starts only named sessions has no default session for the injected context to fall back to, and is told so by name rather than being told it never opened a browser:
No browser session has been started for this test. … This test has started 'alice' and 'bob',
which the injected context does not follow until it is pointed at one:
context.Page = (await Browser.StartAsync("bob")).Page.
Signing in once#
A suite that signs in per test spends most of its run signing in. Playwright's storage state — the cookies and local storage of a signed-in context — is how that becomes one sign-in for the whole run, and IPreceptStartup.BeforeRunAsync is where it happens:
public async Task BeforeRunAsync(IServiceProvider services)
{
await using var session = await services.GetRequiredService<BrowserFactory>().NewSessionAsync();
await session.Page.GotoAsync("/sign-in");
await session.Page.GetByLabel("Email").FillAsync("qa@example.com");
await session.Page.GetByLabel("Password").FillAsync(Environment.GetEnvironmentVariable("QA_PASSWORD"));
await session.Page.GetByRole(AriaRole.Button, new() { Name = "Sign in" }).ClickAsync();
await session.SaveStorageStateAsync();
}
Every session started from then on begins signed in. BeforeRunAsync has no test to belong to, which is why the session comes from BrowserFactory.NewSessionAsync() rather than from Browser: it records nothing — no trace, no video, no screenshot — because there is no test to attach any of it to.
The state is filed under the session's name, and a session starts from the state filed under its name. That is what gives a suite two identities:
await using var admin = await factory.NewSessionAsync("admin");
// sign in as an administrator, then:
await admin.SaveStorageStateAsync();
var admin = await Browser.StartAsync("admin"); // already signed in as one
A state produced somewhere else — by a previous run, or by a tool outside the suite — is named as a file instead, and seeds the default identity:
{
"Web": { "StorageState": "auth.json" }
}
The path is resolved against the working directory, which is normally the test project's output. A file that is not there fails the run: seeding silently from nothing would leave every test signed out and every failure worded as though the application had rejected the credentials.
A saved state is never attached to the test as an artifact, whatever else the module attaches. It is a session cookie or a bearer token in a file — the credential itself, not a record of one — and an artifact goes wherever the report goes.
What the page says about itself#
Console errors and uncaught exceptions go into the test's own log, where they reach the TRX, a test explorer and every reporter — rather than to a process console that belongs to no test:
[console:error] Failed to load resource: the server responded with a status of 500
[page error] TypeError: order is undefined
ConsoleLogging decides how much of the console gets there:
None |
Nothing from the console is logged. |
Errors |
The error lines only. The default. |
All |
Every message the page writes, whatever its level. |
Errors is the default because an error a test was not failed by is still an account of what went wrong, while the rest of an application's logging is normally noise — and occasionally the only account of what the page thought it was doing, which is what All is for. None is for the application whose steady state is noisy, where the errors make every test's report bulky rather than one test's report useful:
{
"Web": { "ConsoleLogging": "None" }
}
The setting decides what is written down, not what is watched. Console errors are collected at every level, so the session still carries them and FailOnConsoleErrors still fails on them and lists them. Uncaught page exceptions are logged at every level too, because no application has a steady state that throws them.
Either can also decide the verdict:
{
"Web": { "FailOnConsoleErrors": true, "FailOnPageErrors": true }
}
Both are off by default, because an application with an error in its steady state would fail every test at once and teach the suite to be ignored. FailOnPageErrors is the narrower net and the stronger signal — an uncaught exception is a bug on the page rather than something the application chose to log. The check runs after the test's last line, so a test that passed every assertion and left a broken page still fails, and still gets its failure screenshot and trace.
The errors are on the session as well, for a test that would rather assert on them than be failed by them:
await Assert.That(Browser.Current!.ConsoleErrors).ToBeEmptyAsync();
Files the page produced#
A file the page downloads is saved into the test's artifacts and reported with it. Playwright deletes a download when its context closes — which is the end of the test — so without this the export a test just triggered is gone by the time anyone reads the report. Turn it off with "SaveDownloads": false.
The copy is waited for as the test tears down, for no longer than TimeoutMilliseconds. A download the server never finishes sending is logged as missing from the artifacts instead of holding the test open — by then the test's last line has run, and nothing is left to time it out.
HarOnFailure records the session's network traffic and attaches it to a failing test, deleting it after one that passed:
{
"Web": { "HarOnFailure": true }
}
Off by default, because the Playwright trace already carries the requests for a failure being investigated in the trace viewer. A HAR is for the failure being investigated somewhere else — by an API team, in a browser's own network panel, or by a tool that reads HARs.
A browser somewhere else#
{
"Web": { "WsEndpoint": "ws://grid.internal:3000/" }
}
A browser grid, or a vendor's cloud, is connected to rather than launched. CdpEndpoint connects to a running Chromium over the DevTools protocol instead — http://localhost:9222, for driving a browser that is already open — and is second choice where both would work, since that protocol is the browser's own rather than Playwright's and supports less of it. WsEndpoint wins when both are set.
Connecting replaces launching, so everything about starting a process stops applying: Headless, Args, Channel, Proxy and InstallBrowsers all describe a browser this machine is no longer starting. What a context is opened with still applies, because the context is still the suite's to describe.
Viewport size#
Every test gets its own browser context, and each one is opened at a fixed viewport — the size the page believes it has, which is what a media query, a layout and a screenshot go by. It is not an operating-system window, so it means the same headless as headed, where the window around it is larger.
Name it as a resolution:
{
"Web": { "Resolution": "1080p" }
}
720p, 900p, 1080p, 1440p and 2160p are understood, as are the aliases a machine is
normally described by — hd, fhd, qhd, uhd and 4k. Names are matched however they were
typed. Anything else is written as a pair, 1366x768, which is how to reach a size no name covers:
{
"Web": { "Resolution": "1366x768" }
}
A value that is neither fails the run and says so against Web:Resolution. The alternative would be
a suite that quietly ran at some other size, and a viewport is a setting whose effect is only
visible in a screenshot taken after the run.
Resolution is unset by default, in which case ViewportWidth and ViewportHeight decide the
viewport — 1280 by 800 unless a project says otherwise:
{
"Web": { "ViewportWidth": 1440, "ViewportHeight": 900 }
}
When a resolution is named it decides both, and those two are ignored. That is what lets a
pipeline set PRECEPT_WEB__RESOLUTION without knowing what the settings file already says: an
overlay or an environment variable cannot clear numbers it cannot see, and a run should not end up
a width from one file tall by a height from another.
A Device outranks the numbers for the same reason — they have defaults, and a default cannot be
told apart from a deliberate 1280 by 800 — while a named Resolution outranks the device. The most
specific statement of a size wins, and the one an overlay can set wins over the ones it cannot see.
What else a context is opened with#
The context is opened for the test rather than by it, so anything the browser needs to be told before the first navigation is a setting rather than something test code can arrange.
Locale and time#
{
"Web": { "Locale": "en-GB", "TimezoneId": "Europe/London", "ColorScheme": "dark" }
}
Locale is a BCP 47 tag; it decides the Accept-Language header and how the page formats dates and
numbers. TimezoneId is an IANA name. Both are worth setting even where the application is
single-language and single-region: unset, they are the machine's, so an assertion about a formatted
date can pass on a laptop and fail on an agent elsewhere — with a failure that reads like a bug in
the page. ColorScheme is light, dark or no-preference, and is what the page's
prefers-color-scheme media queries see.
Position and permissions#
{
"Web": { "Geolocation": "51.5074,-0.1278", "Permissions": "clipboard-read notifications" }
}
Geolocation is one value, LATITUDE,LONGITUDE, for the reason Resolution is one: a coordinate is
a pair or it is nothing, and a variable that could set only the latitude would put a run somewhere
neither file meant. Setting it also grants the geolocation permission — without it the page is
refused the position rather than given it, and the setting looks ignored.
Permissions are separated by spaces or commas and granted to every context up front, which is what
stops a permission prompt no test can click. The names are the browser's own and are passed through
untouched, so one the browser does not recognise fails the context rather than being ignored.
Reaching a protected environment#
{
"Web": {
"IgnoreHttpsErrors": true,
"HttpCredentials": { "Username": "qa", "Origin": "https://staging.example.com" },
"Headers": { "X-Test-Run": "nightly" }
}
}
IgnoreHttpsErrors is what makes an environment behind a self-signed or internally-issued
certificate reachable at all; leave it off where the certificate is real. HttpCredentials answers
HTTP basic authentication, and its Origin is what keeps the credentials from being offered to a
third-party host the page loads an asset from. Headers are added to every request the context
makes — an API gateway key, a feature-flag override, the header a test environment identifies its
callers by.
The password belongs in PRECEPT_WEB__HTTPCREDENTIALS__PASSWORD rather than in a file the repository
carries. See Environment variables for how a
setting maps onto a variable name.
Headers is a JSON object rather than the one-string treatment Args gets, because configuration
keys an object by name: an overlay adding a header keeps the ones underneath it and replaces only the
header it names. That is the merge an array cannot give.
Through a proxy#
{
"Web": { "Proxy": { "Server": "http://proxy.internal:8080", "Bypass": "localhost,*.internal" } }
}
Applied when the browser is launched rather than per context, because the browser is launched once
for the run and a process-wide connection setting cannot be varied per test. Username and
Password go alongside Server where the proxy authenticates. Bypass is normally where a
container or a local stub goes, since a proxy cannot route to the agent's own loopback.
Browser switches#
Anything with no setting of its own, and only for Chromium, goes to the browser process as a command line:
{
"Web": { "Args": "--disable-dev-shm-usage --lang=de-DE" }
}
These reach the process, so they are run-wide — Precept launches one browser and gives each test its own context, and a process argument cannot be varied per context. They are the engine's own flags, passed through untouched: Playwright neither validates them nor translates them between engines, and Chromium ignores a switch it does not recognise rather than failing. A typo therefore costs a run that quietly did not do what the flag asked, which is worth knowing before reaching for this over a real setting.
Quoting groups a value carrying spaces, and either quote character works —
--user-agent='Mozilla/5.0 (X11; Linux)' arrives as one argument. Single quotes are worth reaching
for first, since a double quote has to be escaped to survive JSON. A backslash is a path separator
rather than an escape, because --disk-cache-dir=C:\cache means what it says. A quote that is never
closed fails the run against Web:Args rather than reaching a browser as a mangled flag list.