How to Test ego-lite Components: A Complete Guide to Node.js Native Testing

Testing ego-lite components relies on Node.js 22+'s built-in test runner, colocated *.test.mjs files, and a FakeEgo mock injected via globalThis.ego overrides to assert CDP sequences without a real browser.

The ego-browser package in the citrolabs/ego-lite repository ships with a comprehensive test suite covering task-space management, element resolution, and Chrome DevTools Protocol (CDP) evaluation. All tests use native Node.js APIs, eliminating external test framework dependencies while maintaining deterministic execution through strategic mocking of low-level browser bindings.

Test Architecture and Runtime Mocking

The testing strategy centers on colocation and deterministic mocking. Test files live alongside source code under package/ego-browser/src/ using the *.test.mjs suffix, discovered automatically by node --test.

The FakeEgo Pattern

Tests inject a lightweight FakeEgo double by temporarily overriding globalThis.ego. This allows helper functions to call browser bindings without launching Chrome, enabling fast, isolated unit tests.

In src/helpers.test.mjs, the withEgo utility swaps the global binding and automatically restores it after each test:

import test from "node:test";
import assert from "node:assert/strict";
import { withEgo } from "./helpers.test.mjs";

test("newTaskSpace creates and selects an agent task space", async () => {
  const calls = [];
  await withEgo(
    {
      async createTaskSpace(name) {
        calls.push(["createTaskSpace", name]);
        return { taskId: name, id: 7, name, ownership: "agent" };
      },
      useTaskSpace(taskId) {
        calls.push(["useTaskSpace", taskId]);
        return taskId;
      },
    },
    async () => {
      const result = await newTaskSpace("checkout-flow");
      assert.deepEqual(result, {
        taskId: "checkout-flow",
        id: 7,
        name: "checkout-flow",
        ownership: "agent",
      });
    }
  );
  assert.deepEqual(calls, [
    ["createTaskSpace", "checkout-flow"],
    ["useTaskSpace", 7],
  ]);
});

Source: src/helpers.test.mjs

Low-Level Override Mechanism

For CDP-level control, __testing.setOverrides replaces specific runtime behaviors such as cdpOverride and sleep functions. This isolates asynchronous flows and removes timing dependencies.

import test from "node:test";
import assert from "node:assert/strict";
import { setOverrides } from "../dist/src/state.js";
import * as helpers from "../dist/src/helpers.js";

test("page.url reads the current URL asynchronously", async () => {
  const restore = setOverrides({
    cdpOverride: async (method) => {
      assert.equal(method, "Runtime.evaluate");
      return { result: { value: JSON.stringify({ url: "https://example.com" }) } };
    },
  });

  try {
    const urlPromise = helpers.helperContext().page.url();
    assert.equal(typeof urlPromise.then, "function");
    assert.equal(await urlPromise, "https://example.com");
  } finally {
    restore();
  }
});

Source: src/helpers.test.mjs

Running the Test Suite

Execute the full suite from the repository root using npm, which handles the build step automatically.

  1. Install dependencies:

    npm install
  2. Build and test:

    npm test

    This command runs npm run build (using esbuild to produce dist/) followed by node --test src/**/*.test.mjs, printing progress and any failures.

Testing Task-Space Lifecycle Management

The task-space API—critical for agent orchestration—requires verifying the correct sequence of binding calls. Tests in src/helpers.test.mjs cover the full lifecycle: newTaskSpace, useOrCreateTaskSpace, switchTaskSpace, completeTaskSpace, handOffTaskSpace, and waitForAgentControl.

Each test checks:

  • Correct binding sequences (e.g., createTaskSpace followed by useTaskSpace)
  • Error handling for binding-error objects like { error: "..."}
  • State transitions between agent and user ownership

The src/taskspace-e2e.test.mjs file provides end-to-end coverage for complex orchestration scenarios.

Testing Element Resolution and Error Classification

The src/element-resolver.test.mjs module validates how locators (e.g., css:, role:, xpath=) are parsed and whether resolution failures are classified as transient (retryable) or permanent (fatal).

import test from "node:test";
import assert from "node:assert/strict";
import { resolveElement } from "../dist/src/element-resolver.js";

test("resolveElement returns transient error for missing element", async () => {
  const result = await resolveElement("css:#does-not-exist");
  assert.equal(result.kind, "transient");
});

This ensures that agent loops correctly retry on timing-related DOM misses while failing fast on invalid selector syntax.

Testing CDP Evaluation and Browser Drivers

The src/cdp-eval.test.mjs file confirms that cdp() and js() wrappers correctly marshal expressions, handle return values, and surface protocol errors. Individual driver modules—nav, pointer, keyboard, and downloads—each maintain dedicated *.test.mjs files in src/driver/ asserting that high-level helpers emit the correct CDP messages and behave like Playwright-compatible facades.

For example, src/driver/pointer.test.mjs validates click coordinates, drag sequences, and scroll actions against mocked CDP responses.

Validating the Learning Subsystem

Site-specific "learnings" (automation scripts) are validated against JSON schemas in src/learning/index.test.mjs. Tests ensure manifests contain valid semantic versions, required tool definitions, and safe execution parameters.

import test from "node:test";
import assert from "node:assert/strict";
import { validateLearning } from "../dist/src/learning/index.js";

test("validateLearning rejects malformed manifest", async () => {
  const badManifest = { name: "bad", version: "not-a-semver" };
  await assert.rejects(() => validateLearning(badManifest), /invalid version/);
});

Source: src/learning/index.test.mjs

Writing New Tests for ego-lite Components

Follow this pattern when adding coverage for new features:

  1. Colocate the test next to the source file with the .test.mjs suffix (e.g., src/driver/my-feature.test.mjs).
  2. Import from the compiled output (../dist/src/...) to mirror the runtime environment.
  3. Mock the Ego runtime using withEgo for high-level interactions or __testing.setOverrides for CDP-level control.
  4. Assert sequences with assert.deepEqual for call arrays or assert.rejects for error paths.
  5. Cover edge cases including binding errors and malformed inputs.

Summary

  • ego-lite uses Node.js 22+'s native test runner (node --test) with *.test.mjs files colocated in src/.
  • FakeEgo mocking via globalThis.ego overrides enables fast, deterministic unit tests without browser dependencies.
  • __testing.setOverrides provides surgical control over CDP calls, sleep functions, and side effects.
  • Task-space tests verify lifecycle sequences in helpers.test.mjs and taskspace-e2e.test.mjs.
  • Element resolution tests classify locator failures as transient or permanent in element-resolver.test.mjs.
  • Driver tests in src/driver/*.test.mjs ensure CDP message correctness for user actions.

Frequently Asked Questions

What Node.js version is required to test ego-lite components?

Node.js 22 or higher is required. The test suite depends on the built-in node:test runner and node:assert/strict modules, which became stable and fully featured in recent LTS releases.

How do I mock Chrome DevTools Protocol calls when testing ego-lite?

Use __testing.setOverrides from src/state.js. Pass a cdpOverride function to intercept protocol methods like Runtime.evaluate and return fake responses, making asynchronous flows deterministic and eliminating network dependencies.

Where should I place new test files in the ego-browser package?

Always colocate tests with the source code under package/ego-browser/src/. Name the file with the .test.mjs suffix (e.g., feature.test.mjs next to feature.js). The npm test command automatically discovers these files using the glob src/**/*.test.mjs.

How does ego-lite classify element resolution errors in tests?

Errors are classified as either transient or permanent. The resolveElement function in src/element-resolver.js returns objects with a kind property. Tests in element-resolver.test.mjs verify that missing DOM elements yield { kind: "transient" } (triggering retry logic) while invalid syntax yields { kind: "permanent" } (failing immediately).

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 →