How `fill`, `press`, and `insertText` Are Implemented in ego-lite: A Deep Dive into Keyboard Automation

ego-lite implements keyboard automation through Chrome DevTools Protocol (CDP) key events, element resolution, and DOM manipulation scripts, with fill, press, and type each following distinct event-dispatching patterns.

The ego-lite browser automation framework provides Playwright-style keyboard interactions that translate high-level JavaScript calls into precise CDP commands. Understanding these implementations reveals how the library achieves reliable cross-platform input simulation without native OS dependencies.

Architecture Overview: From JavaScript Call to Browser Event

All keyboard operations in ego-lite follow a three-layer architecture:

  1. Selector resolution via waitForSelector
  2. Element handle acquisition via withHandle
  3. Event dispatch via browserCdp.send('Input.dispatchKeyEvent', …) or evaluated scripts

This design ensures that operations fail gracefully when elements aren't ready and that events are indistinguishable from genuine user input.

Core Keyboard Implementation in keyboard.ts

The primary implementation resides in /src/driver/keyboard.ts (citrolabs/ego-lite). This module exports a Keyboard class with methods for every interaction type.

Key Definitions and Modifier Tracking

The keyboard module maintains internal state for modifier keys:

// src/driver/keyboard.ts
private pressedModifiers: Set<string> = new Set();

private activeModifierBits(additional?: string): number {
  // Combines currently pressed modifiers with any new ones
  const mods = new Set(this.pressedModifiers);
  if (additional) mods.add(additional);
  return [...mods].reduce((bits, mod) => bits | MODIFIER_BITS[mod], 0);
}

Key definitions map semantic names to CDP-compatible event properties. The KEYS constant (line ~80-150) defines special keys like Enter, Tab, Backspace, and Escape with their key, code, and native virtual key codes.

The parseKeyCombo function splits compound shortcuts:

// Parses "Control+Shift+a" into modifiers and key
parseKeyCombo('Control+a') → { modifiers: ['Control'], key: 'a' }
parseKeyCombo('Shift+Tab') → { modifiers: ['Shift'], key: 'Tab' }

How press Works: Single Keystroke Dispatch

The press method is the atomic building block for all typing operations.

Implementation Flow

// src/driver/keyboard.ts
async press(keyCombo: string, options: { delay?: number } = {}): Promise<void> {
  await this.down(keyCombo);
  if (options.delay) await sleep(options.delay);
  await this.up(keyCombo);
}

down(keyCombo) performs:

  1. Calls parseKeyCombo to extract modifiers and target key
  2. Looks up the key in KEYS or treats it as a literal character
  3. Constructs a CDP Input.dispatchKeyEvent payload with:
    • type: 'keyDown'
    • code, key, windowsVirtualKeyCode, nativeVirtualKeyCode
    • modifiers: bitwise OR of active modifier flags
  4. Sends via this.cdp.send('Input.dispatchKeyEvent', payload)
  5. If the key is a modifier (Control, Alt, Shift, Meta), adds to pressedModifiers

up(keyCombo) mirrors this with type: 'keyUp' and removes modifiers from the tracking set.

Example usage:

// Press Escape to close a modal
await keyboard.press('Escape');

// Press Ctrl+S with 100ms hold time
await keyboard.press('Control+s', { delay: 100 });

How fill Works: Direct Value Injection with Event Simulation

The fill method takes a different approach than press—it uses JavaScript evaluation for value assignment rather than individual keystrokes, making it significantly faster for large text blocks.

Element Acquisition Pipeline

// src/driver/keyboard.ts
async fill(
  selector: string,
  value: string,
  options: { clearFirst?: boolean } = {}
): Promise<void> {
  // 1. Wait for element with smart polling
  const element = await waitForSelector(this.page, selector);
  
  // 2. Execute within element handle
  await withHandle(element, async (handle) => {
    // 3. Injection script runs in page context
    return this.cdp.evaluate(/* script */, [handle, value, options.clearFirst]);
  });
}

The Injection Script (Simplified)

The actual script evaluates in the browser and handles three element types:

// Executed via browser-runtime.ts CDP evaluation
function fillElement(element, value, clearFirst) {
  // Validate editability
  const isEditable = element.isContentEditable ||
    element.tagName === 'INPUT' ||
    element.tagName === 'TEXTAREA';
  if (!isEditable) throw new Error('Element is not editable');
  
  // Clear existing content
  if (clearFirst !== false) {
    element.focus();
    element.select?.();
    element.value = ''; // or textContent for contentEditable
    
    // Dispatch 'deleteContentBackward' input event for realism
    element.dispatchEvent(new InputEvent('input', {
      inputType: 'deleteContentBackward',
      bubbles: true
    }));
  }
  
  // Insert new value
  if (element.isContentEditable) {
    element.textContent = value;
  } else {
    element.value = value;
  }
  
  // Critical: dispatch insertText event for framework reactivity
  element.dispatchEvent(new InputEvent('input', {
    inputType: 'insertText',
    data: value,
    bubbles: true
  }));
  
  // Trigger change/blur for form validation
  element.dispatchEvent(new Event('change', { bubbles: true }));
}

Key design decisions in fill:

  • Uses CDP evaluation, not dispatchKeyEvent, for speed
  • Always fires input events with proper inputType so React/Vue/Angular bindings detect changes
  • Handles both <input>/<textarea> and contentEditable through branch logic
  • Optional clearFirst allows appending rather than replacing

How insertText / type Works: Character-by-Character Simulation

The type method (often aliased as insertText in documentation) provides the most realistic input simulation by generating individual keystroke events for each character.

Implementation

// src/driver/keyboard.ts
async type(
  selector: string,
  text: string,
  options: { delay?: number } = {}
): Promise<void> {
  // Resolve element first
  await waitForSelector(this.page, selector);
  
  // Iterate through each code point
  for (const char of text) {
    // Check for special key aliases (e.g., "Enter" in string)
    if (KEYS[char]) {
      await this.press(char, options);
    } else {
      // Dispatch as literal character
      await this.dispatchCharacter(char, options.delay);
    }
  }
}

private async dispatchCharacter(char: string, delay?: number): Promise<void> {
  // Direct CDP dispatch without modifier parsing
  await this.cdp.send('Input.dispatchKeyEvent', {
    type: 'keyDown',
    text: char,           // Unicode character
    unmodifiedText: char, // Without modifiers
    key: char,
    code: `Key${char.toUpperCase()}`,
    // Virtual key codes computed from char.codePointAt(0)
  });
  
  if (delay) await sleep(delay);
  
  await this.cdp.send('Input.dispatchKeyEvent', {
    type: 'keyUp',
    key: char
  });
}

Critical differences from press:

  • No modifier parsing—each character is treated as literal input
  • text field populated in CDP payload, which signals "this is a text input event"
  • Preserves natural typing rhythm through per-character delays

Example:

// Simulate realistic typing with human-like pacing
await keyboard.type('#tweet', 'Just shipped ego-lite v1.0! 🚀', {
  delay: 50 // 50ms between keystrokes
});

Supporting Infrastructure: Waits and Element Operations

waitForSelector (src/driver/waits.ts)

Before any interaction, ego-lite ensures element presence:

// Implements exponential backoff polling
async function waitForSelector(
  page: Page,
  selector: string,
  options: { timeout?: number; visible?: boolean } = {}
): Promise<ElementHandle> {
  const deadline = Date.now() + (options.timeout ?? 30000);
  
  while (Date.now() < deadline) {
    const element = await page.querySelector(selector);
    if (element && (!options.visible || await element.isVisible())) {
      return element;
    }
    await sleep(Math.min(100, deadline - Date.now()));
  }
  throw new TimeoutError(`Selector "${selector}" not found`);
}

withHandle (src/driver/element-ops.ts)

Provides safe CDP handle management:

async function withHandle<T>(
  element: ElementHandle,
  callback: (handle: Runtime.RemoteObjectId) => Promise<T>
): Promise<T> {
  const handle = await element.getBackendNodeId();
  try {
    return await callback(handle);
  } finally {
    // Always release to prevent memory leaks
    await element.dispose();
  }
}

CDP Transport Layer (browser-runtime.ts)

All CDP commands flow through BrowserRuntime in src/browser-runtime.ts:

// Low-level CDP sender used by keyboard operations
class BrowserRuntime {
  async send(method: string, params: object): Promise<any> {
    const id = ++this.messageId;
    this.ws.send(JSON.stringify({ id, method, params }));
    return this.waitForResponse(id);
  }
  
  async evaluate(script: string, args: any[]): Promise<any> {
    // Uses Runtime.callFunctionOn or Runtime.evaluate
    // depending on whether args contain remote object handles
  }
}

The Input.dispatchKeyEvent domain is documented in the Chrome DevTools Protocol specification and accepts parameters matching the W3C UI Events model.

Comparison: When to Use Each Method

Method Speed Realism Best For
fill Fastest (single round-trip) Low (no key events) Form population, bulk data entry
type Slow (per-character) Highest Testing input validation, keystroke listeners
press Fast Medium Shortcuts, navigation, single special keys

Common Patterns and Edge Cases

Content-Editable Elements

ego-lite detects contentEditable and switches injection strategy:

// From fill implementation
const isContentEditable = await this.cdp.evaluate(
  'el => el.isContentEditable',
  [handle]
);

Shadow DOM Piercing

When elements reside in shadow roots, waitForSelector uses Chrome's >>> piercing combinator internally, falling back to JavaScript traversal if CSS piercing is disabled.

Frame/Target Context

Keyboard operations respect the page context they're bound to. Multi-frame scenarios require obtaining a Keyboard instance from the specific frame's Page object.

Summary

  • press dispatches individual keyDown/keyUp CDP events through dispatchKeyEvent, honoring modifier state via pressedModifiers and activeModifierBits
  • fill evaluates JavaScript in page context to directly set values, firing synthetic input events with proper insertType for framework compatibility
  • type iterates characters, calling dispatchCharacter for each to generate realistic keystroke sequences with configurable delays
  • All methods rely on waitForSelector (src/driver/waits.ts) for element resolution and withHandle (src/driver/element-ops.ts) for safe CDP handle management
  • The CDP transport in src/browser-runtime.ts bridges high-level calls to Chrome's Input.dispatchKeyEvent domain

Frequently Asked Questions

Does ego-lite use real OS-level key events or browser-only events?

ego-lite uses browser-only CDP events. The Input.dispatchKeyEvent Chrome DevTools Protocol command injects events directly into the browser's input queue without involving the operating system. This provides cross-platform consistency but means the target window need not be focused—useful for headless automation.

Why does fill not trigger keydown/keyup listeners?

fill uses JavaScript evaluation to set values directly, bypassing the key event lifecycle entirely. If your application logic depends on keydown, keypress, or keyup handlers, use type instead. The trade-off is speed: fill completes in one CDP round-trip versus potentially hundreds for long strings with type.

How are emoji and non-BMP characters handled?

ego-lite processes strings as JavaScript iterables, correctly handling surrogate pairs. The dispatchCharacter method passes Unicode code points directly through CDP's text parameter, which Chrome converts to appropriate compositionstart/compositionend events for IME compatibility.

Can I combine modifiers with type for shifted characters?

No—type sends literal characters without modifier simulation. For shifted symbols (e.g., @ from Shift+2), either pass the literal character to type or use press('Shift+2') explicitly. The press method's parseKeyCombo correctly translates these into combined modifier and key events.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →