How to Test `contenteditable`, Paste Sanitization, and Rich Text Selection Ranges Without Flaky Assertions
By David Frei · September 26, 2026
A practical guide to testing contenteditable editors, paste sanitization, and selection ranges with browser automation, with a focus on stable document-state assertions.
Rich text editor tests usually fail for boring reasons, not mysterious ones. The DOM changes after paste, selection ranges move as the browser normalizes input, clipboard formats vary by browser, and the visible caret is not a stable thing to assert against. If your test only checks keystroke timing or screen pixels, it will be fragile.
The reliable strategy is simpler: drive the editor like a user, then assert on the resulting document state. That means checking the editor model, serialized HTML, selected text, or a normalized DOM snapshot after the browser has finished dispatching beforeinput, input, and clipboard-related events.
For
contenteditablefields, the hard part is not typing. It is proving that the browser accepted, transformed, or rejected the paste in the way you expected.
The short version
If you need to test contenteditable paste handling in browser automation, focus on three layers:
- Selection setup: put the caret or range where the paste should land.
- Paste path: trigger the browser’s paste behavior, or simulate the same event path your editor uses.
- State assertion: verify the final DOM or editor model after sanitization, normalization, and undoable mutations.
Avoid asserting immediately after keystrokes, and avoid expecting the pasted HTML string to survive unchanged. Browsers and editors often strip scripts, normalize tags, unwrap spans, merge text nodes, or rewrite the selection after insertion.
Why these tests get flaky
A rich text editor is not a plain input. The browser can change text by way of selection ranges, clipboard data, and editing commands, while the editor framework may add its own transformation layer on top.
The unstable parts usually come from these behaviors:
- Selection ranges change during editing. A paste replaces the current range, so the selection you set before the action may not exist afterward.
- Clipboard formats are browser-specific. The same paste action may expose
text/plain,text/html, or both, depending on the source and browser. - Sanitization rewrites the DOM. Unsafe or unsupported markup is often removed or normalized.
- Undo/redo is part of the contract. If paste is handled correctly, users should be able to undo it as a single edit operation, or in a browser-specific sequence.
- Framework abstractions hide the browser event order. The editor may listen to
beforeinput,input,paste, or selection events, then mutate the document asynchronously.
For the browser side of this, the most useful primary references are the Selection API, the Clipboard API and events, and MDN’s docs for beforeinput, input, and contenteditable.
What to assert instead of timing
The safest assertions depend on what the editor is supposed to preserve.
Prefer document state over keystroke timing
Good targets for assertions:
- Sanitized HTML in the editor container
- Plain text extracted from the editable region
- Editor model state, if the app exposes one
- Selection endpoints after the action, when selection behavior matters
- Undo stack behavior, if paste should be reversible as one edit
Weak targets for assertions:
- Exact sequence of keydown/keyup timestamps
- Caret pixel position
- Intermediate DOM states right after
paste - Raw clipboard payload as seen only in a single browser
If the app uses a framework like ProseMirror, Slate, Quill, or a custom model, it is often better to assert the model-visible output rather than the raw contenteditable HTML. The browser DOM may be an implementation detail, while the editor state is the user-facing contract.
A stable testing pattern
A reliable test usually has the same structure:
- Set initial content.
- Select the target range.
- Paste a known payload.
- Wait for the editor to settle.
- Assert the normalized result.
Here is a Playwright example that targets final DOM state, not transient keyboard behavior.
import { test, expect } from '@playwright/test';
test('sanitizes pasted HTML in a contenteditable editor', async ({ page }) => {
await page.setContent(`
<div id="editor" contenteditable="true">Hello world</div>
<script>
const editor = document.getElementById('editor');
editor.addEventListener('paste', (event) => {
event.preventDefault();
const html = event.clipboardData?.getData('text/html') || '';
editor.innerHTML = html
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/ on\w+="[^"]*"/g, '');
});
</script>
`);
const editor = page.locator('#editor');
await editor.click();
await page.keyboard.press('Control+A');
await page.keyboard.insertText('<p onclick="alert(1)">Safe <strong>text</strong></p>');
await expect(editor).toHaveText('Safe text');
await expect(editor).toContainText('Safe');
await expect(editor).toHaveJSProperty('innerHTML', '<p>Safe <strong>text</strong></p>');
});
This example is intentionally small. The key point is that the assertion waits for the resulting state, not for a single event boundary.
Testing paste sanitization
Paste sanitization is where many test suites overfit to one browser or one browser cloud.
What you want to prove is usually one of these:
- Dangerous markup is removed.
- Allowed formatting survives.
- Nested structures are normalized consistently.
- Plain text paste does not become HTML unexpectedly.
A good sanitization test starts with explicit input and explicit output. For example, if your product strips style, script, and inline event handlers, assert exactly those removals and nothing else.
await expect(editor).toHaveJSProperty(
'innerHTML',
'<p>Safe <strong>text</strong></p>'
);
If the sanitizer normalizes whitespace, lists, or block tags differently across browsers, do not compare the whole HTML string raw unless you control the normalization step. Instead, parse and compare a normalized structure.
A useful pattern is to extract a simplified representation before asserting:
const html = await editor.evaluate(el => el.innerHTML);
expect(html.replace(/\s+/g, ' ')).toContain('<strong>text</strong>');
expect(html).not.toMatch(/script|onclick|style=/i);
That is not as pretty as a one-line snapshot, but it survives browser-specific formatting changes better.
When to assert on plain text
Use plain-text assertions when the feature under test is not formatting, but insertion, replacement, or length. For example, support chat composers often only care that pasted text is preserved and links are stripped.
If the feature is truly rich text, asserting only on plain text can hide regressions. A paste that converts bullets to a single paragraph may still pass a text-only test.
Selection range testing without caret roulette
Selection is the part that makes many rich text tests look nondeterministic. The browser selection is live, it can collapse after edit operations, and the result depends on whether the editor replaced a range or inserted at a caret.
For a selection range test, do not rely on click coordinates alone unless the editor is visually simple. Programmatic range setup is clearer and more reproducible.
await page.evaluate(() => {
const editor = document.getElementById('editor')!;
const textNode = editor.firstChild!;
const range = document.createRange();
range.setStart(textNode, 6);
range.setEnd(textNode, 11);
const selection = window.getSelection()!; selection.removeAllRanges(); selection.addRange(range); });
That code selects world in Hello world. After a paste, the important assertion is that the replacement text appears where the range was, not that the same range still exists.
If your product exposes a selection toolbar, test the toolbar against the selected content, not against the browser’s highlight color. The highlight is a rendering artifact, not the contract.
Selection assertions are only useful when the behavior itself depends on selection, for example replacement, formatting, or link insertion. Otherwise, prefer content assertions.
Verify the selection effect, not the caret artifact
Useful checks after a selection-driven paste include:
- The selected text is replaced, not appended.
- The pasted content is inserted at the correct boundary.
- Surrounding formatting remains intact.
- The editor does not duplicate nodes around the insertion point.
Dealing with async DOM mutation
Many editors mutate the DOM after the browser fires the initial input event. That mutation may happen in a microtask, animation frame, or framework render cycle. If your test asserts too early, it will capture the pre-sanitized state.
Use a wait condition that reflects the final state, not a fixed sleep.
await expect(editor).toHaveText('Safe text');
or, if the editor emits a known marker when it settles:
await page.waitForFunction(() => {
const editor = document.getElementById('editor');
return editor?.getAttribute('data-ready') === 'true';
});
Avoid waitForTimeout() unless you are debugging. It makes test duration arbitrary and still does not prove that the DOM has settled.
Browser automation paste events: what to be careful about
A clipboard-driven test in browser automation is not the same as a human pressing Ctrl+V in a real browser window. Depending on the framework and environment, you may need to simulate clipboard access, inject events, or use the editor’s paste handler directly.
The important distinction is this:
- Browser restriction: the page script cannot freely read system clipboard contents without user permission and browser rules.
- Automation capability: the test framework may be able to set clipboard state, dispatch input, or drive the browser in a way the page itself cannot.
That is why a test should verify the editor’s result, not the exact path the clipboard took through the operating system.
For Selenium-based suites, you often end up using native key sequences plus DOM assertions, especially when the browser and driver combination makes direct clipboard control awkward. Selenium’s WebDriver actions can help with selection and keyboard input, but they do not remove the need for stable post-action assertions.
A practical checklist for flaky-editor cleanup
If a rich text test is unstable, check these items in order:
- Is the editor focus state explicit? Click or focus the editor before typing or pasting.
- Is the selection deterministic? Set the range programmatically when possible.
- Are you waiting for the final render? Assert after the sanitizer or framework update has finished.
- Are you asserting the right layer? Prefer model or normalized DOM over raw keystrokes.
- Is the clipboard source realistic? Test both plain text and formatted HTML if both matter.
- Is undo part of the requirement? Add a separate test for
Ctrl+Zor equivalent after paste.
If you keep only one habit, make it this: assert the final document as a user would perceive it, not the transient event sequence that created it.
Example: a paste regression test that checks the document, not the gesture
import { test, expect } from '@playwright/test';
test('pasted formatting is sanitized and selection is replaced', async ({ page }) => {
await page.setContent(`
<div id="editor" contenteditable="true"><p>Hello world</p></div>
`);
const editor = page.locator('#editor');
await page.evaluate(() => {
const editor = document.getElementById('editor')!;
const text = editor.querySelector('p')!.firstChild!;
const range = document.createRange();
range.setStart(text, 6);
range.setEnd(text, 11);
const sel = window.getSelection()!;
sel.removeAllRanges();
sel.addRange(range);
});
await page.evaluate(() => {
const editor = document.getElementById('editor')!;
editor.addEventListener('paste', (event) => {
event.preventDefault();
const data = event.clipboardData?.getData('text/html') || '<strong>earth</strong>';
editor.querySelector('p')!.innerHTML = data.replace(/<[^>]+>/g, '');
}, { once: true });
});
await page.keyboard.press('Control+V');
await expect(editor).toHaveText('Hello earth');
await expect(editor).toHaveJSProperty('innerHTML', '<p>Hello earth</p>');
});
This is still simplified, but it captures the right testing idea. The test proves the selection was replaced and the final content was normalized, without depending on a fragile event ordering assertion.
When not to over-test the browser
Sometimes the browser is not the thing you need to test most deeply. If your rich text editor already has unit tests for its sanitizer and model transforms, your browser automation should cover the integration points:
- focus and selection
- paste entry point
- final rendered output
- undo behavior
- cross-browser rendering differences that matter to users
That division keeps browser tests short and makes failures easier to diagnose.
Bottom line
For contenteditable and rich text editors, the stable test is the one that checks the final document state after the browser and editor have finished doing their work. Set selection deliberately, trigger paste through a realistic path, wait for the DOM to settle, and assert on normalized output rather than raw timing or caret position.
If you do that, paste sanitization and selection range tests become readable regression tests instead of intermittent guesses.
FAQ
How do I test paste handling in a contenteditable editor without the clipboard?
Use a browser automation path that exercises the paste handler, then assert the final DOM or editor model. If direct clipboard control is unavailable, simulate the paste payload in the editor’s handler, but keep the assertion on the resulting state.
Should I assert innerHTML or visible text?
Use innerHTML when formatting, sanitization, or structure matters. Use visible text when the feature only cares about inserted content. If browser normalization varies, compare a normalized representation instead of raw HTML.
How do I test a selection range before pasting?
Set the selection programmatically with document.createRange() and window.getSelection(). That is more deterministic than click coordinates for most editors.
Why does my paste test pass locally but fail in CI?
Common causes are timing differences, browser-specific clipboard behavior, and asynchronous DOM updates after the initial paste event. Wait for the final content, not a timeout.
What is the safest assertion for rich text editor testing?
The safest assertion is the one that matches the product contract, usually the final editor state after sanitization and normalization. For many editors, that is either the serialized DOM or the underlying model, not the raw input events.