Solari Browserdocs

Driving the browser

Once a session is open, you drive Chromium the same way you would locally — navigate, click, type, extract, screenshot. This page is a tour of the most common operations.

Anything that works in Playwright works on a Solari browser
The browser object returned by client.launch() exposes the same surface as the Playwright page API. If a Playwright snippet works locally, it works against a Solari session unchanged — this page collects the most common patterns, not an exhaustive reference.

Connecting

There are three ways to get a browser object, and every section below works against any of them. Pick the one that matches your stack.

1. client.launch() — SDK default

Creates a session and connects in one call. The returned object is the Playwright-shaped Browser you'll use throughout this page.

import { Solari } from "@solaribrowser/sdk"

const client = new Solari({ apiKey: process.env.SOLARI_API_KEY! })

const browser = await client.launch({ stealth: true })
const page = await browser.newPage()
// …
await browser.close()   // closes the browser AND releases the session

2. WebSocket wire protocol — bring your own Playwright

If you're already running Playwright (or Patchright) and want to connect a Solari session into your existing test / scraper code, create the session yourself and connect to its wsEndpoint. Lower per-action latency than CDP.

import { chromium } from "patchright-core"

const session = await client.sessions.create({ stealth: true })

const browser = await chromium.connect(session.wsEndpoint)
const context = browser.contexts()[0] ?? await browser.newContext()
const page = await context.newPage()
// …drive normally…

await browser.close()
await client.sessions.releaseAndWait(session.id)
Pin patchright-core to 1.59.3
The wire protocol requires a version-matched client. Pin patchright-core@1.59.3 in your project — newer Playwright releases speak a different protocol and the connect call will fail.

3. CDP — Puppeteer, browser-use, anything else

For non-Playwright clients, connect to the session's Chrome DevTools Protocol endpoint. Any recent CDP client works — no version pinning.

import { chromium } from "playwright-core"

const session = await client.sessions.create({ stealth: true })

// playwright-core
const browser = await chromium.connectOverCDP(session.cdpEndpoint)

// puppeteer-core
// const browser = await puppeteer.connect({ browserWSEndpoint: session.cdpEndpoint })

// browser-use (Python)
// session = BrowserSession(cdp_url=session.cdpEndpoint)

const page = (await browser.contexts()[0].pages())[0] ?? await browser.contexts()[0].newPage()
// …

await browser.close()
await client.sessions.releaseAndWait(session.id)

Both wsEndpoint and cdpEndpoint are returned by sessions.create(). They're bearer-authenticated to your session id — keep them private. See Sessions for the full lifecycle reference.

Pages

A session can hold many pages (tabs). newPage()opens one; closing the browser closes them all.

const browser = await client.launch()
const page = await browser.newPage()

// Open a second tab from the same browser context.
const second = await browser.newPage()
await second.goto("https://news.ycombinator.com")

// Close just one tab — the session stays open.
await second.close()

Navigation

goto() drives the address bar. The waitUntil option chooses what counts as "done".

// Wait for the load event (default).
await page.goto("https://example.com")

// Wait until the network has been idle for 500ms — useful for SPAs
// that finish their initial XHR after `load`.
await page.goto("https://app.example.com", { waitUntil: "networkidle" })

// Set a custom timeout. Default is 30s.
await page.goto("https://slow.example.com", { timeout: 60_000 })

// History navigation.
await page.goBack()
await page.goForward()
await page.reload()
Prefer waitUntil: "domcontentloaded" if you only need the HTML — pages that keep polling will never hit networkidle and you'll burn the timeout.

Clicks, typing, and form input

Use page.locator() to find elements and act on them. Locators auto-wait for the element to be visible and enabled before acting, so you very rarely need an explicit sleep.

// Click a button by its accessible label.
await page.getByRole("button", { name: "Sign in" }).click()

// Click by CSS selector.
await page.locator("button.primary").click()

// Click by visible text.
await page.locator("text=Continue").click()

// Type into a text input.
await page.locator("input[name=email]").fill("you@example.com")

// Append to existing text (vs. fill() which replaces).
await page.locator("textarea").pressSequentially("hello", { delay: 30 })

// Press a single key.
await page.keyboard.press("Enter")
await page.locator("textarea").press("Tab")

// Select an option from a <select>.
await page.locator("select#country").selectOption("US")

// Checkbox / radio.
await page.locator("input[name=agree]").check()
await page.locator("input[name=agree]").uncheck()

// File upload.
await page.locator("input[type=file]").setInputFiles("./photo.jpg")

Finding elements

Prefer role / label / text locators where possible — they survive markup churn better than CSS selectors.

// By ARIA role + name (recommended).
page.getByRole("link", { name: "Pricing" })
page.getByRole("textbox", { name: "Email address" })

// By visible label (form fields).
page.getByLabel("Password")

// By placeholder.
page.getByPlaceholder("Search…")

// By visible text.
page.getByText("Welcome back")

// By test id (data-testid by default).
page.getByTestId("submit-btn")

// CSS / XPath as a fallback.
page.locator(".sidebar a:has-text('Settings')")
page.locator("//h1[contains(., 'Welcome')]")

// Chain to narrow.
page.locator("table.users").getByRole("row", { name: /alice/i })

// First / last / nth.
page.locator("article").first()
page.locator("article").nth(2)

Extracting data

Locators have read methods that return text, attributes, or screenshots of the matched element.

// Single value.
const title = await page.locator("h1").innerText()
const href  = await page.locator("a.cta").getAttribute("href")
const html  = await page.locator("article").innerHTML()
const value = await page.locator("input[name=email]").inputValue()

// All matches.
const headlines = await page.locator(".titleline > a").allInnerTexts()
const links     = await page.locator("a").evaluateAll(
  (els) => els.map((a) => (a as HTMLAnchorElement).href),
)

// Page-level shortcuts.
const url    = page.url()
const title2 = await page.title()
const cookies = await page.context().cookies()

Running JS in the page

page.evaluate() runs a function in the browser and returns its result. Anything serializable round-trips cleanly.

const stats = await page.evaluate(() => ({
  title: document.title,
  links: document.querySelectorAll("a").length,
  scrollY: window.scrollY,
}))

// Pass arguments in (closures don't capture — Node-side variables
// must be passed as the second arg).
const text = await page.evaluate(
  (sel) => document.querySelector(sel)?.textContent ?? "",
  "h1",
)

// Evaluate against a specific element.
await page.locator("button").evaluate((btn) => btn.click())

Waiting

Locators auto-wait, so explicit waits are usually only needed for state transitions that aren't tied to an element.

// Wait for an element to appear / be visible / be detached.
await page.locator(".toast-success").waitFor()                // visible
await page.locator(".spinner").waitFor({ state: "hidden" })

// Wait for a URL change.
await page.waitForURL("**/dashboard")

// Wait for a specific network response.
const resp = await page.waitForResponse((r) => r.url().endsWith("/api/me"))
const me = await resp.json()

// Wait for arbitrary JS to be true (last resort).
await page.waitForFunction(() => window.dataLayer?.length > 0)
Avoid raw sleeps
await page.waitForTimeout(2000) exists but you almost never want it — it makes your script slow on fast pages and flaky on slow ones. Wait for a real signal instead (element state, URL change, response).

Screenshots and PDFs

// PNG bytes of the visible viewport.
const png = await page.screenshot()

// Full-page screenshot.
const full = await page.screenshot({ fullPage: true })

// Just one element.
const card = await page.locator(".product-card").screenshot()

// PDF (Chromium headless only — works on Solari out of the box).
const pdf = await page.pdf({ format: "Letter", printBackground: true })

Downloads

Catching downloads is asynchronous — start the wait before the action that triggers the download.

const [download] = await Promise.all([
  page.waitForEvent("download"),
  page.locator("a:has-text('Export CSV')").click(),
])

// Stream to a buffer (no disk).
const stream = await download.createReadStream()
const chunks: Buffer[] = []
for await (const c of stream) chunks.push(c as Buffer)
const bytes = Buffer.concat(chunks)

console.log(download.suggestedFilename(), bytes.length, "bytes")

Iframes

frameLocator() reaches into an iframe; everything you can do on a page also works on a frame.

const checkout = page.frameLocator("iframe[name=stripe-card]")
await checkout.getByLabel("Card number").fill("4242 4242 4242 4242")
await checkout.getByLabel("MM / YY").fill("12 / 34")
await checkout.getByLabel("CVC").fill("123")

Multiple pages and popups

// Wait for a click to open a new tab.
const [popup] = await Promise.all([
  browser.contexts()[0].waitForEvent("page"),
  page.locator("a[target=_blank]").click(),
])

await popup.waitForLoadState()
console.log("opened", popup.url())

// List every open tab.
for (const p of browser.contexts()[0].pages()) {
  console.log(p.url())
}

Cookies and storage

const ctx = page.context()

// Read every cookie.
const cookies = await ctx.cookies()

// Set a cookie.
await ctx.addCookies([{
  name: "consent",
  value: "1",
  domain: "example.com",
  path: "/",
}])

// Clear everything.
await ctx.clearCookies()

// localStorage (run inside the page).
await page.evaluate(() => localStorage.setItem("flag", "on"))

For persistent cookies / localStorage across runs, see Profiles — Solari stores the storageState server-side so you don't hand- roll your own cookie jar.

Network interception

Route handlers let you inspect, modify, mock, or block requests. Useful for stubbing third-party APIs in tests, or for blocking heavy assets to make scraping faster.

// Block images and fonts.
await page.route("**/*", (route) => {
  const type = route.request().resourceType()
  if (type === "image" || type === "font") return route.abort()
  return route.continue()
})

// Mock an endpoint.
await page.route("**/api/me", (route) =>
  route.fulfill({
    status: 200,
    contentType: "application/json",
    body: JSON.stringify({ id: 1, name: "Test User" }),
  })
)

// Inspect responses without modifying them.
page.on("response", (resp) => {
  if (resp.url().endsWith("/api/search")) {
    console.log(resp.status(), resp.url())
  }
})

Dialogs

// Auto-accept any alert / confirm / prompt that pops up.
page.on("dialog", (dialog) => dialog.accept())

// Or handle a specific one inline.
const [dialog] = await Promise.all([
  page.waitForEvent("dialog"),
  page.locator("button:has-text('Delete')").click(),
])
console.log(dialog.message())
await dialog.accept()

Errors and timeouts

Every action throws on timeout — wrap in try/catch if you need to recover. Locator actions default to a 30 s timeout; you can override per-call.

try {
  await page.locator("text=Sign in").click({ timeout: 5_000 })
} catch (err) {
  // Take a screenshot for debugging, then bail.
  await page.screenshot({ path: "fail.png", fullPage: true })
  throw err
}

// Set a default for the whole page.
page.setDefaultTimeout(15_000)

Next

  • Sessions — launch options (stealth, proxy, captcha, recording).
  • Profiles — persistent login state between sessions.
  • API reference — raw HTTP endpoints for non-SDK clients.