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.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
A direct command skips the review prompt. Inspect the source before running it.
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.
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. Use evaluate to extract state. Use page.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) — exceptJSHandle/ElementHandle, which are passed by reference. Functions, class instances, and undefined keys do not cross intact.
evaluate returns serialized values; evaluateHandle returns 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.
// Read localStorage (impossible to assert on from Node directly).
const
await
evaluate
() =>
localStorage
getItem
'theme'
expect
toBe
'dark'
// Read a computed style the user actually sees.
const
await
evaluate
() =>
const
document
querySelector
'button.primary'
return
getComputedStyle
backgroundColor
expect
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 asWindow).innerWidth, windowHandle);
expect(innerWidth).toBeGreaterThan(0);
// Handle to a specific element with live properties.const inputHandle = await page.evaluateHandle(
() =>document.querySelector('input#email') asHTMLInputElement,
);
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 }) => {
constanalyticsCalls: 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 = (windowasany).analytics?.track;
(windowasany).analytics = {
track: (event: string, props: unknown) => {
(windowasany).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 = newDate('2025-01-01T00:00:00Z').valueOf();
Date.now = () => fixed;
});
await page.goto('https://example.com/dashboard');
awaitexpect(page.getByTestId('current-year')).toHaveText('2025');
});
Best Practices
Default to locators for actions; reach for evaluate only to read. If you typed evaluate(() => el.click()), ask whether locator.click() is correct instead.
Bundle multiple inputs into one object argument and destructure inside the callback — evaluate accepts a single arg.
Return plain JSON-serializable data from evaluate; if the result is non-serializable, switch to evaluateHandle.
Call handle.dispose() when finished with a JSHandle/ElementHandle in long tests to avoid leaking browser memory.
Install exposeFunction / addInitScript before page.goto so they are present when the page executes.
Prefer locator.evaluate(el => ...) over page.evaluate plus a manual querySelector — the locator already found and waited for the element.
Anti-Patterns
Using evaluate to 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)) is undefined inside the browser — pass url as 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 {} or undefined. Return primitives/plain objects, or use a handle.
Forgetting evaluate accepts 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 addInitScript for assertions — it only injects setup code; read state with evaluate after 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 evaluate and evaluateHandle"
"Call a Node function from inside the browser" / "capture analytics calls"
"Read computed CSS / getBoundingClientRect in a test"
"Mock Date.now / geolocation before the page loads"