Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 169 additions & 0 deletions docs/knowledge-base/Testing.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { Footer, TableOfContent } from '@sb/components';
import { Meta } from '@storybook/addon-docs/blocks';

<Meta title="Testing" />

# Testing

<TableOfContent />

This guide covers the particularities of testing UI5 Web Components for React — the things that
behave differently from plain React components because these are custom elements with Shadow DOM.
It is **not tied to a specific test runner**: the advice applies to any tool that drives a
**real browser** (for example Cypress, Playwright, or WebdriverIO).

> **Use a real browser, not JSDOM.**
> UI5 Web Components are custom elements that rely on the real DOM — Shadow DOM, custom-element
> upgrades, slots, and CSS. Virtual-DOM environments such as JSDOM (the default for Jest and
> Vitest) do not implement these, so components will not render or behave correctly. Choose a
> runner that executes in an actual browser.

## Component test setup

When you mount a component in isolation (component testing), you have to provide the same runtime
context the real app would. Two things are required:

1. **Wrap the mounted component in `ThemeProvider`, as the outermost element.** Components read
theming and configuration from it, so it must sit above whatever you render.

```tsx
mount(
<ThemeProvider>
<YourComponentUnderTest />
</ThemeProvider>,
);
```

Set this up once in your runner's mount wrapper (e.g. a custom Cypress `mount` command, or
Playwright CT's `beforeMount` hook) so every test gets it automatically.

2. **Import the assets** once in your test setup if the test depends on translations (i18n),
theming, or locale-specific (CLDR) formatting — otherwise components render with English text
only and theme switching won't work:

```tsx
import '@ui5/webcomponents-react/dist/Assets.js';
```

> This setup applies to **component testing**. In **end-to-end** tests you drive the real
> application, which already renders `ThemeProvider` and imports assets — so you don't add either
> in the test itself.

## Selecting elements

Select the host element by its **attribute selector** — the tag name written as an attribute in
square brackets, e.g. `[ui5-button]` for `<ui5-button>` — rather than by the tag name itself.
This keeps selectors working when custom-element [scoping](https://ui5.github.io/webcomponents/docs/advanced/scoping/)
adds a suffix to the tag name (e.g. `<ui5-button-a1b2c3>`).

```js
// ✅ attribute selector — robust against scoping
get('[ui5-button]')

// ❌ tag-name selector — breaks when scoping is enabled
get('ui5-button')
```

Component internals live in the Shadow DOM. Real-browser runners can pierce it, so if a test
genuinely needs an internal element you can query and assert on it — for example Cypress with
`includeShadowDom: true`, or Playwright, whose locators pierce shadow roots by default. Prefer
asserting on the component's public surface (props, attributes, emitted events) where you can;
reach into the shadow tree only when there's no equivalent public signal, and expect such
assertions to be more brittle since internal structure can change between versions.

To identify which item fired a collection event (e.g. from `e.detail`), see
[IDs via dataset](https://ui5.github.io/webcomponents-react/?path=/docs/knowledge-base-ids-via-dataset--docs).

## Web components are asynchronous

This is the pitfall behind most flaky UI5 tests. A UI5 Web Component is not fully usable the
moment it appears in the DOM — it becomes ready in **two asynchronous stages**:

1. **Custom element definition** — the browser has to register (define + upgrade) the custom
element before the host is anything more than an inert placeholder. This happens
asynchronously, especially when component modules are loaded on demand.
2. **Internal rendering** — even after definition, the component renders its own Shadow DOM (and
sometimes nested sub-components) asynchronously. So the shadow parts, inner elements, and
final layout appear a tick _after_ the host exists.

A test that queries an internal node, reads a computed style, or clicks a shadow element **too
early** will intermittently fail — the target may not exist or may not be positioned yet.

**Wait by asserting on the element, not with a fixed delay.** The reliable, framework-agnostic
approach is to let the runner's retrying assertions wait for the actual state you care about —
they keep re-checking until the async definition and rendering have completed:

- Wait for the host to exist / be visible via its attribute selector (e.g. `[ui5-button]`).
- For interactions with internals, assert on the specific target (a shadow part, an option, a
menu item) — prefer a visibility assertion over a mere existence check, since a node can be
present in the DOM before it is actually shown (see overlays below).
- For overlays, wait on the state that indicates readiness (e.g. a popover's `open` attribute —
see the "Popovers, menus, and dialogs" section below).

Avoid fixed timeouts (`wait(500)`) to "let the component settle" — they are both flaky and slow.
Gate on a retrying assertion of the element or its state instead.

## Abstract components render into their parent

Some components are **abstract** (marked `@abstract`). Unlike a normal component, an abstract
component does **not** render its own top-level shadow root — it renders into the DOM of its
**parent**. Typical examples are slotted item components such as `SuggestionItem`, `Tab`,
`ToolbarButton`, `WizardStep`, and `SideNavigationItem`.

Abstract components are marked as such in their API documentation.

This matters for testing because the host placeholder element you write in JSX is not the node
that actually renders or carries the interaction handlers. To reach the real node, use the
component's DOM utility methods:

- **`getDomRef()`** — returns the DOM element that represents the component. For an abstract
component this is the **parent-owned** node that renders it, not a placeholder.
- **`getFocusDomRef()`** — returns the element marked `data-sap-focus-ref` (the node that
receives focus by default).

```js
// For an abstract component, reach the rendered node via its instance handle,
// by evaluating in the browser: element.getDomRef()
rendered = get('[ui5-tab]').evaluate((el) => el.getDomRef())
```

For non-abstract components this is rarely necessary — the host element renders its own shadow
DOM, and interacting with the host works directly (see the next section).

## Popovers, menus, and dialogs: wait until open

Overlay content — popovers, menus, dialogs, dropdowns — is only **visible and interactable while
it is open**, and when open it is displayed in a top-layer/overlay container rather than inline in
the page flow.

Note that "in the DOM" and "interactable" are not the same thing here. Slotted content (for
example a `Menu`'s `MenuItem`s) is authored by you as light-DOM children, so unless you render it
conditionally it is **always** in the DOM — even while the overlay is closed. Querying for its
existence therefore succeeds regardless of open state, yet the content is not visible or clickable
until the overlay opens.

Two consequences for tests:

1. **Wait for the open state (or visibility), not just existence, before interacting.** Asserting
the overlay's `open` attribute — or that the target is _visible_ — waits for the state that
actually makes it interactable, whereas an existence check can pass while the item is still
hidden.

```js
// open the menu, then wait for it to be open before interacting
click(opener)
expect('[ui5-menu]').toHaveAttribute('open')
click('[ui5-menu-item][text="New File"]')
```

2. **Prefer a real, coordinate-based click over a synthetic one.** A real click (what
browser-driving runners perform) hit-tests through the Shadow DOM and lands on the node that
actually carries the handler. A **synthetic** `element.click()` dispatched on the host element
fires on an ancestor of the handler node and may never reach it — so it can silently do
nothing. Let your runner perform its normal click on the element rather than calling
`element.click()` yourself in page-evaluated code.

Because overlays open and animate asynchronously, always gate interactions on the open state (as
above) rather than on fixed delays.

<Footer />
4 changes: 4 additions & 0 deletions packages/mcp-server/scripts/utils/cem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ export function loadCemData(monorepoRoot: string): {
* - cssParts on component root
*/
export function enrichWithCem(apiData: ComponentApiData, cemDecl: CemDeclaration): void {
if (cemDecl._ui5abstract) {
apiData.isAbstract = true;
}

if (cemDecl.events) {
for (const event of cemDecl.events) {
if (event._ui5privacy && event._ui5privacy !== 'public') continue;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,15 @@ test('handler: Dialog has applyFocus method extracted from DomRef', (t) => {
const applyFocus = result.methods.find((m: { name: string }) => m.name === 'applyFocus');
t.truthy(applyFocus, 'Dialog should have applyFocus method');
});

test('handler: abstract component is flagged with isAbstract', (t) => {
// Tab is marked @abstract (_ui5abstract in the CEM) -> isAbstract set in enrichWithCem
const result = getStructured(handler({ componentName: 'Tab' }));
t.true(result.isAbstract, 'Tab should be flagged isAbstract: true');
});

test('handler: non-abstract component does not set isAbstract', (t) => {
// Button is a regular self-rendering component -> flag should be absent/falsy
const result = getStructured(handler({ componentName: 'Button' }));
t.falsy(result.isAbstract, 'Button should not be flagged as abstract');
});
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ EXAMPLE INPUT: { "componentName": "Dialog" }
name: string, // Use as: componentSelector::part(name) { ... }
description: string
}>,
isAbstract?: boolean, // true = abstract component: renders into its PARENT's DOM, not its own shadow root. For testing, target the node from getDomRef() (parent-owned), not the host placeholder. See the "Testing" knowledge-base section (get_documentation).
subTypeDocs?: string // Markdown docs for complex prop types (e.g. column definition properties)
docUrl?: string // Upstream docs link for complex behavioral concepts
}
Expand All @@ -137,6 +138,12 @@ EXAMPLE INPUT: { "componentName": "Dialog" }
.array(z.object({ name: z.string(), description: z.string() }))
.optional()
.describe('CSS ::part() selectors for shadow DOM styling'),
isAbstract: z
.boolean()
.optional()
.describe(
'True for abstract components (marked @abstract): they render into a parent’s DOM, not their own shadow root. When interacting in a real browser (tests), target the node returned by getDomRef() rather than the host placeholder. See the "Testing" knowledge-base section.',
),
subTypeDocs: z
.string()
.optional()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,42 @@
],
"localPath": "docs--knowledge-base--Styling.mdx"
},
{
"name": "Testing",
"description": "Particularities of testing UI5 Web Components for React in a real browser (Cypress, Playwright, etc. — not JSDOM): setup, selectors, async readiness, abstract components, and overlays.",
"link": "https://ui5.github.io/webcomponents-react/v2/?path=/docs/knowledge-base-testing--docs",
"sourcePath": "docs/knowledge-base/Testing.mdx",
"content": [
"Real browser required (not JSDOM/virtual DOM)",
"Component-test setup: wrap in ThemeProvider (outermost), import Assets for i18n/theming",
"Setup applies to component testing, not E2E (app already provides it)",
"Attribute selectors [ui5-*] over tag names (scoping-safe)",
"Shadow DOM is pierceable (Cypress includeShadowDom, Playwright by default); prefer public surface",
"Web components are async: custom element definition + internal rendering",
"Wait via retrying assertions on the element/state, not fixed delays",
"Abstract components render into their parent (isAbstract flag)",
"getDomRef() / getFocusDomRef() for reaching rendered nodes",
"Popovers/menus/dialogs: wait for open/visible state (slotted items may exist in DOM while hidden)",
"Real coordinate-based click vs synthetic element.click()"
],
"tags": [
"testing",
"cypress",
"playwright",
"e2e",
"component-testing",
"themeprovider",
"assets",
"i18n",
"async",
"flaky-tests",
"shadow-dom",
"abstract-components",
"getdomref",
"popovers"
],
"localPath": "docs--knowledge-base--Testing.mdx"
},
{
"name": "Accessibility",
"description": "How to make UI5 Web Components applications accessible. Covers accessibility APIs (accessibleName, accessibleNameRef, accessibleDescription, accessibleRole, accessibilityAttributes), label-input relationships, invisible messaging, keyboard handling, and high contrast themes.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,15 @@ test('handler: section + query together returns error', (t) => {
const result = getText(handler({ section: 'Getting Started', query: 'styling' }));
t.true(result.includes('either'));
});

test('handler: search finds the Testing section', (t) => {
const result = getText(handler({ query: 'testing' }));
t.true(result.includes('Testing'));
});

test('handler: Testing section lookup with fetchContent loads bundled content', (t) => {
// docs/*.mdx files are gitignored (generated by bundle:docs)
const result = getText(handler({ section: 'Knowledge Base > Testing', fetchContent: true }));
t.true(result.includes('Testing'));
t.false(result.includes('bundled docs may need to be regenerated'));
});
2 changes: 2 additions & 0 deletions packages/mcp-server/src/types/cem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export interface CemDeclaration {
members?: unknown[];
slots?: unknown[];
superclass?: { name: string; package: string; module: string } | null;
/** True when the component is marked `@abstract` (renders into a parent's DOM, not its own shadow root). */
_ui5abstract?: boolean;
}

export interface CemModule {
Expand Down
6 changes: 6 additions & 0 deletions packages/mcp-server/src/types/component-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ export interface ComponentApiData {
props: Record<string, CleanedProp>;
methods: MethodInfo[];
cssParts?: CssPart[];
/**
* True when the component is abstract (marked `@abstract`): it renders into a parent's DOM
* rather than its own shadow root. Present (and `true`) only for abstract components.
* See the "Testing" knowledge-base section for how this affects interacting with it.
*/
isAbstract?: boolean;
/** Additional documentation for complex prop types (e.g. column definition shape). */
subTypeDocs?: string;
/** Link to upstream UI5 Web Components documentation for complex behavioral concepts. */
Expand Down
Loading