How to Test Rich Text and WYSIWYG Editors
Short answer
WYSIWYG editors fail on contenteditable input quirks, toolbar state desync, and unsafe pasted HTML—not on whether bold looks bold in the iframe. Assert sanitized HTML via server probe after save, use keyboard.type or evaluate for contenteditable, and test paste XSS paths explicitly in CMS and email-builder flows.
Part of Testing Guides by UI patterns.
Who this is for
Teams shipping CMS bodies, email builders, comment boxes, policy editors, or in-app messaging with TipTap, ProseMirror, Quill, Slate, CKEditor, or Draft.js. Any contenteditable surface with toolbar buttons and paste from Word/Google Docs.
Why testing rich text editors matters
Editor bugs become security and brand incidents:
- XSS via paste —
<script>oronerrorattributes stored and rendered on public pages. - Data loss — toolbar shows bold but saved HTML is plain text; marketing publishes broken templates.
- Compliance — legal disclaimers stripped on paste; published policy missing mandatory clauses.
- Email rendering — inline styles stripped differently in preview vs sent message.
- Collaboration conflicts — last save wins over concurrent edits; audit trail wrong.
Probe stored HTML or JSON document model—editor DOM is not the source of truth.
Complexity map
| Scenario | Edge case | Why tests break | Approach |
|---|---|---|---|
fill() on contenteditable | Clears formatting | Empty save | click + keyboard.type |
| Toolbar toggle | State not applied | False bold | Assert probe <strong> |
| Paste from Word | Bloated markup | Sanitizer strips all | Fixture .docx HTML paste |
| XSS paste | <img onerror> | Stored script | Probe sanitized output |
| Link insertion | javascript: URLs | Open redirect | Probe href protocol |
| Mention/autocomplete | Popup intercepts keys | Incomplete mention | Type @ + select option |
| Image upload | Base64 vs CDN URL | Huge DB rows | setInputFiles + probe URL |
| Undo/redo | Stack desync | Wrong content saved | Save after undo; probe |
| Empty document | <p><br></p> | Validation fail | Probe allows empty vs not |
| Read-only mode | Toolbar still active | Edits persist | Probe 403 on save |
| Markdown mode | Dual representation | HTML/md mismatch | Probe canonical format |
| i18n RTL | Caret position | Garbled insert | ExploreChimp RTL pass |
Typing in contenteditable
const editor = page.locator('[contenteditable=true]').first();
await editor.click();
await page.keyboard.type('Hello **world**'); // or use toolbar
await page.getByRole('button', { name: 'Bold' }).click();
await page.keyboard.type(' important');
await page.getByRole('button', { name: 'Save' }).click();
const doc = await request.get(`/api/test/documents/${docId}`).then(r => r.json());
expect(doc.html).toContain('<strong>');
expect(doc.html).not.toMatch(/<script/i);
For ProseMirror/TipTap, some teams set content in test env only:
await page.evaluate(({ html }) => {
window.__TEST_EDITOR__.commands.setContent(html);
}, { html: '<p>Fixture</p>' });
Paste sanitization (security-critical)
const malicious = '<p>Hi</p><img src=x onerror=alert(1)>';
await editor.click();
await page.evaluate(({ html }) => {
const dt = new DataTransfer();
dt.setData('text/html', html);
document.querySelector('[contenteditable]').dispatchEvent(
new ClipboardEvent('paste', { clipboardData: dt, bubbles: true })
);
}, { html: malicious });
await page.getByRole('button', { name: 'Save' }).click();
const saved = await request.get(`/api/test/documents/${docId}`).then(r => r.json());
expect(saved.html).not.toMatch(/onerror/i);
expect(saved.html).not.toMatch(/<script/i);
Always probe server-side sanitizer—client-only DOMPurify can differ from API.
Toolbar and format persistence
| Action | UI check (optional) | Probe assert |
|---|---|---|
| Bold | aria-pressed=true on button | <strong> or font-weight in HTML |
| Heading | Block tag in editor | <h2> in stored doc |
| Link | Dialog URL field | href normalized https |
| List | OL/UL visible | <ol><li> structure |
CI checklist
- One XSS paste negative per public-facing editor
- Save → probe HTML for every critical toolbar action
- Avoid
fill()on contenteditable unless app explicitly supports it - Image uploads via
setInputFileson editor upload hook - CMS publish flow cross-check with CMS publishing guide
- ExploreChimp on RTL locales for layout-breaking toolbars
Anti-patterns
| Anti-pattern | Why it fails | Better approach |
|---|---|---|
| Assert editor.innerHTML only | Unsaved state | Probe after save |
| Skip paste tests | XSS in prod | ClipboardEvent fixture |
| Screenshot toolbar | Theme flake | Probe HTML tags |
fill() long content | Breaks mentions | keyboard.type / setContent |
| Trust client sanitizer alone | API differs | Server probe |
| Test every toolbar icon | Low ROI | Risk-ranked formats |
Example scenario
Situation: Marketing pastes formatted content from Word into CMS body and publishes.
Expected outcome: Public page shows headings and lists; scripts stripped; CDN serves published HTML only.
Why UI-only automation breaks: Editor preview looks fine; public page missing lists because sanitizer removed tags.
- Arrange: Seed draft doc id; use Word-export HTML fixture in repo.
- Act: Paste fixture, save draft, publish workflow.
- Assert: Probe draft HTML retains lists; public probe has no script; live URL matches published slug.
TestChimp workflow: Instrument cms_save with content_source=paste|typed; prioritize paste scenarios if prod paste share is high.
Same Arrange/Act/Assert pattern as expired-coupon checkout.
Connect scenarios to your QA workflow
Capture business rules in markdown test plans and enforce them with seed routes and probe Assert. Link SmartTests with // @Scenario: for requirement traceability. Use /testchimp test on PRs; /testchimp explore on SmartTest paths for non-functional gaps (ExploreChimp).
Related scenarios
- Form validation — required body fields
- CMS publishing — draft/preview/publish
- Localization — RTL toolbars
- GDPR privacy — user-generated content in exports
External references
Frequently asked questions
How do I type in contenteditable in Playwright?
Click the editor focus target, use page.keyboard.type for short text, or page.evaluate to call editor setContent in test env. Avoid fill() unless your app supports it on contenteditable.
Should I assert editor DOM or saved HTML?
Saved HTML or JSON document model via probe after Save. Editor DOM can look correct before autosave or sanitizer runs.
How do I test paste from Word safely?
Store representative bloated HTML fixtures in repo, dispatch ClipboardEvent paste, save, probe server output for retained structure and no script handlers.
How do I test image upload in editors?
Use setInputFiles on the editor file input or toolbar upload button, save, probe that stored content references CDN URL not giant base64 blob.
How do mentions and slash commands work in E2E?
Type trigger character, wait for popup, click option with getByRole. Probe stored mention node id—not just visible chip.
How does CMS publishing overlap with WYSIWYG tests?
WYSIWYG tests prove content integrity; CMS guide covers draft/preview/public separation—combine probe HTML with public URL cache checks.
Can ExploreChimp find editor UX issues?
Yes—especially RTL overflow and toolbar overlap on narrow viewports; convert sessions to SmartTests with paste fixtures.
Apply these patterns in your repo
Run `/testchimp init` to connect TestChimp to your repo, then `/testchimp test` on PRs to turn these patterns into maintained SmartTests. Use `/testchimp evolve` when you want to expand coverage as your app grows.