How to Test contenteditable Editors, Paste Sanitization, and Undo Behavior Without Brittle Browser Assertions
By Antoine Dubois · August 16, 2026
A practical guide to testing contenteditable editors in real browsers, with guidance on paste sanitization, selection ranges, undo and redo, DOM normalization, and stable Playwright assertions.
A contenteditable surface looks like a text box, but it behaves more like a tiny editor runtime. That is why tests that assert raw DOM strings, single keystrokes, or exact selection offsets tend to fail after the first real browser change.
If your target is to test contenteditable paste undo behavior, the reliable approach is to assert the editor contract, not incidental browser internals. In other words, verify what the user can see, copy, paste, undo, and retype, then normalize the DOM before comparing it.
The hard part is not typing text. It is proving that the editor preserves intent across browser sanitization, selection changes, and undo stack mutations.
This article focuses on real browser testing with Playwright, but the same ideas apply to Selenium or Cypress when the editor is backed by native browser editing behavior.
What makes contenteditable tests brittle
contenteditable is not a single feature. It is a browser editing host with several overlapping behaviors:
- keyboard input updates selection and the undo stack
- paste can insert plain text, HTML, or sanitized HTML depending on the browser and event handlers
- the DOM may normalize adjacent text nodes or split formatting wrappers
execCommand,beforeinput, andinputevents can all be involved, depending on implementation- the visible caret position is not always encoded in the DOM in a stable way
The MDN documentation for contenteditable is a useful reminder that it is an enumerated global attribute, not a full editor framework. For rich text, browser behavior matters as much as your application code.
For paste and editing semantics, the Input Events specification and the beforeinput event documentation are the most relevant references. They explain why a user action can produce different intermediate states than a direct DOM mutation.
The testing model that stays stable
A practical test strategy uses three layers:
1. User-visible result
Assert the editor content as rendered text or sanitized HTML after the action completes. Prefer a normalized representation over a raw HTML string.
2. Behavioral contract
Assert that paste, undo, redo, and selection changes produce the intended editor behavior. For example, after a paste, does the editor keep allowed formatting and strip forbidden markup?
3. Low-level event flow only when needed
Inspect beforeinput, input, paste, and selection changes only for the cases where the application owns the sanitization or history logic. Do not make every test depend on event ordering unless that ordering is part of the feature.
If a test fails because the browser split a text node, your assertion is too low-level. If it fails because unsafe HTML survived the paste, your assertion is at the right level.
A compact decision table
| Behavior | What to assert | What not to assert |
|---|---|---|
| Plain typing | Final visible text or normalized DOM | Exact sequence of intermediate text nodes |
| Paste sanitization | Allowed markup kept, disallowed markup removed | Browser-specific clipboard internals |
| Undo / redo | Content returns to prior visible state | Internal undo stack shape |
| Selection handling | User-visible caret-dependent outcome | Absolute selection offsets after every keystroke |
| DOM normalization | Final normalized structure | Raw HTML string before cleanup |
Build the smallest reproducible editor fixture
Create a minimal page that includes only the editing surface, your sanitization logic, and a stable way to read the result.
A good fixture avoids framework noise. If your production editor is wrapped in React, ProseMirror, Slate, Quill, or a custom inline rename field, you still want a stripped-down page that reproduces the behavior with the same DOM contract.
Example host markup:
```html
<div
id="editor"
contenteditable="true"
role="textbox"
aria-multiline="true"
></div>
For testability, expose one normalized read function from the page. This keeps the assertion logic out of brittle DOM traversal.
```typescript
function normalizeEditorHtml(root: HTMLElement): string {
return root.innerHTML
.replace(/\s+/g, ' ')
.replace(/> </g, '><')
.trim();
}
The normalization rule should match your product contract. Do not over-clean the HTML, or you may hide defects. If your editor must preserve <strong> but remove inline style, encode that explicitly in the sanitizer and test for it.
Testing paste sanitization
Paste is the highest-value path to cover because it crosses browser and application boundaries.
A robust paste test checks three things:
- the editor receives a paste action
- disallowed HTML is removed or converted as intended
- the final content matches the allowed formatting contract
With Playwright, use the clipboard API when available, or simulate paste through the page if your setup permits it. For many editor tests, the simplest stable path is to set the clipboard content and trigger paste in the browser context.
import { test, expect } from '@playwright/test';
test('sanitizes pasted html', async ({ page, context }) => {
await page.goto('http://localhost:3000/editor-fixture');
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
await page.evaluate(async () => {
await navigator.clipboard.writeText('<b>Safe</b><img src=x onerror=alert(1)>');
});
await page.locator('#editor').click();
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+V' : 'Control+V');
await expect(page.locator('#editor')).toContainText('Safe');
await expect(page.locator('#editor')).not.toContainText('onerror');
});
That example is intentionally conservative. If your application paste handler reads event.clipboardData, you may need to dispatch a real paste event in the page and inspect the application output. What matters is that the test observes the final sanitized result, not the browser’s private clipboard implementation.
When to test HTML versus plain text
Test HTML when formatting matters, such as bold, links, lists, and inline mentions. Test plain text when the editor is meant to behave like an inline rename field, search box, or AI prompt input that only accepts text.
If you accept pasted HTML but later serialize to plain text, assert the serialized result, not the transient rich DOM.
Undo and redo need stateful assertions
Undo behavior is where many tests become misleading. The browser undo stack is tied to actual editing actions, not arbitrary DOM changes. If your test mutates innerHTML directly, pressing Undo may do nothing useful, because the browser did not create the previous state.
Use genuine user interactions when you want to validate undo and redo.
test('undo restores the previous user-editable state', async ({ page }) => {
await page.goto('http://localhost:3000/editor-fixture');
const editor = page.locator('#editor');
await editor.click();
await page.keyboard.type('Hello');
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+Z' : 'Control+Z');
await expect(editor).toHaveText('');
});
This is a minimal case. Real editors often have more complicated history rules:
- a paste may create one undo step
- typing plus formatting may create multiple steps
- autoformatting may merge or split history entries
- IME input can behave differently from direct keystrokes
If you support redo, test it separately after undo. Do not assume the redo stack survives unrelated DOM mutations or focus changes.
Selection ranges are important, but do not over-assert them
Selection is critical for paste, replacement, and formatting, but exact offsets are fragile across browsers. A test usually needs to prove one of two things:
- the selection is preserved well enough to replace the intended text
- the resulting content reflects the expected selection-based action
If you truly need to inspect selection, query it inside the page and assert a small number of stable facts.
test('selects the inserted token', async ({ page }) => {
await page.goto('http://localhost:3000/editor-fixture');
const result = await page.evaluate(() => {
const el = document.getElementById('editor')!;
el.textContent = 'Hello world';
const range = document.createRange();
range.setStart(el.firstChild!, 6);
range.setEnd(el.firstChild!, 11);
const sel = window.getSelection()!;
sel.removeAllRanges();
sel.addRange(range);
return {
selected: sel.toString(),
text: el.textContent
};
});
expect(result.selected).toBe('world');
expect(result.text).toBe('Hello world');
});
Use this style sparingly. Selection assertions are best when they confirm a browser interaction precondition, not as a substitute for an end-to-end behavior check.
DOM normalization is not optional
Many rich-text editors produce semantically equivalent DOM structures that are not byte-for-byte identical. For example, one browser may merge adjacent text nodes while another may split them after formatting changes.
Do not compare raw innerHTML unless your editor serializes to a canonical format and you control every mutation. Instead:
- normalize whitespace
- remove editor-only attributes that do not affect output
- sort or canonicalize attribute order if your serializer does not already do it
- compare semantic output, such as Markdown or schema JSON, when that is the product contract
If your editor stores a document model, assert against that model as well as the rendered DOM. That gives you one test for behavior and one for serialization.
Failure modes worth looking for
1. Sanitizer strips too much
The editor removes useful formatting, such as links or bold text, because the allowlist is too strict. The test should show that allowed markup survives paste.
2. Sanitizer strips too little
Unsafe attributes or tags survive. This is a security bug, not just a UI bug. Test for the absence of scripting-capable attributes and unsupported tags.
3. Undo is broken after programmatic normalization
An editor that rewrites innerHTML after each input can break the history stack. If the test only checks the final DOM, this problem is easy to miss. Explicitly verify undo after typing and after paste.
4. Selection-dependent commands target the wrong range
Formatting or replacement applies to stale selection ranges after paste or async updates. This is easiest to reproduce with copy-paste and inline mention insertion.
5. IME or mobile input is ignored
Typing tests that use only keyboard.type may miss composition behavior. If your product supports multilingual text input, add a separate path for composition-heavy flows.
A practical test matrix
For a WYSIWYG editor or inline editing control, I would keep the matrix small but meaningful:
- type plain text
- paste plain text
- paste rich HTML
- paste disallowed HTML and confirm sanitization
- undo after typing
- undo after paste
- redo after undo
- replace selected text
- serialize or save the final content
That is usually enough to catch regressions in browser behavior without drowning the suite in low-level assertions.
Not the best fit if you only need a mock-rich unit test
If your component is a static rendering of text, not a browser editing host, then a DOM unit test is enough. You do not need browser-level paste and undo coverage for a pure presentational component.
If your editor implementation is built on a third-party framework with a stable document model, you may prefer testing that model directly in addition to the browser surface. The right split depends on where regressions actually happen, DOM serialization, selection logic, or save/load behavior.
A simple rule of thumb
Use browser tests when the risk lives in browser behavior. Use model tests when the risk lives in your editor schema or sanitizer. Use both when the feature crosses the boundary between them.
That is the core of stable contenteditable testing. Do not ask the browser to prove its own internals. Ask it to prove the user-visible contract after real editing actions.
FAQ
How do I test contenteditable paste undo behavior without flaky assertions?
Assert the final visible content or normalized serialized output after each action, not raw intermediate DOM mutations. Use real keyboard and paste actions, then verify undo and redo as user-visible state changes.
Should I check innerHTML in contenteditable tests?
Only if your product contract is a canonical HTML serializer. Otherwise, normalize the DOM or assert against a model output, because browsers can reshape equivalent markup.
How do I verify clipboard sanitization?
Paste content that contains both allowed and disallowed markup, then assert that allowed formatting remains and unsafe tags or attributes are removed. Avoid depending on browser-specific clipboard internals.
Why does Undo fail after my test sets innerHTML?
Because the browser undo stack is tied to editing actions, not arbitrary DOM assignment. Use actual typing, paste, and selection interactions when you want to test undo.
What should I do about selection assertions?
Keep them narrow. Assert the selection only when it is part of the feature under test, such as replacing highlighted text or inserting a mention. For most cases, the end result matters more than the exact range.