Documentation

Evaluation

Sometimes the SDK's typed verbs aren't quite the right shape — you want a value out of the page, a custom predicate, or a one-off DOM query that doesn't deserve its own helper. Evaluate runs an arbitrary JavaScript expression in the page and gives you back either a regular value or a handle to a DOM element you can act on. Running the same expression inside any frame on the page — even a cross-origin one — is just EvaluateInFrame with the frame's id.

TL;DR
  • Evaluate(expr) runs JavaScript in the main frame, EvaluateInFrame(frameId, expr) runs it in any frame by id.
  • The return splits two ways: non-Element values land in Value (JSON-decoded); a DOM Element populates BackendNodeId / IsVisible / Bounds instead, ready to hand to Node(id) for typed follow-up actions.
  • Cross-origin / OOPIF frames are first-class — once you have a frameId (from Wait, GetPages, or any action's result), evaluating in them is a single call. No proxy chains.
  • Don't use Evaluate as a wait loop. Wait is the right tool for "wait until something is true".

The basic call

A single expression goes in, the JSON-serialised result comes out:

r, err := browser.Evaluate(ctx, "document.title")
if err != nil {
  log.Fatal(err)
}
fmt.Println(r.Value)        // → "Example Domain"

A couple of small but important rules:

  • The string you pass must be a single expression, not a script body. For anything multi-statement, wrap it in an IIFE — see the recipes below.
  • The result is whatever your expression evaluates to, JSON-serialised by the server and parsed back client-side. Anything not JSON-representable (functions, undefined, circular structures…) comes back as the zero value.
  • In TypeScript, evaluate is generic: evaluate<MyType>(...) lets you type the return value at the call site instead of casting later.

The two return-value shapes

There is only one EvaluateResult type, but it has two distinct populated shapes depending on what your expression returned:

  • Value path — the expression returned a JSON-serialisable value (string, number, array, plain object). Value carries it; the Element fields stay zeroed.
  • Element path — the expression returned a single DOM Element. Value is empty/null, but BackendNodeId is a non-zero handle to that element, with IsVisible and Bounds populated as a bonus.

The Element path is the part most people miss the first time — and it's exactly what makes Evaluate a serious tool rather than a debug helper. It lets you write one expression that finds the element and hand the result to a typed SDK action with no second lookup:

// Find the first visible "Add to cart" button via JS, then click it.
r, err := browser.Evaluate(ctx, `
  [...document.querySelectorAll("button")]
    .find(b => b.offsetParent && /add to cart/i.test(b.textContent))
`)
if err != nil || r.BackendNodeId == 0 {
  log.Fatal("no add-to-cart button")
}
_, _ = browser.Click(ctx, wrc.Node(r.BackendNodeId))

The pattern — one expression to locate, one typed call to act — is the same one Wait gives you with r.BackendNodeId. Evaluate just lets you swap CSS for arbitrary JavaScript when CSS isn't expressive enough.

A quick mental rule: if your expression's last value is an Element, you'll be looking at the Element path. Anything else (text, number, plain object, array, null) takes the Value path.

EvaluateInFrame — same call, any frame

EvaluateInFrame is Evaluate plus a frameId argument. Same return shape, same rules — the JS just runs in the frame you point at instead of the main one:

title, _ := browser.EvaluateInFrame(ctx, iframeId, "document.title")
fmt.Println(title.Value)

WRC treats every frame on the page as a plain string id. The id you get from a Wait result, the id you get from GetPages.FrameTree, the id baked into a locator's .InFrame(...) — they all interchange freely, and they all work with EvaluateInFrame. Same-origin or cross-origin, in-process or out-of-process: the call shape doesn't change. That is the part that's nice about WRC's frame model — the plumbing CDP-based tools usually expose is gone.

The canonical pattern: wait across all frames, evaluate in the match

Wait can fan out across every frame with .InAllFrames(), and the result tells you which frame actually matched. EvaluateInFrame then runs your JS in that exact frame:

// 1. Wait for the consent button to appear, anywhere on the page.
r, err := browser.Wait(ctx, wrc.CSS("button.accept-all").InAllFrames())
if err != nil {
  log.Fatal(err)
}

// 2. Inspect or transform something in *that* frame.
info, _ := browser.EvaluateInFrame(ctx, r.FrameId, `
  (() => {
      const btn = document.querySelector("button.accept-all");
      return { text: btn.textContent.trim(), href: location.href };
  })()
`)
fmt.Println(info.Value)

// 3. Or just click it — Wait already gave you the backendNodeId.
_, _ = browser.Click(ctx, wrc.Node(r.BackendNodeId).InFrame(r.FrameId))

Three things worth pulling out of that snippet:

  • Wait(...).InAllFrames() is how you say "I don't know where this element lives, find it for me". The match wins and you get the exact FrameId back.
  • EvaluateInFrame itself does not take ALL_FRAMES — it always runs in exactly one frame. The Wait → EvaluateInFrame handshake is the cross-frame search pattern.
  • Note the IIFE wrapping the multi-statement body. Plain const btn = …; return …; would fail; an arrow IIFE makes it a single expression.

Anti-patterns

Two ways people abuse Evaluate that you should not:

Don't use it as a wait loop

If you find yourself writing setTimeout / polling loops inside an Evaluate expression, or repeatedly calling Evaluate from your script to check for some condition, stop. That's Wait's job. See Waiting for the explicit pattern.

// ❌ Don't do this — burns round-trips and never times out cleanly.
for {
  r, _ := browser.Evaluate(ctx, "!!document.querySelector('.done')")
  if v, ok := r.Value.(bool); ok && v { break }
  time.Sleep(100 * time.Millisecond)
}

// ✅ Do this instead.
_, _ = browser.Wait(ctx, wrc.CSS(".done"))

Don't use it for actions the SDK already has

Evaluate("document.querySelector('button').click()") will fire a click event in the DOM, but it dispatches synthetic JS events with no real mouse path, no scroll-into-view, no focus management — and modern anti-bot stacks have been trained to spot exactly that. Use Click when you mean click; use Evaluate for things the SDK doesn't have a verb for.

Gotchas

  • Single expression only. Multi-statement bodies need an IIFE: (() => { /* statements */; return value; })().
  • undefined, functions and circular structures don't serialise. They come back as the zero value (nil in Go, null in TS).
  • Element returns reset the Value field. When BackendNodeId > 0, Value is nil / null regardless of what other properties the element had. Read from BackendNodeId / IsVisible / Bounds instead.
  • Errors thrown in the expression surface as call errors. throw new Error("nope") becomes a returned error on the SDK call — there's no separate "expression threw" channel.
  • EvaluateInFrame doesn't take ALL_FRAMES. That's the whole reason Wait(...).InAllFrames() exists. Find the frame with Wait, run the JS with EvaluateInFrame — one frame at a time.
See also