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="">).

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 component inspection and event telemetry methods under window.__coralite__.test (and window.__coralite__.devtools in development and testing builds) 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';
  
  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:  (ID: )</div>
  </template>
  
  &lt;script type="module"&gt;
    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 }
      }
    })
  &lt;/script&gt;

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';
  
  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 #

Plugins injected into the application can also be mocked under testing.mocks.plugins. Mocks apply only in mode: 'testing'. Each key must match the plugin name used for context injection, or the plugin's server.name / client.name when those are set explicitly.

javascript
Code copied!
  import { createCoralite, definePlugin } from 'coralite'
  
  const analyticsPlugin = definePlugin({
    name: 'analytics',
    server: {
      context: () => () => ({
        track: event => `real:${event}`
      })
    },
    client: {
      context: () => () => ({
        track: event => `real-client:${event}`
      })
    }
  })
  
  const app = await createCoralite({
    mode: 'testing',
    plugins: [analyticsPlugin],
    testing: {
      mocks: {
        plugins: {
          analytics: {
            server: {
              context: {
                track: event => `mocked:${event}`
              }
            },
            client: {
              context: {
                track: event => `mocked-client:${event}`
              }
            }
          }
        }
      }
    }
  })

A component consuming the plugin can use it directly in server() and client():

html
Code copied!
  &lt;script type="module"&gt;
    export async function server ({ analytics }) {
      return { event: analytics.track('page_view') }
    }
  
    export function client ({ analytics }) {
      analytics.track('click')
    }
  &lt;/script&gt;

With the mock above, server() receives mocked:page_view and client code receives mocked-client:click.

How Mocks Merge #

Mock context objects are shallow-merged over the resolved Phase 2 context:

javascript
Code copied!
  resolved = Object.assign({}, realContext, mock.context)

Only the keys you provide are replaced. Methods that are not mocked continue to use the real plugin implementation.

Plugin Setup Still Runs #

Important: testing.mocks.plugins does not skip plugin setup. The real plugin's Phase 1 and Phase 2 context functions still execute before the mock is merged. Do not use this mechanism to avoid database connections, secret reads, or external API calls inside the plugin itself. For full isolation, replace the plugin with a stub in the plugins array.

javascript
Code copied!
  const stubAnalytics = definePlugin({
    name: 'analytics',
    server: {
      context: () => () => ({
        track: event => `stubbed:${event}`
      })
    },
    client: {
      context: () => () => ({
        track: event => `stubbed-client:${event}`
      })
    }
  })
  
  const app = await createCoralite({
    mode: 'testing',
    plugins: [stubAnalytics]
  })

Top-Level Context #

testing.mocks.plugins targets server.context and client.context. Top-level plugin.context is resolved through a separate isomorphic path used by getters and the client runtime. For tests that must override top-level context, register a stub plugin instead of relying on built-in mocks.

Component vs. Plugin Mocks #

If a component has both a component mock and a plugin mock, the component mock takes precedence for that component's server() result.

Choosing a Mocking Strategy #

Goal Mechanism
Override a component's server result testing.mocks.components
Override plugin server context testing.mocks.plugins.<name>.server.context
Override plugin client context testing.mocks.plugins.<name>.client.context
Unit test a consumer with injected plugin context loadComponent / loadPage options.context
Replace a plugin completely Stub plugin in the plugins array

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');
    });
  });

Test Fixtures & Dedicated Test Pages #

When testing isolated components with Playwright or Cypress, you need HTML pages (test fixtures) that mount and exercise your custom elements with various attributes, slots, and scenarios. Rather than placing test fixtures inside src/pages/ (which pollutes your production routes and build output), Coralite supports dedicated fixtures configuration:

javascript
Code copied!
  import { defineConfig } from 'coralite';
  
  export default defineConfig({
    pages: 'src/pages',
    components: 'src/components',
    fixtures: 'tests/fixtures'
  });

Organize your test fixtures in a dedicated directory:

text
Code copied!
  my-project/
  ├── coralite.config.js
  ├── src/
  │   ├── components/
  │   │   └── user-card.html         # Application component
  │   └── pages/
  │       └── index.html             # Clean production pages
  └── tests/
      └── fixtures/
          └── card-demo.html         # Fixture mounting &lt;user-card&gt;

During coralite dev or coralite test, your fixtures are automatically mounted under /__fixtures__/<fixture-name> with full SSR, scoping, and hydration:

javascript
Code copied!
  import { test, expect } from '@playwright/test';
  
  test('user card fixture renders accurately', async ({ page }) => {
    // Navigate cleanly to the test fixture
    await page.goto('/__fixtures__/card-demo');
  
    const card = page.getByTestId('test-card');
    await expect(card).toBeVisible();
  });

Zero Production Leakage: During production builds (coralite build), fixture pages and test patterns (__test__, __tests__, *.test.html, *.spec.html, *.fixture.html) are strictly excluded and never emitted to dist/.

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!