E2E Testing & Hydration Readiness

Because Coralite uses a Server-Side Rendering (SSR) approach with deferred client-side component definitions and scripts, there is a small window of time between when the browser initially paints the HTML and when the dynamic web components actually become interactive (Hydration).

End-to-end testing frameworks like Playwright or Cypress interact with the DOM incredibly fast. If you try to click a button or assert the state of a dynamic component immediately after navigating to a page, your tests will likely fail due to a race condition: the framework hasn't finished mounting or initializing the component's internal state.

The Testing Mode #

Coralite provides a first-class testing mode designed to eliminate flakiness and provide deep framework visibility to test runners like Playwright or Cypress. You can enable it in your coralite.config.js:

javascript
Code copied!
  export default defineConfig({
    mode: 'testing',
    // ...
  });

E2E Targeting Selectors & data-testid Injection #

To support reliable, non-flaky E2E tests, Coralite auto-injects and prefixes test selectors in testing mode.

Explicit Test IDs & Auto-Prefixing #

When authoring HTML templates, you MUST write static, plain-text data-testid attributes:

html
Code copied!
  <input>

CRITICAL RULE: Do NOT attempt to dynamically bind refs or instance IDs to test IDs using curly braces (e.g. ❌ <input data-testid="{{ ref_username }}">).

The Coralite build compiler automatically parses static data-testid strings and prepends the correct page__ or instance-id__ prefix during the build process to avoid collisions.

Auto-Generated Test IDs #

Interactive elements (like button, a, input, form, select, textarea, or elements with a tabindex or interactive role attribute) that do not have an explicit data-testid are automatically assigned one following the pattern [prefix][tagName]-[zero-based-index] (e.g. data-testid="my-component-0__button-0").

For example, a component named user-profile with two buttons will automatically render:

html
Code copied!
  <div data-cid="user-profile-0">
    <button>Cancel</button>
    <button>Save Changes</button>
  </div>

Targeting Strategies (Strict vs. Flexible Assertions) #

Because Coralite uses deterministic AST rendering, component instance IDs (like my-component-0) are highly predictable. Choose your selector strategy based on the test's goal:

The Velocity Engine #

CSS transitions and animations are the leading cause of flaky E2E tests. Animations, transitions, and smooth scrolling are globally disabled in testing mode to eliminate race conditions and speed up tests. This ensures that the DOM updates instantaneously and test runners can interact with elements immediately.

Glass Box Assertions & Telemetry #

Standard DOM assertions can sometimes be unreliable. Coralite's testing mode exposes the internal framework state directly to the global window for glass box assertions:

Component & Plugin Mocking #

To write reliable end-to-end tests without hitting real database connections, web APIs, or downstream microservices, Coralite includes a built-in mocking system. When running in mode: 'testing', you can override the server() blocks of any dynamic components or mock the runtime contexts of plugins by registering mocks in your coralite.config.js file.

The mocking system accepts two keys under the testing.mocks configuration object: components and plugins.

Configuring Mocks #

Define your mock implementations under the testing.mocks.components and testing.mocks.plugins objects within your configuration:

javascript
Code copied!
  import { defineConfig } from 'coralite-scripts';
  
  export default defineConfig({
    mode: 'testing',
    testing: {
      mocks: {
        // Override component server-side data fetching
        components: {
          'user-profile': {
            server: async () => ({ name: 'Mock User', role: 'admin' })
          }
        },
        // Mock plugin runtime contexts on the server and client
        plugins: {
          'analytics-plugin': {
            server: {
              context: { db: { query: () => [] } }
            },
            client: {
              context: { track: () => {} }
            }
          }
        }
      }
    }
  });

Component Mocking (testing.mocks.components) #

This maps component tag names (module IDs) to mock definitions containing a mock server function.

Isomorphic Context Arguments #

The mock's server() function receives the exact same context arguments as the component's real server() block. This includes access to:

Dynamic Mocking via Attributes #

Since state contains coerced attributes passed in the HTML template (e.g., <my-component user-id="123">), mock implementations can return dynamic data conditionally based on these attributes (e.g., checking state.userId).

This allows you to write dynamic mock logic that behaves differently depending on the component's attributes or the page it is rendered on. Below is a full example:

First, the component file (components/user-profile/user-profile.html):

html
Code copied!
  <template id="user-profile">
    <div>User: {{ name }} (ID: {{ userId }})</div>
  </template>
  
  <script type="module">
    import { defineComponent } from 'coralite'
  
    export default defineComponent({
      attributes: {
        userId: String
      },
      async server ({ state }) {
        // In production/development, this would perform a real database lookup:
        const user = await db.fetchUser(state.userId)
        return { name: user.name }
      }
    })
  </script>

Next, the mock configuration in coralite.config.js overriding this component's data based on the userId attribute under components:

javascript
Code copied!
  import { defineConfig } from 'coralite-scripts';
  
  export default defineConfig({
    mode: 'testing',
    testing: {
      mocks: {
        components: {
          'user-profile': {
            server: async ({ state }) => {
              // Read the attribute from state and return dynamic mock data
              if (state.userId === '1') {
                return { name: 'Alice Smith' };
              }
              if (state.userId === '2') {
                return { name: 'Bob Jones' };
              }
              return { name: 'Unknown Test User' };
            }
          }
        }
      }
    }
  });

Plugin Mocking (testing.mocks.plugins) #

Plugins injected into the application can also be mocked. Under the testing.mocks.plugins key, you can map the plugin's name to a definition containing server and/or client mock configurations:

Incremental Builds & ISR Cache Invalidation #

Coralite's Incremental Static Regeneration (ISR) system seamlessly handles configuration updates for mock definitions. The build compiler computes a hash of the entire testing.mocks configuration. Any modification to the mock definitions, implementations, or configuration invalidates the Incremental Static Regeneration (ISR) cache and triggers a rebuild of only the pages consuming the mocked components or plugins.

This guarantees that your E2E test runs always execute against the latest mock logic and data without requiring a manual clean or slow full rebuild of the entire site.

Additionally, testing mode acts as a static target just like production. Development-only AST structures are cleaned up and pages are optimized, ensuring the E2E test environment mimics production behavior as closely as possible.

The window.__coralite__.lifecycle.hydrated Promise #

To solve this, Coralite injects a global promise named window.__coralite__.lifecycle.hydrated into the built HTML document. This promise acts as a reliable hook to signal the absolute completion of the client-side hydration phase.

Your testing frameworks MUST wait for this promise to resolve immediately after performing a page navigation before interacting with any Coralite components.

Playwright Example #

javascript
Code copied!
  import { test, expect } from '@playwright/test';
  
  test('component should increment counter', async ({ page }) => {
    // Navigate to the page
    await page.goto('/my-page');
  
    // Wait for Coralite hydration to complete
    await page.waitForFunction(() => window.__coralite__.lifecycle.hydrated);
  
    // Now it is safe to interact with the DOM using the namespaced pattern
    const button = page.getByTestId('my-counter__increment-btn-0');
    await button.click();
  
    await expect(page.getByTestId('my-counter__count-display-0')).toHaveText('1');
  });

Cypress Example #

javascript
Code copied!
  describe('Counter Component', () => {
    it('should increment counter', () => {
      // Navigate to the page
      cy.visit('/my-page');
  
      // Wait for Coralite hydration to complete
      cy.window().then((win) => {
        return win.__coralite__.lifecycle.hydrated;
      });
  
      // Now it is safe to interact with the DOM using the namespaced pattern
      cy.get('[data-testid="my-counter__increment-btn-0"]').click();
      cy.get('[data-testid="my-counter__count-display-0"]').should('have.text', '1');
    });
  });

What Does It Wait For? #

The window.__coralite__.lifecycle.hydrated promise ensures two critical hydration pipelines have completed:

  1. Component Definition: The framework has dynamically imported all required module chunks for custom elements and registered them via customElements.define().
  2. Declarative Scripts: The framework has fully executed the async script blocks for all declarative component instances mapped on the page.

For a detailed technical breakdown of how these pipelines operate under the hood, refer to the Hydration Readiness Technical Reference.

Start Building with Coralite!

Use the scaffolding script to get jump started into your next project with Coralite

Copied commandline!