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.
When to use it
Section titled “When to use it”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.
Anatomy of a browser step
Section titled “Anatomy of a browser step”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 }}Configuration
Section titled “Configuration”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.
Headless defaults
Section titled “Headless defaults”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 withoutCAP_SYS_ADMIN. Without it Chrome hangs silently at startup before CDP is reachable.--disable-dev-shm-usage: avoids crashes on runners where/dev/shmis 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).
Chrome resolution
Section titled “Chrome resolution”The provider looks for a Chrome executable in this order:
- The target’s
executablefield (when set) - The
CHROME_PATHenvironment variable - Common binary names on
PATH:google-chrome,google-chrome-stable,chromium,chromium-browser,chrome - 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
Section titled “Actions”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.
| Action | Required attrs | Notes |
|---|---|---|
goto | url | Navigates and waits for the document to settle |
click | selector | Waits for visibility, then clicks |
fill | selector, value | Clears and types value. Set secure = true to mask the value in reports |
clear | selector | Erases the input value |
press | key, optional selector | Focuses selector first if set, then sends the keystroke |
submit | selector | Calls HTMLFormElement.requestSubmit() (falls back to .submit()) |
scroll | selector or x + y | Scrolls element into view, or scrolls the page by offset |
wait_visible | selector | Polls until the element is visible |
wait_not_visible | selector | Polls until the element is gone or hidden |
hover | selector | Dispatches a mouseover event |
select | selector, value | Sets <select> value and fires change |
check / uncheck | selector | Toggles a checkbox / radio input |
reload | none | Reloads the current page |
back / forward | none | Navigates the browser history. See limitations below |
All actions accept an optional timeout = "<duration>" and interval = "<duration>".
Secure values
Section titled “Secure values”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
Section titled “Expectations”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.
Capture helpers
Section titled “Capture helpers”Inside a browser step’s capture block, the runtime injects four browser-specific helpers backed by the snapshot recorded after the step ran:
| Helper | Returns |
|---|---|
text("selector") | The rendered text of the first matching element |
attribute("selector", "name") | The value of the named DOM attribute |
browser.url | The document URL after the step |
browser.title | The 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.
Sessions and isolation
Section titled “Sessions and isolation”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.
Artifacts
Section titled “Artifacts”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 modes
Section titled “Capture modes”--capture-screenshots <mode> (or default driven by --report-html):
none: no captures at allfailures(default without--report-html): one screenshot + dom.html at step level when the step failssteps: 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
Visual report integration
Section titled “Visual report integration”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.
Timeout layering
Section titled “Timeout layering”Tales applies timeouts in this precedence order (innermost first):
- Per-expectation
timeout = "..."(just that expectation) - Per-action
timeout = "..."(just that action) - Target
driver.timeout(default for actions / expectations in the step) - Built-in 10s default for expectations, 30s default for actions
- CLI
--timeout(wraps the entire suite ctx)
Selectors
Section titled “Selectors”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).
Determinism caveat
Section titled “Determinism caveat”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 pollingretry { 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).
Web performance budgets
Section titled “Web performance budgets”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 }}Available metrics
Section titled “Available metrics”| Canonical name (capture) | Friendly aliases (web_perf) | Source |
|---|---|---|
dom_content_loaded_ms | dom_content_loaded, dom_ready | Navigation Timing API |
load_event_ms | load, load_event | Navigation Timing API |
fcp_ms | fcp | Paint Timing API (first-contentful-paint) |
lcp_ms | lcp | PerformanceObserver (LCP) |
cls | cls | PerformanceObserver (layout-shift) |
resources_count | resources_count | Resource Timing API |
transfer_size_bytes | transfer_size | Resource Timing API |
encoded_body_size_bytes | encoded_body_size | Resource Timing API |
decoded_body_size_bytes | decoded_body_size | Resource Timing API |
url, title | n/a | location / document |
Implementation notes
Section titled “Implementation notes”- A small JS script is injected via
Page.addScriptToEvaluateOnNewDocumentbefore the first navigation. It installsPerformanceObservers forlargest-contentful-paintandlayout-shiftso LCP and CLS accumulate from the first paint. Observer entries withhadRecentInputare 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_perffails withbrowser performance metric "lcp_ms" is not availablerather than silently passing or comparing against zero. Capture also exposes the metric asnull, 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.
V1 limitations
Section titled “V1 limitations”- Chrome / Chromium only. Firefox and WebKit are out of scope.
- CSS selectors only. No XPath, no role-based locators.
backandforwardhave 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 JShistory.back()/history.forward()plus a short settle delay; in practice this works for the common case (back to the previous page) but may reportcontext deadline exceededon pages that redirect immediately. Prefer explicitgotocalls 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.
CI example
Section titled “CI example”make e2e-browser # passes when Chrome is present; skips otherwisemake e2e-browser-failure # asserts exit 1 on a deliberately broken scenarioThe 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.
Debugging
Section titled “Debugging”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.
TALES_BROWSER_DEBUG=1 tales test --verbose ./e2e/browser/login.tales