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.
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 populatesBackendNodeId/IsVisible/Boundsinstead, ready to hand toNode(id)for typed follow-up actions. - Cross-origin / OOPIF frames are first-class — once you have a
frameId(fromWait,GetPages, or any action's result), evaluating in them is a single call. No proxy chains. - Don't use
Evaluateas a wait loop.Waitis 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"const r = await browser.evaluate<string>("document.title");
console.log(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,
evaluateis 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).
Valuecarries it; the Element fields stay zeroed. - Element path — the expression returned a single DOM Element.
Valueis empty/null, butBackendNodeIdis a non-zero handle to that element, withIsVisibleandBoundspopulated 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))// Find the first visible "Add to cart" button via JS, then click it.
const r = await browser.evaluate(`
[...document.querySelectorAll("button")]
.find(b => b.offsetParent && /add to cart/i.test(b.textContent))
`);
if (r.backendNodeId === 0) throw new Error("no add-to-cart button");
await browser.click(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)const title = await browser.evaluateInFrame<string>(iframeId, "document.title");
console.log(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))// 1. Wait for the consent button to appear, anywhere on the page.
const r = await browser.wait(css("button.accept-all").inAllFrames());
// 2. Inspect or transform something in *that* frame.
const info = await browser.evaluateInFrame<{ text: string; href: string }>(
r.frameId,
`(() => {
const btn = document.querySelector("button.accept-all");
return { text: btn.textContent.trim(), href: location.href };
})()`,
);
console.log(info.value);
// 3. Or just click it — wait already gave you the backendNodeId.
await browser.click(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 exactFrameIdback.EvaluateInFrameitself does not takeALL_FRAMES— it always runs in exactly one frame. TheWait → EvaluateInFramehandshake 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 do this — burns round-trips and never times out cleanly.
while (true) {
const r = await browser.evaluate<boolean>("!!document.querySelector('.done')");
if (r.value === true) break;
await sleep(100);
}
// ✅ Do this instead.
await browser.wait(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 (nilin Go,nullin TS).- Element returns reset the Value field. When
BackendNodeId > 0,Valueisnil/nullregardless of what other properties the element had. Read fromBackendNodeId/IsVisible/Boundsinstead. - 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. EvaluateInFramedoesn't takeALL_FRAMES. That's the whole reasonWait(...).InAllFrames()exists. Find the frame withWait, run the JS withEvaluateInFrame— one frame at a time.
- Reading the page — when you want to look at the page without running JS.
- Frames & iframes — where
frameIds come from and how the frame tree is shaped. - Targeting elements — the
Node(id)constructor that turns anEvaluateresult into the next call's target. - API reference: Go
Evaluate/EvaluateInFrame· TSevaluate/evaluateInFrame.