Skip to content

Browser provider

The browser provider drives Chrome / Chromium through the Chrome DevTools Protocol using chromedp. It is Go-native: no Node, no Playwright, no Selenium / WebDriver. Tales stays a single binary.

Reach for the browser provider when you need to assert behaviour that only the rendered page produces: cookies set by the server-side login flow, JavaScript-driven form interactions, redirects after authentication, etc. For pure HTTP / API checks, the HTTP provider is faster and gives you cleaner failure diagnostics.

step "browser" "<name>" {
target = "chrome" // required: config.browser.targets key
actions { // ordered list, runs sequentially
goto { url = "..." }
fill { selector = "..." value = "..." }
click { selector = "..." }
wait_visible { selector = "..." timeout = "10s" }
// …
}
expect { // optional, polled until timeout
visible { selector = "..." }
text { selector = "..." value = "..." }
attribute { selector = "..." name = "..." value = "..." }
url { value = contains("/dashboard") }
title { value = "Dashboard" }
}
capture { // optional, evaluated against the
// post-step snapshot
heading = text("[data-testid='dashboard.title']")
csrf = attribute("meta[name='csrf-token']", "content")
current = browser.url
title = browser.title
}
}

Browser targets live under config.browser.targets:

config {
base_url = "${env("BASE_URL", "http://localhost:1337")}"
browser = {
targets = {
chrome = {
browser = "chrome" // "chrome" only in V1
headless = true // default true
executable = env("CHROME_PATH", "") // optional override
viewport = {
width = 1440 // default 1440
height = 1000 // default 1000
}
timeout = "30s" // default 30s: action / expect default
args = [
"--disable-gpu",
]
}
}
}
}

If target = "<name>" is omitted on a step and exactly one target is declared, that target is used. Multiple targets without an explicit target produce a clear error so misconfigurations don’t pick the wrong browser silently.

When headless = true (the default), Tales adds three Chrome flags on top of the user-supplied args so the browser starts cleanly in CI / Docker environments:

  • --no-sandbox: required on GitHub Actions runners and most Docker images, where the setuid sandbox cannot be created without CAP_SYS_ADMIN. Without it Chrome hangs silently at startup before CDP is reachable.
  • --disable-dev-shm-usage: avoids crashes on runners where /dev/shm is too small (typical 64 MiB containers).
  • --disable-gpu: removes a startup warning + saves a few hundred milliseconds in headless mode where GPU acceleration is irrelevant.

When headless = false, none of these flags are added: the local Chrome sandbox stays enabled and the GPU is used. To override a default in headless mode, pass the opposite flag in args (e.g. --enable-gpu if you really want it).

The provider looks for a Chrome executable in this order:

  1. The target’s executable field (when set)
  2. The CHROME_PATH environment variable
  3. Common binary names on PATH: google-chrome, google-chrome-stable, chromium, chromium-browser, chrome
  4. OS-specific install locations (macOS /Applications/Google Chrome.app/..., Linux /usr/bin/...)

A missing Chrome surfaces as a clear error suggesting executable or CHROME_PATH.

Actions execute in the order they appear in the .tales file. A failing action stops the step; subsequent actions are recorded as "skipped" so the visual report shows the full sequence.

ActionRequired attrsNotes
gotourlNavigates and waits for the document to settle
clickselectorWaits for visibility, then clicks
fillselector, valueClears and types value. Set secure = true to mask the value in reports
clearselectorErases the input value
presskey, optional selectorFocuses selector first if set, then sends the keystroke
submitselectorCalls HTMLFormElement.requestSubmit() (falls back to .submit())
scrollselector or x + yScrolls element into view, or scrolls the page by offset
wait_visibleselectorPolls until the element is visible
wait_not_visibleselectorPolls until the element is gone or hidden
hoverselectorDispatches a mouseover event
selectselector, valueSets <select> value and fires change
check / uncheckselectorToggles a checkbox / radio input
reloadnoneReloads the current page
back / forwardnoneNavigates the browser history. See limitations below

All actions accept an optional timeout = "<duration>" and interval = "<duration>".

fill with secure = true masks the value to "***" in every report (console, JSONL, visual HTML, JUnit). The masking happens once inside the provider; reporters never see the plaintext.

fill {
selector = "[data-testid='login.password']"
value = config.test_password
secure = true
}

Expectations poll until the timeout elapses. Default timeout: 10s; default interval: 250ms.

expect {
visible { selector = "#dashboard" }
not_visible { selector = ".spinner" }
text {
selector = "h1"
value = "Dashboard" // exact match
}
text {
selector = "[data-testid='greeting']"
value = contains("Welcome") // matcher
}
attribute {
selector = "meta[name='csrf-token']"
name = "content"
value = is_string() // any string is fine
}
url { value = matches("/web/dashboard$") }
title { value = "Dashboard" }
}

All of Tales’ built-in matchers (contains, matches, one_of, any, exists, is_string, is_number, is_bool, is_array, is_object) are reusable in browser expect blocks.

Inside a browser step’s capture block, the runtime injects four browser-specific helpers backed by the snapshot recorded after the step ran:

HelperReturns
text("selector")The rendered text of the first matching element
attribute("selector", "name")The value of the named DOM attribute
browser.urlThe document URL after the step
browser.titleThe document title after the step
capture {
heading = text("h1")
csrf = attribute("meta[name='csrf-token']", "content")
current_url = browser.url
page_title = browser.title
}

These helpers are only available inside a browser step’s capture block. The standard expression scope (config, result, vars, input, generators, host, …) is still available.

V1 spawns one Chrome subprocess per (target, scenario) pair. This gives each scenario a fresh BrowserContext with its own cookies, localStorage, and history (at the cost of a ~600ms startup per scenario). The Provider holds the per-target session map and closes everything via io.Closer at suite end, so no Chrome processes leak after tales test returns.

Inside one scenario, all steps on the same target share the browser context. Use that to carry login state across steps.

On step failure (and per --capture-screenshots), the provider writes:

build/artifacts/browser/
<scenario-safe>-<hash>/<step-safe>/<phase>/attempt-<N>/
step/
screenshot.png // viewport PNG
dom.html // outerHTML of <html> when the step failed
actions/
0000-goto-<selector>/
screenshot.png // when --capture-screenshots=actions
dom.html
0001-fill-loginemail/
...

Artifact paths are surfaced on StepResult.Artifacts and per-action ActionResult.Screenshot / ActionResult.Hierarchy, which the visual HTML report renders inline.

--capture-screenshots <mode> (or default driven by --report-html):

  • none: no captures at all
  • failures (default without --report-html): one screenshot + dom.html at step level when the step fails
  • steps: one screenshot + dom.html at the end of each step (success or failure)
  • actions (default with --report-html): per-action capture, plus a failure capture if any action fails

When --report-html is set, the browser provider populates the same report.ActionResult records used by the mobile provider’s visual replay. The HTML report shows:

  • Action timeline with per-action duration
  • Per-action screenshots (referenced via relative path under build/artifacts/browser/...)
  • Masked secure values
  • Per-action DOM snapshots (dom.html)

There is no browser-specific renderer: the visual layout is shared with mobile.

To share the report, ship the e2e-browser.html file together with the build/artifacts/browser/ directory (the HTML uses relative paths to load the PNG / DOM files). The CSS / JS template assets are baked into the HTML itself, so opening the report from any local folder that preserves the relative artifact layout works without a web server.

Tales applies timeouts in this precedence order (innermost first):

  1. Per-expectation timeout = "..." (just that expectation)
  2. Per-action timeout = "..." (just that action)
  3. Target driver.timeout (default for actions / expectations in the step)
  4. Built-in 10s default for expectations, 30s default for actions
  5. CLI --timeout (wraps the entire suite ctx)

CSS selectors only in V1. Recommended pattern: add data-testid="..." attributes to your application markup and select by those: they are stable across CSS / styling changes.

click { selector = "[data-testid='login.submit']" }

The provider also accepts any selector chromedp’s ByQuery mode supports (tag, id, class, attribute, descendant, combinator).

Browser steps are not seed-deterministic. Real-browser interaction depends on layout reflow timing, async script execution, and network latency, none of which the Tales seed mixer controls.

Pin flakiness with:

  • expect.<kind> { timeout = "10s" interval = "100ms" }: bounded polling
  • retry { attempts = 3 interval = "1s" } on the step
  • Synchronous waits (wait_visible) after navigations before asserting

The seed mixer continues to work for everything outside the browser step (config, vars, generators, expressions evaluated before the provider runs).

Browser steps collect a Web Performance snapshot from Chrome after the actions settle. The snapshot is exposed under browser.performance and can be asserted via an expect { web_perf { ... } } block. Pair it with the numeric threshold matchers (lt, lte, gt, gte, between) and Go duration strings ("1800ms", "3s") to write readable budgets.

step "browser" "dashboard_perf" {
target = "chrome"
actions {
goto { url = "${config.base_url}/web/dashboard" }
wait_visible { selector = "[data-testid='dashboard.title']" }
}
expect {
web_perf {
fcp = lt("1800ms")
lcp = lt("2500ms")
cls = lt(0.1)
load = lt("3000ms")
dom_content_loaded = lt("1500ms")
resources_count = gte(1)
}
}
capture {
perf = browser.performance
}
}
Canonical name (capture)Friendly aliases (web_perf)Source
dom_content_loaded_msdom_content_loaded, dom_readyNavigation Timing API
load_event_msload, load_eventNavigation Timing API
fcp_msfcpPaint Timing API (first-contentful-paint)
lcp_mslcpPerformanceObserver (LCP)
clsclsPerformanceObserver (layout-shift)
resources_countresources_countResource Timing API
transfer_size_bytestransfer_sizeResource Timing API
encoded_body_size_bytesencoded_body_sizeResource Timing API
decoded_body_size_bytesdecoded_body_sizeResource Timing API
url, titlen/alocation / document
  • A small JS script is injected via Page.addScriptToEvaluateOnNewDocument before the first navigation. It installs PerformanceObservers for largest-contentful-paint and layout-shift so LCP and CLS accumulate from the first paint. Observer entries with hadRecentInput are excluded from CLS, matching the Web Vitals definition.
  • When a metric is intrinsically optional (LCP / CLS / FCP) and the browser did not surface a value (very simple pages or aborted navigations), expect.web_perf fails with browser performance metric "lcp_ms" is not available rather than silently passing or comparing against zero. Capture also exposes the metric as null, so HCL can branch on it.
  • Web performance is fundamentally non-deterministic. Use generous thresholds in shared CI and tighten them in environment-specific projects.
  • Chrome / Chromium only. Firefox and WebKit are out of scope.
  • CSS selectors only. No XPath, no role-based locators.
  • back and forward have known navigation-timing quirks. The naive chromedp navigation primitives hang waiting for a CDP response that the page tears down during navigation. V1 ships a workaround based on JS history.back() / history.forward() plus a short settle delay; in practice this works for the common case (back to the previous page) but may report context deadline exceeded on pages that redirect immediately. Prefer explicit goto calls when reliability matters.
  • Per-scenario Chrome startup cost (~600ms). Acceptable for now per the V1 brief; V2 may introduce a “shared context” mode.
  • No image / pixel diffing. Out of scope for V1.
Terminal window
make e2e-browser # passes when Chrome is present; skips otherwise
make e2e-browser-failure # asserts exit 1 on a deliberately broken scenario

The make targets gate on Chrome availability and exit 0 with a clear message when Chrome is missing, so CI runners without a browser stay green.

Set TALES_BROWSER_DEBUG=1 to enable verbose stderr logging from the browser provider and the Chrome builder. Each action, session start, and cleanup is timestamped.

Terminal window
TALES_BROWSER_DEBUG=1 tales test --verbose ./e2e/browser/login.tales