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:
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:
<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:
<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:
-
Default to Exact Match (Strict Structure): For standard assertions, you MUST default to exact, fully-qualified test IDs using standard locators (e.g.
page.getByTestId('my-component-0__button-0')). This implicitly acts as a strict structural assertion, catching regressions if component render order changes unexpectedly. -
Fallback to Suffix Matching (Dynamic Contexts): Only use CSS attribute suffix matchers (e.g.
page.locator('[data-testid$="__button-0"]')) when targeting components rendered inside dynamic loops (e.g. lists), when the component's position in the document tree is intentionally variable, or if exact structural order is irrelevant to the specific test.
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:
window.__coralite__.components['instance-id'].state: Access the reactive proxy of any hydrated component.window.__coralite__.events: A telemetry log of everyCustomEventdispatched by components (Event Telemetry).
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:
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:
state: An object containing coerced attribute values passed to the component instance in the HTML template.page: Metadata about the current page rendering (such as page route and file info).root: The root element structure.module: The component module metadata.id: The unique instance ID of the component.session: The build rendering session.app: The Coralite application instance.noHydration: Boolean flag indicating if hydration is bypassed.- Plugin context namespaces (e.g., custom plugin methods injected via
server.context).
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):
<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:
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:
server.context: An object containing mock properties or methods to be merged into the plugin's server-side context (the return value of the plugin's Phase 2 resolver during the server build).client.context: An object containing mock properties or methods to be merged into the plugin's client-side context (the return value of the plugin's Phase 2 resolver in the browser).
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 #
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 #
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:
- Component Definition: The framework has dynamically imported all required module chunks for custom elements and registered them via
customElements.define(). - Declarative Scripts: The framework has fully executed the async
scriptblocks 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.