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.
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 session2. 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)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()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)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.