Playwright architecture, selector reliability, and advanced interaction patterns.

Automating Rich Text Editors

A rich text editor is the one form control where fill() is usually the wrong tool. A <textarea> has a value; an editor built on ProseMirror, Quill, Lexical or CKEditor has a document model that it projects into a contenteditable element, and the two can disagree. Tests written the same way as the rest of your Form Automation & Input Handling suite pass while the saved document is missing its headings, its mention nodes, or its content entirely.

This page covers the failure precisely: which node to locate, when to use pressSequentially() instead of fill(), how to clear content the editor seeded, how to apply formatting through keyboard shortcuts, how to reach editors that live inside an iframe, and what to assert so a green test means the API received the right document. Everything here sits under Advanced Interactions & Test Assertions and assumes Playwright 1.38 or newer, which is where the ControlOrMeta modifier landed.

Anatomy of a rich text editor under test Keyboard input reaches a contenteditable surface, which feeds a document model that re-renders the surface and serializes into the hidden form value. Toolbar commands bold, link, list Playwright input contenteditable surface what Playwright types into and what the user sees editor document model node tree / delta / JSON the source of truth transaction re-render command serialize hidden form value what the server stores
The visible contenteditable node is a projection; the document model beside it is what the form eventually posts.

Root cause: the node you type into is not the value you submit

locator.fill() focuses the target, selects everything inside it, and inserts the whole string in a single beforeinput/input pair. It never dispatches keydown, keypress or keyup. Every editor feature bound to key events — markdown input rules, slash-command palettes, mention suggestions, live character counters, undo checkpoints — is therefore skipped, and editors that rebuild state from key handling rather than DOM mutation record nothing at all. The characters appear on screen, so a text assertion passes, while the serialized document the API receives is empty or plain.

How badly this bites depends on the editor's architecture. ProseMirror-based editors run a DOM observer and will usually reconstruct a text node inserted by fill(), which is why the plain characters survive; the plugins layered on top of it will not, because they subscribe to handleKeyDown. Editors that treat the DOM as strictly derived state — a controlled React value backed by an immutable model — discard the mutation on the next render, leaving nothing behind. Code editors such as Monaco and CodeMirror 6 take a third route: the visible text is painted markup with no editable node at all, and all real input arrives through a hidden textarea that Playwright refuses to fill because it is positioned outside the viewport.

Where fill and pressSequentially enter the input pipeline A five-stage pipeline from keydown to form sync, showing that pressSequentially starts at keydown while fill enters at beforeinput. pressSequentially() enters here fill() enters here no key events keydown keyup beforeinput input model update transaction DOM patch re-render form sync serialize assert here Plugins bound to keydown stall at stage one
Anything a plugin does on keydown is invisible to fill(), which joins the pipeline one stage later.

Minimal reproducible example

A TipTap editor renders a mention suggestion list when the user types @. The plugin listens for keydown, so the test below produces on-screen text and a silently wrong document. This is the shape of the bug that survives code review: nothing about the test looks unusual, the locator is scoped, the assertion is a retrying one, and it still certifies broken behaviour. Read it as a template for the class of defect rather than for the mention feature specifically — any editor extension keyed on Enter, /, #, Tab or Backspace fails identically.

import { test, expect } from '@playwright/test';

test('mention autocomplete never opens after fill()', async ({ page }) => {
  await page.goto('/posts/new');

  // The wrapper div renders immediately; contenteditable appears only on mount.
  const editor = page.locator('[data-testid="post-body"] .ProseMirror');
  await expect(editor).toHaveAttribute('contenteditable', 'true');

  // fill() focuses the node, selects everything, and inserts the whole string
  // in one beforeinput/input pair — no keydown, no keypress, no keyup.
  await editor.fill('Ship it @al');

  // The characters are on screen, so the naive assertion is green.
  await expect(editor).toContainText('Ship it @al');

  // But the suggestion plugin waits for a keydown that never happened, so no
  // popover opened and no mention node was ever inserted into the model.
  await expect(page.getByRole('listbox', { name: 'Mentions' })).toBeHidden();

  // The document that reaches the API contains the literal text "@al" instead
  // of a mention node — a defect this test cheerfully reports as a pass.
});

Step-by-step fix

  1. Wait for the editor to finish mounting. Editor bundles are almost always code-split, so the wrapper element exists long before the editable node does. Assert on the attribute that only appears after initialisation — await expect(editor).toHaveAttribute('contenteditable', 'true') — rather than on visibility, which the empty wrapper satisfies. The same reasoning applies to any deferred widget covered in Waiting Strategies for Dynamic React Components.
  2. Locate the editable node, not its wrapper. Target .ProseMirror, .ql-editor, .ck-content or body#tinymce — the element that actually carries contenteditable. Scope it under a stable ancestor such as [data-testid="post-body"], because pages with two editors will otherwise raise a strict mode violation, the resolution for which is described in Resolving Strict Mode Violations.
  3. Click the surface before typing. locator.click() places a real caret inside the document; a bare focus() can leave the selection collapsed outside any text node, and the editor then drops the first character or throws on its own selection mapping. Follow with await expect(editor).toBeFocused() so the next action cannot race the editor's focus handler.
  4. Type with pressSequentially() whenever behaviour depends on keys. It emits keydown, keypress, input and keyup per character, which is what input rules, slash menus and mention plugins listen for. Keep fill() only for editors where you are seeding plain body text and no key-bound behaviour is under test — it is roughly an order of magnitude faster.
  5. Clear seeded content with select-all plus Delete. Most editors bootstrap with an empty paragraph node, and fill('') can leave that node in an inconsistent state. page.keyboard.press('ControlOrMeta+A') followed by page.keyboard.press('Delete') routes the clear through the editor's own delete command, so the model and the DOM stay in agreement on Linux CI and macOS alike.
  6. Apply formatting through the shortcuts the editor registers. page.keyboard.press('ControlOrMeta+B') after a selection exercises the same command path as the toolbar and avoids depending on toolbar markup. When you do drive the toolbar — a heading <select>, a colour menu — treat it as an ordinary control and use the techniques in Handling Dropdowns, Checkboxes, and Radio Buttons.
  7. Paste rich content by synthesizing a DataTransfer. Clipboard permissions are unreliable across engines, so build the payload in the page: construct a DataTransfer, call setData('text/html', ...), and dispatch a ClipboardEvent('paste', { clipboardData }) on the editable node inside page.evaluate(). That exercises the editor's HTML sanitiser, which is where most paste defects live.
  8. Assert on the serialized document, not only the rendered DOM. Read the editor's own output — editor.getJSON(), quill.getContents(), tinymce.activeEditor.getContent() — or the hidden field it syncs, and pair it with a user-visible check. Retrying Web-First Assertions handle the debounce most editors put between a keystroke and the form sync.
Editable surface and entry method by editor family A matrix listing five editor families with the element that carries contenteditable and the Playwright text entry method each one needs. Editor family Editable surface Text entry TipTap / ProseMirror div.ProseMirror pressSequentially() Quill div.ql-editor fill() or keystrokes CKEditor 5 div.ck-content pressSequentially() TinyMCE iframe body#tinymce frameLocator first Monaco / CodeMirror hidden textarea click then keyboard Match the row to your bundle before writing a selector
Each editor family exposes a different editable node, and the entry method follows from how that node consumes events.

Applying all eight steps turns the failing example into a test that exercises the real code path:

import { test, expect } from '@playwright/test';

test('creates a mention through real keystrokes', async ({ page }) => {
  await page.goto('/posts/new');

  const editor = page.locator('[data-testid="post-body"] .ProseMirror');
  await expect(editor).toHaveAttribute('contenteditable', 'true');

  // Click places a caret inside the document; focus() alone can leave the
  // selection collapsed outside it and lose the first character.
  await editor.click();
  await expect(editor).toBeFocused();

  // Route the clear through the editor's own delete command.
  await page.keyboard.press('ControlOrMeta+A');
  await page.keyboard.press('Delete');

  // One keydown/keypress/input/keyup cycle per character, so the suggestion
  // plugin sees the '@' exactly as it would from a human.
  await editor.pressSequentially('Ship it @al', { delay: 20 });

  const mentions = page.getByRole('listbox', { name: 'Mentions' });
  await expect(mentions).toBeVisible();
  await mentions.getByRole('option', { name: 'alice' }).click();

  // Assert the rendered node and the value the form will post.
  await expect(editor.locator('span[data-mention]')).toHaveText('@alice');
  await expect(page.locator('#post-body-value')).toHaveValue(/data-mention/);
});

Three things changed and each one matters. The click established a caret, so the editor's selection is inside the document before any key arrives. The select-all-and-delete pair removed the bootstrap paragraph through a command the editor understands, which keeps its history stack coherent for later undo checks. And pressSequentially() with a small delay gives asynchronous suggestion queries a realistic gap between characters — 20 ms is enough for a debounced lookup to settle without turning a fifty-character paragraph into a visible pause. The closing pair of assertions covers both halves of the diagram above: the rendered mention node and the value the form will actually post.

Troubleshooting variants

locator.fill: Element is not an <input>, <textarea>, <select> or [contenteditable] element

Playwright resolved your locator to the wrapper rather than the editable node. TipTap renders div.tiptap > div.ProseMirror, Quill renders div.quill > div.ql-editor, and only the inner element carries contenteditable="true". Fix the locator instead of forcing the action — force: true will not help, because the check is on element type, not actionability. If the wrapper is genuinely the only node in the DOM, the bundle has not mounted yet and you are looking at the server-rendered placeholder; add the attribute assertion from step 1. Monaco is the exception that stays broken: its textarea.inputarea is deliberately positioned off-screen and Playwright will report element is not visible rather than filling it, so click the .monaco-editor surface and use page.keyboard instead.

Text renders but the model or the hidden field stays empty

The editor is fed by a controlled React or Vue binding that only reacts to events it recognises, so the DOM mutation Playwright made was reverted or ignored. Confirm it by reading the model directly in page.evaluate() immediately after typing: if the DOM shows your text and getJSON() does not, the write never reached the model. Switch that interaction to pressSequentially(), and if the editor still ignores it, the field is likely composition-driven — call locator.press() for individual keys so each one carries its own key and code. A second, quieter cause is debounce: many editors sync to the hidden input 300–500 ms after the last keystroke, so replace any immediate inputValue() read with await expect(hidden).toHaveValue(...), which retries. Debounce races of this shape are catalogued in Detecting and Fixing Flaky Playwright Tests.

The editable area lives inside an iframe

TinyMCE and CKEditor 4 render the document into their own iframe, so a page-level locator resolves to nothing and you get a plain Timeout 30000ms exceeded with waiting for locator('body#tinymce'). Enter the frame first with frameLocator(), then locate the body inside it; the deeper nesting cases are handled in Automating Elements Inside Nested Iframes. Note that TinyMCE writes back to the original <textarea> only on save, not on every keystroke, so assert against the editor API mid-test and against the textarea only after submitting — the same ordering discipline that Automating Multi-Step Forms with Playwright applies to wizard steps. Editors distributed as web components hide their surface behind a shadow root instead; locator chaining pierces those automatically, as described in Shadow DOM Traversal.

import { test, expect } from '@playwright/test';

test('types into a TinyMCE editor hosted in an iframe', async ({ page }) => {
  await page.goto('/admin/pages/1/edit');

  // The editable document is a separate frame; its body is contenteditable.
  const body = page.frameLocator('iframe.tox-edit-area__iframe').locator('body#tinymce');

  await body.click();
  await body.pressSequentially('Quarterly summary');
  await expect(body).toContainText('Quarterly summary');

  // TinyMCE only mirrors into the hidden textarea on save, so read the model.
  const html = await page.evaluate(() =>
    (window as unknown as { tinymce: { activeEditor: { getContent(): string } } })
      .tinymce.activeEditor.getContent());
  expect(html).toContain('Quarterly summary');
});

Verification

Check the fix at three levels. First, assert what the user sees: await expect(editor.locator('strong')).toHaveText('Hello') proves the mark was applied to the right range, not merely that bold text exists somewhere. Second, assert what the server will store, by reading the editor's serialized output through page.evaluate() or by matching the hidden field with toHaveValue(); that is the only check that catches a model which drifted from the DOM. Third, when a run fails on CI and not locally, open the recording in the Playwright Trace Viewer — the per-character actions of pressSequentially() appear as discrete steps with DOM snapshots between them, so you can see the exact keystroke at which a suggestion popover failed to open.

import { test, expect } from '@playwright/test';

test('serialized document matches the rendered editor', async ({ page }) => {
  await page.goto('/posts/new');

  const editor = page.locator('.ProseMirror');
  await editor.click();
  await editor.pressSequentially('Hello');
  await page.keyboard.press('ControlOrMeta+A');
  await page.keyboard.press('ControlOrMeta+B');

  // Read the editor's own JSON so the check survives a theme change in how a
  // strong mark happens to be rendered.
  const doc = await page.evaluate(() =>
    (window as unknown as { editor: { getJSON(): unknown } }).editor.getJSON());
  expect(JSON.stringify(doc)).toContain('"type":"bold"');

  // Pair the model check with a user-visible one so neither can pass alone.
  await expect(editor.locator('strong')).toHaveText('Hello');
});

Be deliberate about which assertion carries the weight. toHaveText() normalizes whitespace and flattens the element's subtree, so it happily passes on a document whose paragraph structure collapsed; when structure is the thing under test, assert on counts and shapes instead — await expect(editor.locator('p')).toHaveCount(3) — or compare the serialized JSON. Equally, avoid snapshotting the editor's innerHTML wholesale: editors emit internal attributes and placeholder classes that churn between minor versions and will turn a passing suite red on an unrelated dependency bump.

Run the suite with --repeat-each=5 once before merging. Editor tests fail intermittently more often than other form tests because focus, debounce and asynchronous plugin loading all compete, and five clean consecutive runs is a cheap signal that the synchronization is anchored on state rather than luck.

Frequently Asked Questions

Is fill() ever acceptable on a rich text editor?

Yes, when you are seeding body text as a precondition and no key-driven behaviour is part of the assertion — filling a description field before testing the publish button, for example. It is markedly faster than per-character typing, and for editors that rebuild their model from DOM mutations the resulting document is correct. Reach for pressSequentially() the moment the feature under test reacts to individual keys.

Why does the first character of my typed string go missing?

The editor moved the caret after Playwright started typing. Editors place an initial selection during mount, and if your click landed before that ran, the editor's own selection reset swallows the first keystroke. Click the editable node, wait for toBeFocused(), and where the editor renders a placeholder, wait for that placeholder to disappear before typing.

How do I test undo and redo?

Send the platform shortcut with page.keyboard.press('ControlOrMeta+Z') and its redo counterpart, then assert on the document rather than on toolbar button state. Undo history is grouped by input transactions, so text entered with a single fill() collapses into one undo step while the same text entered per character produces the grouping a user would experience — another reason to match the entry method to what you are asserting.

Can I set editor content directly through the editor's JavaScript API?

You can, and for arrange-phase setup it is the fastest option: call editor.commands.setContent() or the equivalent inside page.evaluate(). Keep it out of the act phase, though. Setting content through the API bypasses the sanitiser, the input rules and the change events, so a test that both arranges and asserts that way validates nothing about the code a user actually exercises.

Back to overview