Browser testing
Sounding uses Playwright for browser-capable trials.
Browser trials expose a Sounding page wrapper. Use page or visit() for actions, and keep assertions under expect(page).
test() when the browser matters
Use browser trials when the real browser matters:
- sign in journeys
- mobile navigation
- editors and dashboards
- gated content flows
- checkout handoff
When a repo uses a separate functional layer, keep these browser flows under tests/e2e/pages/ and move request or Inertia contracts down into tests/functional/.
import { test } from 'sounding'
test(
'subscriber can finish a members-only issue',
{ browser: true, world: 'issue-access' },
async ({ visit, expect }) => {
const page = await visit.as('subscriber')('/issues/the-nerve-to-build')
await expect(page).toSee('The rest of the story')
expect(page).toHaveNoSmoke()
}
)Expect-first browser assertions
Sounding browser trials keep the action/assertion split clear:
await page
.click('@send-link')
.type('email', '[email protected]')
.press('Send link')
await expect(page).toSee('Check your email')
await expect(page).not.toSee('Invalid email')
expect(page).toHavePath('/check-email')Use these matchers for browser pages:
| API | Purpose |
|---|---|
expect(page).toSee(text) | Assert page text exists in the document text. |
expect(page).not.toSee(text) | Assert page text is absent. |
expect(page).toHaveUrl(url) | Assert the full URL, or path when the expected value starts with /. |
expect(page).toHavePath(path) | Assert path, query, and hash. |
expect(page).toHaveTitle(title) | Assert document title. |
expect(page).toHaveNoJavascriptErrors() | Fail on browser runtime errors. |
expect(page).toHaveNoConsoleLogs() | Fail on any console message, including log, warn, info, and error. |
expect(page).toHaveNoConsoleErrors() | Fail only on console.error. |
expect(page).toHaveNoSmoke() | Fail on JavaScript errors or console errors. |
expect(page).toMatchScreenshot(name) | Compare a full-page screenshot with an approved visual baseline. |
toHaveNoConsoleLogs() is intentionally strict. Use it when a flow should leave the browser console completely silent. Use toHaveNoSmoke() for the normal smoke-safety check.
For page-level layout contracts, read Visual regression testing.
Browser smoke helpers
Use smoke() when the trial is only trying to answer one question: do these pages boot cleanly in a real browser?
import { test } from 'sounding'
test('public pages do not smoke', async ({ smoke }) => {
await smoke(['/', '/pricing', '/contact'])
})smoke() opens the browser lazily. That means a normal request-level trial can stay light until it actually asks for browser proof:
test('pricing page has a contract and browser smoke', async ({
visit,
smoke,
expect
}) => {
const page = await visit('/pricing')
expect(page).toBeInertiaPage('marketing/pricing')
await smoke('/pricing')
})Pass a browser project when the route list should run through a named browser configuration:
test('mobile public pages do not smoke', async ({ smoke }) => {
await smoke(['/', '/pricing', '/contact'], { project: 'mobile' })
})Use visit.all() when you want the inspected route collection back:
test('public pages do not smoke', async ({ visit, expect }) => {
const pages = await visit.all(['/', '/pricing', '/contact'])
expect(pages).toHaveNoSmoke()
expect(pages.entries[0].target).toBe('/')
})Each collection entry includes:
targetprojectcurrentUrljavascriptErrorsconsoleMessagesconsoleErrorspage
On failure, Sounding stops at the first smoky route. The terminal output names the route, project, current URL, JavaScript errors, and console errors, and the browser artifacts point at that same page.
Browser test handles
Use @name for stable browser test handles. This is a Sounding convention: the @ prefix does not mean CSS, id, or JavaScript decorator. It means "find this element by its test handle."
Sounding maps @send-link to elements with data-test="send-link" or data-testid="send-link":
<button data-test="send-link">Email me a link</button>await page.click('@send-link')Prefer test handles when visible copy may change, when UI repeats the same copy, or when an element does not have a natural accessible label. Plain text and normal CSS selectors still work:
await page.click('Email me a link')
await page.click('#send-link')
await page.fill('input[name="email"]', '[email protected]')Fluent browser journeys
The Sounding page wrapper provides common browser journey actions:
await visit('/settings')
.as('owner')
.inDarkMode()
.withGeolocation(6.5244, 3.3792)
.click('@avatar')
.attach('@avatar-file', 'tests/fixtures/avatar.png')
.typeSlowly('@display-name', 'Kelvin')
.clear('@tagline')
.append('@tagline', 'Building in public')
.key('Enter')
await page.withinFrame('@billing-frame', async (frame) => {
await frame.fill('@card-number', '4242424242424242')
await frame.click('@save-card')
})Supported page actions:
| API | Purpose |
|---|---|
page.click(target) | Click visible text, a test handle, or selector. |
page.type(target, value) / page.fill(target, value) | Fill an input by label, test handle, or selector. |
page.typeSlowly(target, value) | Type with a small delay for search boxes, masks, and key-driven UI. |
page.append(target, value) | Add text to the current input value. |
page.clear(target) | Empty an input. |
page.press(target, key) | Press a key while focused on a target. |
page.select(target, value) | Select an option. |
page.check(target) / page.uncheck(target) | Toggle checkboxes and radios. |
page.hover(target) | Hover over a target. |
page.attach(target, files) | Attach one or more files to an upload input. |
page.drag(source, target) | Drag one target onto another. |
page.scroll(target) | Scroll an element into view, or scroll the page with numeric coordinates. |
page.wait(target) | Wait for a timeout, selector, test handle, or load state. |
page.resize(width, height) | Resize the page viewport. |
page.key(key) / page.keys(keys) | Press keyboard shortcuts. |
page.back() / page.forward() / page.reload() | Navigate browser history or reload. |
page.debug() | Pause in Playwright when the runtime supports it. |
page.withinFrame(target, callback) | Scope actions to an iframe. |
Setup helpers
Setup helpers run before navigation when chained from visit('/path'):
const page = await visit('/dashboard')
.withHost('app.test')
.inDarkMode()
.withLocale('en-GB')
.withTimezone('Africa/Lagos')
.withUserAgent('SoundingBot/1.0')
.withGeolocation({ latitude: 6.5244, longitude: 3.3792 })withHost() resolves relative visit targets against a specific host:
await visit('/dashboard').withHost('app.test')
// http://app.test/dashboard
await visit('/dashboard').withHost('https://creator.example.com')
// https://creator.example.com/dashboardUse it for tenant domains, custom host routing, signed-link hosts, or apps that branch on the request Host header.
Supported setup helpers:
| API | Purpose |
|---|---|
visit('/path').as(actor) | Log in as a world actor before navigation. |
visit('/path').on(project) / onMobile() | Open a named browser project before navigation. |
visit('/path').withHost(host) | Resolve relative paths against a specific host. |
visit('/path').inDarkMode() / inLightMode() | Emulate color scheme before navigation. |
visit('/path').withLocale(locale) | Override browser locale signals where possible. |
visit('/path').withTimezone(timezone) | Store timezone intent for browser metadata and future context support. |
visit('/path').withUserAgent(userAgent) | Set the page user-agent header where possible. |
visit('/path').withGeolocation(lat, lon) | Grant geolocation permission and set coordinates where possible. |
Read and capture helpers
Use page helpers when a trial needs browser state or visual evidence:
page.url()
await page.text()
await page.text('@status')
await page.html()
await page.content()
await page.script(() => document.title)
await page.screenshot('.tmp/page.png')
await page.screenshotElement('@receipt', '.tmp/receipt.png')Raw Playwright access remains available through page.raw or page.playwrightPage when a browser flow needs a lower-level escape hatch.
Failure artifacts
Browser failures are the hardest failures to understand from a stack trace alone, so Sounding captures the most useful evidence automatically.
When a { browser: true } trial fails, Sounding keeps:
- the current page URL
- a
current-url.txtfile - a full-page
screenshot.png
The files live under:
.tmp/sounding/artifacts/<trial-name>/<browser-project>/For example, this trial:
test(
'subscriber can finish a members-only issue',
{ browser: true },
async ({ page }) => {
await page.goto('/issues/the-nerve-to-build')
// ...
}
)would write failure evidence to:
.tmp/sounding/artifacts/subscriber-can-finish-a-members-only-issue/desktop/Sounding also appends the current URL and artifact paths to the thrown error:
Sounding browser artifacts:
- URL: http://127.0.0.1:3333/issues/the-nerve-to-build
- current URL file: .tmp/sounding/artifacts/subscriber-can-finish-a-members-only-issue/desktop/current-url.txt
- screenshot: .tmp/sounding/artifacts/subscriber-can-finish-a-members-only-issue/desktop/screenshot.pngThat keeps the assertion failure as the main message while still pointing you at the browser evidence.
Capture traces and videos
Screenshots and URLs are cheap enough to keep on by default. Traces and videos are heavier, so Sounding makes them opt-in.
Turn them on for one trial when a browser flow is flaky or timing-sensitive:
test(
'checkout keeps the cart after refresh',
{
browser: {
artifacts: {
trace: true,
video: true
}
}
},
async ({ page, expect }) => {
await page.goto('/checkout')
await page.reload()
await expect(page).toSee('Your cart')
expect(page).toHaveNoSmoke()
}
)The failure directory will then include:
current-url.txt
screenshot.png
trace.zip
video.webmUse trace when you need to inspect network requests, DOM snapshots, console output, or the timing between browser actions. Use video when motion, redirects, focus changes, or transient UI states are the thing you need to see.
Artifact modes
Artifact settings accept booleans for the common path:
truekeeps the artifact when the trial failsfalsedisables that artifact
They also accept explicit modes:
offdisables the artifacton-failurekeeps the artifact only for failed trialsonkeeps the artifact for successful trials too
Most apps should use booleans in day-to-day tests:
test(
'publisher can update an issue cover',
{
browser: {
artifacts: {
trace: true
}
}
},
async ({ page }) => {
await page.goto('/publisher/issues/42/edit')
}
)Use on when you are deliberately collecting evidence from a passing browser flow:
test(
'launch demo recording stays stable',
{
browser: {
artifacts: {
video: 'on'
}
}
},
async ({ page }) => {
await page.goto('/demo')
}
)Disable artifacts for a trial
If a smoke trial is intentionally tiny and you do not want files, turn artifacts off for that trial:
test(
'health page responds',
{ browser: { artifacts: false } },
async ({ visit, expect }) => {
const page = await visit('/health')
await expect(page).toSee('OK')
}
)This is mainly useful for very large suites or trials that intentionally fail while exercising error boundaries.
Load a world before browser setup
Browser trials become harder to maintain when they carry too much setup. Use the trial's world option to start from a named business situation before the browser flow begins.
Run the same flows across browser projects
Run the same core flows across named browser projects:
- desktop
- mobile
- WebKit or Firefox when the browser engine matters
- dark mode when relevant
The default project is desktop, so most browser trials can stay terse:
test(
'subscriber can read a gated issue',
{ browser: true },
async ({ visit, expect }) => {
const page = await visit('/issues/the-nerve-to-build')
await expect(page).toSee('The rest of the story')
}
)When a trial needs a specific project, use the string shorthand:
test(
'mobile navigation opens the account menu',
{ browser: 'mobile' },
async ({ visit, expect }) => {
const page = await visit('/dashboard').inDarkMode()
await page.click('@account-menu')
await expect(page).toSee('Settings')
}
)Or use the object form when the trial also needs artifacts or direct browser overrides:
test(
'checkout works in WebKit',
{
browser: {
project: 'safari',
artifacts: {
trace: true
}
}
},
async ({ visit, expect }) => {
const page = await visit('/checkout')
await expect(page).toSee('Checkout')
}
)Use visit().on() when one visit inside a browser trial needs a different project before navigation:
test(
'publisher dashboard adapts across projects',
{ browser: true },
async ({ visit, expect }) => {
const mobilePage = await visit('/dashboard').onMobile()
await expect(mobilePage).toSee('Menu')
const safariPage = await visit('/dashboard').on('safari')
await expect(safariPage).toSee('Dashboard')
}
)For a whole trial, prefer { browser: 'mobile' }. For one specific visit, visit('/path').onMobile() closes the current browser session, opens the named project, then applies actor login, color scheme, locale, host, and other setup before it navigates.
Define the projects in config/sounding.js:
module.exports.sounding = {
browser: {
projects: {
desktop: {},
mobile: {
device: 'iPhone 13'
},
safari: {
type: 'webkit'
}
}
}
}If a trial references a project that is not configured, Sounding fails before opening the browser and lists the available project names.
Choose the right layer
Use browser trials for what only the browser can prove.
Leave lower-level behavior to:
test()withsails.helperstest()withget()/post()test()withvisit()
What browser-capable trials add
When you opt into { browser: true }, the trial context additionally gives you:
pagebrowserContextbrowser- Playwright-flavored
expect()behavior for browser assertions
It also makes helpers like login.as(actorOrEmail, page) useful in the same trial. It also makes helpers like login.withPassword(actorOrEmail, page, { password }) useful when the real browser form is the behavior.
Typical browser cases
Use browser-capable trials for:
- sign-in journeys
- editor flows
- checkout or handoff flows
- mobile navigation
- anything where the DOM, navigation, or real page state is the behavior
Do not use browser-capable trials just because they exist.
If a request or Inertia contract can prove the behavior more cheaply and more clearly, prefer that lower layer first.