Solari

Driving the browser

Once a session is open, you drive the browser the same way you would locally: navigate, click, type, extract, screenshot. This page is a friendly tour of the most common things you'll do.

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 "@solarisdk/browser"

const client = new Solari({ apiKey: process.env.SOLARI_API_KEY!, baseUrl: "https://api.getsolari.com" })

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

2. Bring your own Playwright

Already running Playwright (or Patchright) in your test or scraper code? Create the session yourself and connect to its wsEndpoint. This path tends to feel a little snappier than the CDP one below.

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
This path needs a version-matched client. Pin patchright-core@1.59.3 in your project; newer Playwright releases won't connect.

3. Puppeteer, browser-use, and everything else

Using something other than Playwright? Connect to the session's cdpEndpoint. Any recent CDP client works, with no version pinning to worry about.

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 come back from sessions.create(). They're tied to your session, so keep them private. See Sessions for the full lifecycle reference.

Pages

One session can hold as many pages (tabs) as you like. newPage() opens one, and 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"))

Want cookies and localStorage to carry over between runs? See Profiles. Solari saves your storageState for you, so you don't have to manage 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 if it times out, so wrap it in try/catch when you need to recover. Locator actions wait up to 30 seconds by default, and you can override that on any 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: the direct endpoints, if you're not using the SDK.