Use page.evaluate() for an immediate, whole-page check: evaluate a predicate in the browser and return a Boolean. For text that appears after client-side rendering, wait with page.waitForFunction(). If you need the element that contains the text, use Puppeteer’s text selector or a locator instead. The correct method depends on timing, scope, and matching rules such as case sensitivity and whitespace.
Choose the check that matches your question
| Question | Recommended API | What it proves |
|---|---|---|
| Does this string occur in the current page text? | page.evaluate() |
A Boolean result for the chosen DOM text representation at that instant. |
| Will this string appear after JavaScript finishes rendering? | page.waitForFunction() |
That a page-context predicate becomes truthy before the timeout. |
| Which element contains this text? | Text selector or locator | A handle or locator for a minimal matching element, including text in open shadow roots. |
| Does a known selector exist? | page.waitForSelector() |
That at least one element matches the selector; it does not verify arbitrary text. |
These APIs are documented in Puppeteer’s Page.evaluate reference, Frame.waitForFunction reference, Page.waitForSelector reference, and page interactions guide. The documentation pages observed for this topic are labeled Puppeteer 25.10.0 or 25.12.0; your installed version can differ.
Immediate whole-page substring check
This is the smallest useful test. It reads document.body.innerText in the page context and applies JavaScript’s case-sensitive includes():
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({headless: true});
const page = await browser.newPage();
await page.goto('https://example.com', {waitUntil: 'domcontentloaded'});
const target = 'Order confirmed';
const exists = await page.evaluate(
text => document.body?.innerText.includes(text) ?? false,
target,
);
console.log({target, exists});
await browser.close();
page.evaluate() serializes the function’s return value back to Node.js. Passing target as an argument is safer and clearer than interpolating it into source code: quotes, backslashes, and user input remain data rather than executable JavaScript.
What’s actually slowing this PC down?
Pick the symptom - the matching free tool is one click away.
#1 Best Overall
innerText versus textContent
innerText approximates rendered text and is affected by layout and visibility. textContent reads the DOM text nodes, including text that may not be rendered. Choose deliberately:
const domTextExists = await page.evaluate(
text => document.body?.textContent?.includes(text) ?? false,
target,
);
Neither method automatically performs case folding, word-boundary matching, or whitespace normalization. A substring such as Order also matches PreOrder. State the intended rule in the test name and assertion.
Define matching semantics explicitly
Case-insensitive matching
const exists = await page.evaluate((text) => {
const haystack = document.body?.innerText ?? '';
return haystack.toLocaleLowerCase().includes(text.toLocaleLowerCase());
}, target);
For predictable machine-oriented checks, use a fixed normalization policy rather than locale-sensitive behavior:
const exists = await page.evaluate((text) => {
const normalize = value => value.normalize('NFKC').toLowerCase();
return normalize(document.body?.innerText ?? '').includes(normalize(text));
}, target);
Whitespace-insensitive matching
const compact = value => value.replace(/s+/g, ' ').trim();
const exists = await page.evaluate((text) => {
const compact = value => value.replace(/s+/g, ' ').trim();
return compact(document.body?.innerText ?? '').includes(compact(text));
}, target);
Do not normalize whitespace when spaces carry meaning, such as preformatted code or a fixed-format identifier.
Rank #2
Exact text instead of a substring
For an exact whole-page comparison, normalize both values and compare with ===. More commonly, exactness applies to one element; locate that element and compare its normalized textContent rather than treating a page-wide substring as exact.
Wait for text rendered asynchronously
A one-time evaluation reports only the current state. Single-page applications may insert the target after an API response, hydration, or a delayed component render. Use a finite predicate wait:
const target = 'Order confirmed';
await page.waitForFunction(
text => (document.body?.innerText ?? '').includes(text),
{timeout: 10_000, polling: 'mutation'},
target,
);
console.log('Text appeared');
waitForFunction() repeatedly runs the predicate in the page context until it returns a truthy value or the timeout expires. Its options support polling, timeout, and cancellation through a signal. A finite timeout turns a missing message into a useful test failure instead of an indefinitely pending run. If your application changes text through timers rather than DOM mutations, use interval polling:
await page.waitForFunction(
text => (document.body?.innerText ?? '').includes(text),
{timeout: 15_000, polling: 100},
target,
);
Wait for a selector when the selector is the requirement
If the application contract is “the confirmation element is present,” use waitForSelector():
const confirmation = await page.waitForSelector('[data-testid="confirmation"]', {
visible: true,
timeout: 10_000,
});
if (!confirmation) throw new Error('Confirmation element was not found');
This proves selector presence (and, with visible: true, visibility according to Puppeteer’s selector wait). It does not prove that the element contains a particular phrase. Read and test its text separately:
const actual = await page.$eval(
'[data-testid="confirmation"]',
element => element.textContent ?? '',
);
if (!actual.includes('Order confirmed')) {
throw new Error(`Unexpected confirmation text: ${actual}`);
}
The Page.$eval reference describes evaluating against the first matching element. If you retain an element handle from a lower-level wait, dispose of it when finished; the interactions guide warns that unreleased handles can contribute to memory leaks.
Find the element that contains the text
Use a text selector when the next operation is element-oriented—for example, clicking a button whose label is “Continue.” Puppeteer’s interactions guide documents the ::-p-text(...) selector and locators:
const locator = page.locator('::-p-text(Order confirmed)');
await locator.wait();
const text = await locator.map(element => element.textContent).first().wait();
console.log(text);
The text selector targets minimal elements containing the requested text and can match text in open shadow roots. That behavior is different from scanning document.body.innerText. It does not automatically mean exact normalized equality; compare the returned text yourself when exactness matters.
The Tool Desk
Outbyte PC Repair FREEClear out junk files and repair common Windows errorsFree Scan →Outbyte Driver Updater FREEFix the driver behind crashes, sound loss and screen glitchesFind Drivers →Punctuation and selector syntax
Characters that overlap selector syntax can require escaping or a different locator expression. If the target is supplied by a user or external data, prefer a page-context predicate for a literal Boolean check, or validate and escape the selector input before constructing a text selector.
Open and closed shadow roots
Puppeteer’s documented text selector explicitly covers open shadow roots. A body-text snapshot and a text selector may therefore produce different results in component-heavy applications. Closed shadow roots intentionally restrict outside access; test through a public UI signal or application-level hook instead of claiming that a page-wide scan covers them.
Build a reliable reusable helper
export async function pageContainsText(page, text, {
timeout = 0,
caseSensitive = true,
collapseWhitespace = false,
property = 'innerText',
} = {}) {
const normalize = value => {
let result = value;
if (collapseWhitespace) result = result.replace(/s+/g, ' ').trim();
if (!caseSensitive) result = result.toLowerCase();
return result;
};
const predicate = (value, options) => {
const raw = document.body?.[options.property] ?? '';
const normalizeInPage = input => {
let result = String(input);
if (options.collapseWhitespace) result = result.replace(/s+/g, ' ').trim();
if (!options.caseSensitive) result = result.toLowerCase();
return result;
};
return normalizeInPage(raw).includes(normalizeInPage(value));
};
const options = {caseSensitive, collapseWhitespace, property};
if (timeout > 0) {
await page.waitForFunction(predicate, {timeout}, text, options);
return true;
}
return page.evaluate(predicate, text, options);
}
Keep the helper’s policy visible in its options. A test that silently changes from case-sensitive to case-insensitive matching can pass while the UI regresses.
Navigation and timing setup
Text checks are only as reliable as the page state they inspect. Choose a navigation wait condition that matches the application:
Free tools Windows power users keep installed
One-click scans. No signup required.
Best Value
domcontentloadedis useful when the initial DOM is the test target.loadwaits for the load event and its dependent resources.networkidle0ornetworkidle2can help on pages that finish rendering after requests, but analytics, long polling, and streaming can prevent a true idle state.
await page.goto(url, {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
// Then wait for the specific text or selector your test requires.
Prefer a meaningful application signal over a large arbitrary delay. A fixed waitForTimeout() can be too short on a busy runner and unnecessarily slow on a fast one; a predicate or selector wait expresses the real condition.
Common failures and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| The result is false, but a user sees the phrase. | Checked before client rendering, used a different case, or text is inside a component boundary. | Use waitForFunction(), define normalization, and inspect the component’s shadow-root behavior. |
waitForSelector() succeeds but the text assertion fails. |
The selector exists but its content is different or still loading. | Read textContent and assert the phrase explicitly, or wait on a text predicate. |
| The wait times out. | The phrase never appears, the wrong frame is active, or the timeout is shorter than the real render time. | Capture diagnostics, verify the URL and frame, check the page text, and set a realistic finite timeout. |
| Text is split by line breaks or nested spans. | Whitespace in innerText or textContent differs from the visual phrase. |
Collapse whitespace deliberately, or assert on the element’s accessible/semantic signal instead. |
| A text selector returns an unexpected ancestor. | The selector chooses a minimal element according to its documented behavior, not necessarily your preferred tag. | Inspect the matched element and add a structural selector or explicit text comparison. |
| The browser process hangs or memory grows. | Browser/page instances or element handles are not closed or disposed. | Use try/finally to close the browser and dispose retained handles. |
| The page is in an iframe. | page.evaluate() runs in the main frame. |
Obtain the target frame and call its evaluation or waiting API, then verify the frame URL and lifecycle. |
Add diagnostics on failure
try {
await page.waitForFunction(
text => (document.body?.innerText ?? '').includes(text),
{timeout: 8_000},
target,
);
} catch (error) {
console.error('URL:', page.url());
console.error('Title:', await page.title());
console.error('Visible text sample:', (await page.evaluate(() => document.body?.innerText ?? '')).slice(0, 2_000));
throw error;
}
Performance, reliability, and security
- Evaluate one predicate rather than transferring an entire large DOM to Node.js. The Boolean result is small and avoids unnecessary serialization.
- Use a selector or scoped element evaluation when the page is large and the requirement concerns one component.
- Choose mutation polling for DOM-driven rendering and interval polling for timer-driven updates; keep the timeout bounded.
- Do not put untrusted text into executable code. Pass it as an argument to
evaluate(),waitForFunction(), or a locator API. - Use stable test hooks such as
data-testidwhen you own the page. Human-facing copy changes more often than an explicit test contract. - When matching sensitive text, avoid logging complete page text in CI output; log a short, redacted diagnostic.
Or skip the browser setup
If you only need a rendered screenshot or PDF rather than a Puppeteer assertion, ScreenshotNeo provides a website screenshot API and MCP server. One GET request returns PNG, JPEG, WebP, or PDF. It accepts cookie/consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each cleanup step can be disabled. Bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed, and the response identifies the result with X-Page-Verdict and X-Billed headers. This is a capture service, not a replacement for a Boolean DOM assertion, but it can remove browser orchestration when your deliverable is visual.
cURL
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Python
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
Node.js
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const buffer = Buffer.from(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', buffer));
See the ScreenshotNeo documentation for request options. It supports full-page capture with lazy images, CSS-selector element capture, dark mode, device presets and custom viewports, retina scale, PDF paper settings and page ranges, custom CSS and JavaScript, clicks, selector/delay/network-idle waits, request and resource blocking, headers/cookies/user agents, authorization, timezone and geolocation, transparent backgrounds, resizing, chosen TTL caching, signed links, asynchronous webhooks, bulk capture of up to 100 URLs per call, usage reporting, and an OpenAPI specification. An MCP server exposes take_screenshot, get_page_info, and capture_pdf to Claude, Cursor, and other MCP clients. Parameter names used by other screenshot APIs are accepted to ease migration.
Every plan includes every feature: Free provides 1,000 shots per month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account to start without a card.
Windows Errors? Fix Them Before They Spread
Repair common Windows errors and clear accumulated junk for a smoother, more stable PC - no reinstall needed.Free scan · no reinstallOutdated Drivers Are Slowing You Down
One free scan finds every outdated or missing driver and matches the right update for your exact hardware.Free scan · exact hardware matchTesting strategy
- Navigate to the page with an explicit timeout and an appropriate initial wait condition.
- State whether the assertion is substring, exact, case-insensitive, or whitespace-normalized.
- Use
evaluate()for an immediate snapshot,waitForFunction()for asynchronous text, or a text selector/locator for an element operation. - Scope the check to a stable element or frame when a whole-page scan could produce false positives.
- On failure, record the URL, title, frame, and a redacted text sample, then close resources in cleanup code.
Frequently Asked Questions
Does Puppeteer have a dedicated contains-text assertion?
Puppeteer supplies browser APIs rather than a built-in assertion library. Evaluate innerText or textContent for a Boolean, or use a text selector/locator to find an element; add assertions from your test framework.
Can a body-text check see text in an iframe?
Not from the main page context. Select the iframe’s frame and run the evaluation or wait in that frame.
Should I use innerText or textContent?
Use innerText when rendered text is the requirement and textContent when DOM text nodes, including hidden content, are the requirement.
What happens when the text never appears?
waitForFunction() rejects after its timeout. Catch the error to add diagnostics, then fail the test rather than using an unbounded wait.
Recommended Free Tools
Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.




