Unit tests are good at checking the functions that turn HTML into a document, and they cannot tell you whether bold works when a user presses Ctrl+B in the middle of a sentence. For that you need a real browser that types, selects and pastes. Playwright is a good fit: it drives Chromium, Firefox and WebKit, and it waits for the page to settle before it checks anything, which removes most of the timing problems that make browser tests flaky.
Set up the test runner
Install Playwright with its test runner and point it at the page that holds the editor. A dedicated test page is better than the production site, because it can mount the editor with known settings and no other scripts:
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
use: { baseURL: "http://localhost:3000" },
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
],
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: true,
},
});Running the same tests on all three engines is the point. Selection and input behave differently in each, and a bug that appears only in WebKit is the kind users report as “it doesn’t work on my iPad”.
Type and press keys
A first test should use the same path as a user: click into the editor, type, press a shortcut, and check what the document now contains. Target the editor by its role. Every editable element should have one, which also helps screen readers, so the test doubles as an accessibility check:
import { test, expect } from "@playwright/test";
test("Mod+B makes the next words bold", async ({ page }) => {
await page.goto("/editor-test");
const editor = page.getByRole("textbox");
await editor.click();
await page.keyboard.type("Hello ");
await page.keyboard.press("ControlOrMeta+B");
await page.keyboard.type("world");
await expect(editor.locator("strong, b").last()).toHaveText("world");
});ControlOrMeta sends Ctrl on Windows and Linux and Cmd on macOS, which matches what the keyboard article recommends. The check uses strong, b because the editor may use either element for bold. Pick the one your editor emits and assert it exactly, so a change in markup breaks the test on purpose.
Paste HTML without the clipboard
The real clipboard is not reliable in automated runs. It differs between operating systems, and some browsers block it without a user gesture. Dispatch a paste event with the HTML you want to test instead. This exercises the same paste handler, which is where the filtering from Word and Google Docs happens:
test("pasting Word HTML removes the Office markup", async ({ page }) => {
await page.goto("/editor-test");
const editor = page.getByRole("textbox");
const wordHtml = '<p class="MsoNormal" style="mso-margin-top-alt:auto">Quarterly <b>report</b></p>';
await editor.evaluate((el, html) => {
const data = new DataTransfer();
data.setData("text/html", html);
el.dispatchEvent(new ClipboardEvent("paste", { clipboardData: data, bubbles: true, cancelable: true }));
}, wordHtml);
const saved = await editor.innerHTML();
expect(saved).not.toContain("mso-");
expect(saved).toContain("report");
});Keep a folder of real paste samples from Word, Google Docs, a web page and a PDF. Each one becomes a test with a fixed expected output. When a sanitiser change alters one of them, the diff shows exactly what changed.
Check the saved HTML, not the screen
Visible text is a weak assertion. Two different documents can look the same, and the difference is what gets saved. Read the content the way your application will store it, and assert on that. Where the editor autosaves, wait for the save to finish with expect.poll rather than a fixed delay:
await page.keyboard.type("Draft line");
await expect
.poll(async () => page.evaluate(() => (window as any).__lastSavedHtml), { timeout: 5000 })
.toContain("Draft line");The window.__lastSavedHtml hook is something your test page exposes. It is not a production feature. The alternative, checking a network request, works too, and it tests the save path end to end.
What the browser test does not cover
Automated input is not the same as a person typing. Input method editors (the tools used to type Japanese, Chinese and Korean) cannot be driven reliably by these scripts, so composition bugs still need manual checks. Real clipboard behaviour across operating systems needs manual testing too. Screen reader output is another manual step, because automated tests can check that a label exists but not that it sounds right.
Use the browser tests for the things they are good at: keyboard shortcuts, paste filters, undo and redo, formatting commands and saved output. Keep a short manual checklist for the rest, and run it before each release.
Keep the suite stable
- Wait on visible results with
expect. AvoidwaitForTimeout, which makes tests slow and still flaky. - Give each test a fresh page and a known starting document. Shared state between tests causes failures that depend on order.
- Run the suite on all three engines in continuous integration. Keep the local run to Chromium for speed.
- Store a trace on failure with
trace: "on-first-retry". It records the DOM and the actions, which makes a failure in one engine easy to diagnose.
RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.