name: Playwright page.evaluate Patterns description: Teaches the agent when and how to use page.evaluate, evaluateHandle, and exposeFunction in Playwright — passing arguments safely, reading DOM/JS state, and why locators should be preferred for actions. version: 1.0.0 author: thetestingacademy license: MIT tags: [playwright, evaluate, evaluatehandle, exposefunction, dom, javascript, jshandle, browser-context] testingTypes: [e2e, integration] frameworks: [playwright] languages: [typescript] domains: [web] agents: [claude-code, cursor, github-copilot, windsurf, codex, aider, continue, cline, zed, bolt, gemini-cli, amp]
Playwright page.evaluate Patterns
This skill makes the agent use page.evaluate the way it is meant to be used: to read application state from the browser, not to replace user actions. The function body runs inside the page's JS context, so document, window, and app globals are available — but Node closures and imports are not. Arguments must be explicitly serialized across the bridge.
Use this skill when the agent needs to inspect localStorage, read a JS variable, call a page API, or scrape computed values — and to stop the agent from clicking via evaluate(() => el.click()) when a locator would be correct.
Core Principles
- Read with
evaluate, act with locators. Useevaluateto extract state. Usepage.getByRole(...).click()for interactions — locators auto-wait and reflect real user behavior;el.click()inside evaluate bypasses actionability checks and hides bugs. - The callback runs in the browser, not Node. No access to test variables,
require,process.env, or imported helpers unless passed as an argument. - Arguments must be serializable (JSON-compatible) — except
JSHandle/ElementHandle, which are passed by reference. Functions, class instances, andundefinedkeys do not cross intact. evaluatereturns serialized values;evaluateHandlereturns a live handle. Use a handle when you need to keep referencing a non-serializable object (e.g.window, a DOM node) across calls.- Treat all page input as untrusted. Never build the evaluated source by string-concatenating page content — pass data as an argument so it can never be interpreted as code.
Workflow / Patterns
Pattern 1 — Read JS / DOM state (the primary use)
import { test, expect } from '@playwright/test';
test('reads application state from the page', async ({ page }) => {
await page.goto('https://example.com/app');
// Read a global the app sets.
const userId = await page.evaluate(() => (window as any).__APP__?.currentUser?.id);
expect(userId).toBeTruthy();
// Read localStorage (impossible to assert on from Node directly).
const theme = await page.evaluate(() => localStorage.getItem('theme'));
expect(theme).toBe('dark');
// Read a computed style the user actually sees.
const color = await page.evaluate(() => {
const btn = document.querySelector('button.primary')!;
return getComputedStyle(btn).backgroundColor;
});
expect(color).toBe('rgb(37, 99, 235)');
});
Pattern 2 — Pass arguments safely (single arg, then object/array)
evaluate takes exactly one argument. Bundle multiple values into an object or array.
test('passes data into the page context', async ({ page }) => {
await page.goto('https://example.com');
// Single primitive.
const doubled = await page.evaluate((n) => n * 2, 21);
expect(doubled).toBe(42);
// Multiple values -> wrap in an object, destructure inside.
const fullName = await page.evaluate(
({ first, last }) => `${first} ${last}`.trim(),
{ first: 'Ada', last: 'Lovelace' },
);
expect(fullName).toBe('Ada Lovelace');
// Seed app state for a test scenario.
await page.evaluate((token) => {
localStorage.setItem('auth_token', token);
}, process.env.TEST_TOKEN ?? 'test-token-123');
});
Pattern 3 — Pass a located element into evaluate
A Locator resolves to an element handle that crosses the bridge by reference, so you can operate on the exact element the locator found.
test('evaluates against a located element', async ({ page }) => {
await page.goto('https://example.com/products');
const card = page.getByRole('article', { name: 'Pro Plan' });
// The first arg of the callback is the resolved element node.
const data = await card.evaluate((el) => ({
price: el.querySelector('.price')?.textContent?.trim(),
inStock: el.getAttribute('data-in-stock') === 'true',
width: el.getBoundingClientRect().width,
}));
expect(data.inStock).toBe(true);
expect(Number(data.width)).toBeGreaterThan(0);
// Pass extra args alongside the element (element first, then your arg).
const matches = await card.evaluate(
(el, expected) => el.querySelector('.price')?.textContent?.includes(expected),
'$29',
);
expect(matches).toBe(true);
});
Pattern 4 — evaluateHandle for non-serializable objects
When the value cannot be serialized (the window, a DOM node, a Map) but you need to keep using it, get a handle and pass it back into later evaluate calls.
test('keeps a live handle to a non-serializable object', async ({ page }) => {
await page.goto('https://example.com');
// window is not serializable — get a handle instead.
const windowHandle = await page.evaluateHandle(() => window);
// Reuse the handle as an argument in a later evaluate.
const innerWidth = await page.evaluate((w) => (w as Window).innerWidth, windowHandle);
expect(innerWidth).toBeGreaterThan(0);
// Handle to a specific element with live properties.
const inputHandle = await page.evaluateHandle(
() => document.querySelector('input#email') as HTMLInputElement,
);
const validity = await inputHandle.evaluate((el: HTMLInputElement) => el.validity.valid);
expect(typeof validity).toBe('boolean');
// Dispose handles when done to free browser memory.
await windowHandle.dispose();
await inputHandle.dispose();
});
Pattern 5 — exposeFunction to call Node from the page
exposeFunction installs a Node-backed async function on window, so page code can call back into your test (logging, recording calls, providing data the browser cannot compute).
test('captures page-side events via exposeFunction', async ({ page }) => {
const analyticsCalls: Array<{ event: string; props: unknown }> = [];
// Install BEFORE navigation so it exists when the page runs.
await page.exposeFunction('reportToTest', (event: string, props: unknown) => {
analyticsCalls.push({ event, props });
});
await page.goto('https://example.com');
// Hook the app's analytics so each call is forwarded to Node.
await page.evaluate(() => {
const original = (window as any).analytics?.track;
(window as any).analytics = {
track: (event: string, props: unknown) => {
(window as any).reportToTest(event, props);
original?.(event, props);
},
};
});
await page.getByRole('button', { name: 'Add to cart' }).click();
// The exposed function returns a Promise; give the call time to land.
await expect.poll(() => analyticsCalls.length).toBeGreaterThan(0);
expect(analyticsCalls[0].event).toBe('add_to_cart');
});
Pattern 6 — addInitScript to run code before any page script
Use this (not evaluate) when you must override a browser API before the app boots — e.g. freezing Date.now or stubbing geolocation.
test('freezes time before the app loads', async ({ page }) => {
await page.addInitScript(() => {
const fixed = new Date('2025-01-01T00:00:00Z').valueOf();
Date.now = () => fixed;
});
await page.goto('https://example.com/dashboard');
await expect(page.getByTestId('current-year')).toHaveText('2025');
});
Best Practices
- Default to locators for actions; reach for
evaluateonly to read. If you typedevaluate(() => el.click()), ask whetherlocator.click()is correct instead. - Bundle multiple inputs into one object argument and destructure inside the callback —
evaluateaccepts a single arg. - Return plain JSON-serializable data from
evaluate; if the result is non-serializable, switch toevaluateHandle. - Call
handle.dispose()when finished with aJSHandle/ElementHandlein long tests to avoid leaking browser memory. - Install
exposeFunction/addInitScriptbeforepage.gotoso they are present when the page executes. - Prefer
locator.evaluate(el => ...)overpage.evaluateplus a manualquerySelector— the locator already found and waited for the element.
Anti-Patterns
- Using
evaluateto click, type, or hover. It skips Playwright's actionability checks (visibility, enabled, stable), so tests pass on broken UIs. - Closing over Node variables in the callback.
const url = '...'; page.evaluate(() => fetch(url))isundefinedinside the browser — passurlas an argument. - Interpolating page-derived strings into the evaluated source.
page.evaluate(\run('${userInput}')`)` is an injection vector; pass values as arguments instead. - Returning DOM nodes, functions, or class instances from
evaluate. They serialize to{}orundefined. Return primitives/plain objects, or use a handle. - Forgetting
evaluateaccepts only one argument and passing two positional values — the second is silently dropped. - Never disposing handles in loops or long-lived sessions, causing the browser process to grow unbounded.
- Using
addInitScriptfor assertions — it only injects setup code; read state withevaluateafter load.
When to Trigger This Skill
- "Read localStorage / sessionStorage / a cookie value in Playwright"
- "Get a JavaScript variable or
window.__STATE__from the page" - "How do I pass a variable into
page.evaluate?" - "Difference between
evaluateandevaluateHandle" - "Call a Node function from inside the browser" / "capture analytics calls"
- "Read computed CSS /
getBoundingClientRectin a test" - "Mock
Date.now/ geolocation before the page loads" - "Should I use
evaluateto click this element?"