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