Editor Component Testing Guide

How to Test Monaco Editor with Playwright: Complete 2026 Guide

A scenario-by-scenario walkthrough of testing Monaco Editor with Playwright. Virtual scrolling, the hidden textarea, IntelliSense autocomplete, minimap rendering, diff editor comparison, language services, and action contributions that break traditional locator strategies.

15M+

Monaco Editor powers VS Code, which has over 15 million monthly active users. The same editor component is embedded in thousands of web applications from GitHub to Azure DevOps.

VS Code Team, 2025

0Native input elements in the editor
0Test scenarios covered
0%Virtual DOM recycled on scroll
0%Fewer lines with Assrt

Monaco Editor Rendering Pipeline

Test RunnerBrowser DOMMonaco CoreLanguage ServiceVirtual ViewportFocus editor containerDispatch keyboard eventRequest completionsReturn suggestionsRender visible lines onlyUpdate DOM with line elementsShow IntelliSense widgetAssert visible content

1. Why Testing Monaco Editor Is Harder Than It Looks

Monaco Editor is the code editor component extracted from Visual Studio Code. Unlike a standard <textarea> or contentEditable div, Monaco renders its content through a completely custom rendering pipeline. The visible text is painted into a series of <div> elements inside a .view-lines container, and keyboard input is captured by a hidden <textarea> that sits off-screen at a 1x1 pixel size. This architecture means that standard Playwright locators like getByRole('textbox') will find the hidden textarea, but filling it directly will not produce the expected result because Monaco intercepts and processes input events through its own event pipeline.

Virtual scrolling compounds the challenge. Monaco only renders the lines currently visible in the viewport, plus a small buffer above and below. If your document has 10,000 lines, only about 40 to 60 of them exist in the DOM at any given time. The rest are recycled as you scroll. This means you cannot query for text on line 500 without first scrolling to bring it into the viewport. Any assertion that expects all content to be present in the DOM simultaneously will fail.

The complexity extends to interactive overlays. IntelliSense autocomplete suggestions appear in a .suggest-widget overlay that is conditionally rendered and animated. The minimap renders a scaled-down version of the entire document in a separate <canvas>element that is not queryable by text content. The diff editor creates two side-by-side Monaco instances with additional inline and side-by-side diff decorations. Action contributions register custom commands and context menu entries through Monaco's internal registry, making them invisible to standard DOM inspection until triggered.

There are six structural reasons this component is hard to test reliably. First, the hidden textarea captures all input but does not reflect content in its value attribute. Second, virtual scrolling means most lines are not in the DOM. Third, IntelliSense widgets are ephemeral overlays with animation delays. Fourth, the minimap uses canvas rendering that cannot be queried with text selectors. Fifth, the diff editor doubles the complexity with two editor instances and diff-specific decorations. Sixth, language services run asynchronously in web workers, so diagnostics appear with unpredictable timing.

Monaco Editor Input Pipeline

🌐

Keyboard Event

User types a character

⚙️

Hidden Textarea

1x1px captures input

⚙️

Input Handler

Monaco processes event

⚙️

Model Update

Text buffer modified

🌐

View Update

Visible lines re-rendered

⚙️

Language Service

Async validation in worker

Decorations

Errors, highlights applied

2. Setting Up Your Test Environment

Before writing Monaco-specific tests, you need a reliable test harness that loads the editor and waits for it to fully initialize. Monaco's initialization is asynchronous: the component mounts, loads language definitions and themes from web workers, and only then is the editor ready to receive input. Interacting with the editor before initialization completes will produce silent failures where keystrokes are dropped.

Project Setup

Playwright Configuration

Monaco's web workers need time to initialize, especially on slower CI machines. Set a generous action timeout and configure your base URL to point at your development server or a dedicated test fixture page that mounts the editor.

playwright.config.ts

Waiting for Monaco Initialization

The most important helper you will write is one that waits for Monaco to finish initializing. The editor is ready when the .monaco-editor container has the .monaco-editor-background class applied and the .view-linescontainer has at least one child element. You can also use Monaco's JavaScript API through page.evaluate() for a more reliable signal.

tests/helpers/monaco.ts

Monaco Initialization Sequence

🌐

Mount Component

React renders container

⚙️

Load Workers

Language, editor workers

⚙️

Create Model

Text buffer initialized

🌐

Render View

.view-lines populated

Ready

Editor accepts input

1

Typing Text into the Editor

Moderate

3. Scenario: Typing Text into the Editor

The fundamental operation of any code editor test is typing text and verifying it appears correctly. With Monaco, you cannot use Playwright's .fill() method because the visible text is not stored in a standard input element. Instead, you must click the editor to focus it (which places the cursor in the hidden textarea), then use page.keyboard.type()to simulate real keystrokes that Monaco's input handler will process.

There is a subtlety here that catches many teams. Calling keyboard.type() sends individual keydown, keypress, and keyup events for each character. For fast typing, Monaco may batch these and the resulting text could differ from what you expect if auto-closing brackets or auto-indentation kicks in. Always verify the final editor content through the Monaco API, not by inspecting DOM text nodes.

tests/monaco/typing.spec.ts

What to Assert Beyond the UI

Typing Assertions Checklist

  • Editor model content matches typed text via Monaco API
  • Auto-closing brackets are applied correctly
  • Auto-indentation preserves expected nesting
  • Syntax highlighting tokens are applied to the correct spans
  • Undo/redo stack contains the expected entries

Typing text into Monaco

// Playwright: 25 lines
import { test, expect } from '@playwright/test';

test('type and verify content', async ({ page }) => {
  await page.goto('/editor');
  await page.locator('.monaco-editor').first().waitFor();
  await page.waitForFunction(() => {
    const e = (window as any).monaco?.editor?.getEditors?.();
    return e && e.length > 0 && e[0].getModel();
  });
  await page.locator('.monaco-editor').first().click();
  await page.keyboard.type('const x = 42;', { delay: 30 });
  const val = await page.evaluate(() =>
    (window as any).monaco.editor.getEditors()[0].getValue()
  );
  expect(val).toContain('const x = 42');
});
41% fewer lines
2

Triggering and Selecting IntelliSense Completions

Complex

4. Scenario: Triggering and Selecting IntelliSense Completions

Monaco's IntelliSense (autocomplete) system is one of the richest features inherited from VS Code. When you type a trigger character (like a dot after an object name) or invoke completions manually with Ctrl+Space, Monaco sends a request to the registered completion provider, which runs in a web worker for built-in languages. The suggestions appear in a .suggest-widget overlay that is absolutely positioned and animated.

Testing IntelliSense requires patience with timing. The widget does not appear instantly; there is a configurable delay (default 24ms for quick suggestions, longer for explicit triggers) and the suggestions themselves arrive asynchronously from the language service worker. You must wait for the .suggest-widget.visible selector rather than looking for the widget element alone, because Monaco keeps the widget in the DOM but toggles its visibility.

Each suggestion row is rendered inside the suggest widget with a .monaco-list-row class. The focused (highlighted) suggestion has an additional .focused class. You can navigate suggestions with arrow keys and accept the current selection with Tab or Enter. To select a specific suggestion, use arrow keys to navigate to it, verify the label text, then accept.

tests/monaco/intellisense.spec.ts

What to Assert Beyond the UI

IntelliSense Assertions

  • Suggest widget becomes visible within timeout
  • Expected completion item appears in the suggestion list
  • Accepting a suggestion inserts the full text into the model
  • Dismissing the widget with Escape hides it completely
  • Parameter hints appear after opening parenthesis

Try Assrt for free

Open-source AI testing framework. No signup required.

Get Started
3

Scrolling Through Large Files with Virtual Viewport

Complex

5. Scenario: Scrolling Through Large Files with Virtual Viewport

Monaco's virtual scrolling is the reason most naive test approaches fail. The editor maintains a text model in memory that can hold millions of lines, but the DOM only contains the lines currently visible in the viewport plus a small overscan buffer. When you scroll, Monaco removes the lines that scroll out of view and creates new DOM elements for the lines that scroll into view. This recycling mechanism means that a Playwright locator targeting text on a specific line will not find it unless that line is in the viewport.

To test content on a specific line, you have two strategies. The first is to use Monaco's revealLineInCenter() API through page.evaluate() to scroll the editor to the target line programmatically, then assert on the DOM content. The second is to use page.keyboard shortcuts like Ctrl+G (Go to Line) to navigate to a specific line number. Both approaches bring the target line into the viewport so your locators can find it.

tests/monaco/virtual-scroll.spec.ts

Scrolling to a specific line

// Playwright: 30 lines of scroll handling
test('scroll to line 500', async ({ page }) => {
  await page.goto('/editor');
  await page.locator('.monaco-editor').first().waitFor();
  await page.waitForFunction(() => {
    const e = (window as any).monaco?.editor?.getEditors?.();
    return e?.length > 0 && e[0].getModel();
  });
  const lines = Array.from({ length: 1000 }, (_, i) =>
    `// Line ${i + 1}: value_${i + 1}`
  );
  await page.evaluate((t) => {
    (window as any).monaco.editor.getEditors()[0].setValue(t);
  }, lines.join('\n'));
  await page.evaluate(() => {
    (window as any).monaco.editor.getEditors()[0].revealLineInCenter(500);
  });
  await expect(
    page.locator('.view-lines').getByText('value_500')
  ).toBeVisible();
});
55% fewer lines
4

Validating the Diff Editor

Complex

6. Scenario: Validating the Diff Editor

Monaco's diff editor renders two editor instances side-by-side (or inline, depending on configuration) with diff decorations highlighting insertions, deletions, and modifications. Testing the diff editor is challenging because you are dealing with two separate Monaco instances embedded in a single .monaco-diff-editor container. The original (left) side uses the class .editor.original and the modified (right) side uses .editor.modified.

Diff decorations are applied as CSS classes on line elements. Inserted lines on the modified side get a .insert-sign or .line-insert decoration. Deleted lines on the original side get a .delete-sign or .line-delete decoration. Inline diffs apply character-level decorations with .char-insert and .char-delete classes. You can count these decorations to verify the diff computed correctly.

tests/monaco/diff-editor.spec.ts

What to Assert Beyond the UI

DOM decorations are a visual indicator, but the ground truth lives in Monaco's diff computation. Always verify the getLineChanges() API result in addition to checking the CSS classes. The API returns an array of change objects with original and modified line ranges, which you can assert against directly.

5

Testing Custom Actions and Keybindings

Moderate

7. Scenario: Testing Custom Actions and Keybindings

Monaco allows applications to register custom actions through the editor.addAction() API. These actions can appear in the context menu, the command palette (F1), or respond to custom keybindings. Testing these actions requires triggering them through the same entry points a user would use, then verifying the side effects.

The command palette is the most reliable entry point for testing custom actions. Press F1 to open it, type the action label to filter, then press Enter to execute. For keybinding-triggered actions, simply press the key combination while the editor is focused. For context menu actions, right-click inside the editor to open the context menu, then locate and click your custom entry.

tests/monaco/actions.spec.ts

Command palette action

// Playwright: 22 lines
test('trigger uppercase via palette', async ({ page }) => {
  await page.goto('/editor');
  await page.locator('.monaco-editor').first().waitFor();
  await page.waitForFunction(() => {
    const e = (window as any).monaco?.editor?.getEditors?.();
    return e?.length > 0;
  });
  await page.locator('.monaco-editor').first().click();
  await page.evaluate(() => {
    (window as any).monaco.editor.getEditors()[0].setValue('hello');
  });
  await page.keyboard.press('F1');
  await page.locator('.quick-input-widget').waitFor();
  await page.keyboard.type('Transform to Uppercase');
  await page.keyboard.press('Enter');
  const val = await page.evaluate(() =>
    (window as any).monaco.editor.getEditors()[0].getValue()
  );
  expect(val).toBe('HELLO');
});
50% fewer lines
6

Language Service Diagnostics and Markers

Complex

8. Scenario: Language Service Diagnostics and Markers

Monaco's language services run in web workers and provide real-time diagnostics (errors, warnings, information hints) as the user types. For TypeScript and JavaScript, the language service is a full TypeScript compiler running in the browser. For other languages, diagnostics come from custom language providers registered through the Monaco API.

The critical testing challenge is timing. After you change the editor content, the language service needs time to process the change, compute diagnostics, and send them back to the main thread for rendering. This processing time varies from tens of milliseconds for simple errors to several seconds for complex TypeScript type checking. You must wait for the diagnostic markers to appear in the editor before asserting on them.

Diagnostic markers are rendered as squiggly underlines in the editor using the .squiggly-error, .squiggly-warning, and .squiggly-info CSS classes. You can also query the markers programmatically through monaco.editor.getModelMarkers() for more reliable assertions that do not depend on the DOM rendering timing.

tests/monaco/diagnostics.spec.ts

What to Assert Beyond the UI

Squiggly underlines are a visual representation, but they depend on the line being visible in the viewport. For diagnostics on lines that are scrolled out of view, always use the getModelMarkers() API. This returns all markers regardless of scroll position, with precise line numbers, column ranges, severity levels, and error messages.

9. Common Pitfalls That Break Monaco Test Suites

Using fill() Instead of keyboard.type()

The most common mistake is using Playwright's .fill() method on the Monaco editor. The fill() method targets the hidden textarea, but Monaco does not read its value attribute. Instead, it processes individual keyboard events. Calling fill()may set the textarea's value property without firing the input events Monaco expects, resulting in an editor that appears empty despite the textarea containing text. Always use keyboard.type() or the Monaco API directly via page.evaluate().

Asserting DOM Text for Off-Screen Lines

Virtual scrolling means that querying .view-lines for text that is not currently in the viewport will always fail. Teams write assertions like expect(editor).toContainText('line 500 content') and are surprised when it times out. If you need to verify content on a line that might be off-screen, use the Monaco API: editor.getModel().getLineContent(500). Reserve DOM-level text assertions for content you have explicitly scrolled into view.

Not Waiting for Language Service Initialization

Language services (TypeScript, JSON, CSS) load asynchronously in web workers. If your test sets content and immediately checks for diagnostics, it will find zero markers because the language service has not started processing yet. The delay can be significant: TypeScript type checking on a complex file can take two to five seconds on CI hardware. Always use waitForFunction() polling on getModelMarkers() rather than fixed timeouts.

Relying on Exact CSS Class Names Across Versions

Monaco's internal CSS class names (like .suggest-widget, .view-lines, .squiggly-error) are not part of the public API and can change between versions. While they have been stable for many releases, a major Monaco update could rename or restructure them. Where possible, prefer the JavaScript API through page.evaluate() for assertions. Use DOM class selectors only for visual verification that the API approach cannot cover, such as confirming that an error squiggly is actually rendered and visible to the user.

Forgetting to Handle Monaco's Auto-Closing Brackets

By default, Monaco auto-closes brackets, parentheses, and quotes. If your test types { and expects the content to be exactly {, it will find {}instead. Similarly, typing a quote character produces a matching closing quote. Account for these auto-completions in your assertions, or disable them in your test fixture's editor options by setting autoClosingBrackets: 'never' and autoClosingQuotes: 'never'.

Monaco Testing Anti-Patterns

  • Using .fill() on the hidden textarea instead of keyboard.type()
  • Asserting DOM text for lines not in the virtual viewport
  • Checking diagnostics immediately without waiting for the language service
  • Hardcoding internal CSS class names without fallback selectors
  • Ignoring auto-closing brackets in content assertions
  • Using fixed timeouts instead of waitForFunction polling
Monaco Editor Test Suite Run

10. Writing These Scenarios in Plain English with Assrt

Every scenario above requires deep knowledge of Monaco's internal architecture: the hidden textarea, virtual scrolling, widget class names, and web worker timing. That knowledge is encoded in brittle selectors and timing logic scattered across your test files. When Monaco upgrades from version 0.48 to 0.50 and renames .suggest-widget to .completion-widget, every IntelliSense test breaks. Assrt lets you describe the user intent and handles the selector resolution at runtime.

The IntelliSense scenario from Section 4 illustrates the gap best. In raw Playwright, you need to know the exact widget class name, the visibility toggle mechanism, the list row class, and the focused state class. In Assrt, you describe what the user does and what they expect to see. The framework resolves the selectors, waits for async widgets, and retries on transient failures.

scenarios/monaco-editor-suite.assrt

Assrt compiles each scenario block into the same Playwright TypeScript you saw in the preceding sections, committed to your repo as real tests you can read, run, and modify. When Monaco renames an internal class or restructures the IntelliSense widget DOM, Assrt detects the failure, analyzes the new DOM structure, and opens a pull request with the updated locators. Your scenario files remain unchanged.

Start with the typing scenario. Once it is green in your CI, add the IntelliSense test, then virtual scrolling, then diagnostics, then the diff editor, then custom actions. In a single afternoon you can have comprehensive Monaco Editor test coverage that accounts for virtual rendering, async language services, and all the widget overlays that break naive selector strategies.

Related Guides

Ready to automate your testing?

Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.

$npm install @assrt/sdk