diff --git a/.claude/skills/cypress b/.claude/skills/cypress new file mode 120000 index 0000000000000..61a509e43cdaf --- /dev/null +++ b/.claude/skills/cypress @@ -0,0 +1 @@ +../../skills/cypress \ No newline at end of file diff --git a/.cursor/rules/cypress-skill.mdc b/.cursor/rules/cypress-skill.mdc new file mode 100644 index 0000000000000..3a22c4f2ab089 --- /dev/null +++ b/.cursor/rules/cypress-skill.mdc @@ -0,0 +1,22 @@ +--- +description: UI5 Web Components Cypress testing conventions. Consult before writing or editing a Cypress spec. +globs: + - packages/**/cypress/specs/**/*.cy.tsx + - packages/**/cypress/support/**/*.ts +alwaysApply: false +--- + +# UI5 Web Components — Cypress Skill + +Full testing conventions — mounting, selectors, real events, custom commands, assertions, i18n, and flaky-test recipes — live in the **Cypress skill** at `skills/cypress/SKILL.md`. + +Before writing or editing a Cypress spec, open `skills/cypress/SKILL.md`, find your task in the routing table at the top, and open the **one** reference file it names. Do not read every file. + +Non-negotiables: + +- Never run the full test suite. Run one spec: `yarn test:cypress:single cypress/specs/.cy.tsx`. +- Select by attribute, never tag name: `[ui5-button]` not `ui5-button` — in selectors and `.find()`. +- Use real events: `realClick()`, `realPress()`, `realType()` — never `.click()` or `.type()`. +- `realPress` and `realType` take no subject — call them as separate statements after focusing. +- Never use `cy.wait()` — assert the condition instead; use `cy.waitRenderFinished()` for render cycles. +- `cy.ui5DOMRef()` is declared but unimplemented — do not call it. diff --git a/.github/instructions/cypress-tests.instructions.md b/.github/instructions/cypress-tests.instructions.md new file mode 100644 index 0000000000000..0577e662a9c51 --- /dev/null +++ b/.github/instructions/cypress-tests.instructions.md @@ -0,0 +1,22 @@ +--- +applyTo: "packages/*/cypress/specs/**/*.cy.tsx,packages/*/cypress/support/commands/**/*.ts" +--- + +# Cypress Testing Instructions for UI5 Web Components + +When writing, modifying, or reviewing Cypress component tests (`*.cy.tsx`) or +custom Cypress commands in this repository, follow the project's Cypress testing +skill: + +- **Primary guidance:** [`skills/cypress/SKILL.md`](../../skills/cypress/SKILL.md) +- **Custom commands:** [`skills/cypress/references/COMMANDS.md`](../../skills/cypress/references/COMMANDS.md) +- **Reviewing existing specs:** [`skills/cypress/references/REVIEWING.md`](../../skills/cypress/references/REVIEWING.md) + +## Key rules (see the skill for full details) + +- Use **real events** (`cy.realClick()`, `cy.realPress()`, `cy.realType()`) instead of synthetic `.click()` / `.type()`. +- Select components by **attribute notation** — `cy.get("[ui5-button]")`, never the tag name. +- Type element boundaries with generics — `cy.get + +); + +cy.get("@navigate") + .should("have.been.calledOnce"); + +// When the event is not exposed as a JSX prop — attach via addEventListener +cy.mount(); + +cy.get("[ui5-button]") + .then($el => { + $el[0].addEventListener("click", cy.stub().as("clicked")); + }); + +cy.get("[ui5-button]") + .realClick(); +cy.get("@clicked") + .should("have.been.called"); +``` + +### Configuration (theme, language) + +```typescript +import { setTheme, getTheme } from "@ui5/webcomponents-base/dist/config/Theme.js"; + +cy.wrap({ setTheme }) + .then(async ({ setTheme }) => { + await setTheme("sap_horizon_hcb"); + }); + +cy.wrap({ getTheme }) + .then(({ getTheme }) => getTheme()) + .should("equal", "sap_horizon_hcb"); +``` + +For language tests, always import Assets.js: +```typescript +import "../../src/Assets.js"; // required for extra languages + +cy.wrap({ setLanguage }) + .then(async ({ setLanguage }) => { + await setLanguage("bg"); + }); +``` + +### Mobile / device simulation +```typescript +cy.mount(); +cy.ui5SimulateDevice("phone"); +cy.get("[ui5-my-component]") + .should("have.class", "ui5-my-component-mobile"); +``` + +### Freezing time with `cy.clock` + +Components that depend on the current date/time (`Calendar`, `DatePicker`, `DateTimePicker`, `TimePicker`, `DateRangePicker`, `DynamicDateRange`) render differently every day. A test that mounts them without pinning the clock is non-deterministic — it passes today and fails on another date. Freeze the clock in `beforeEach` **before** `cy.mount()`, and only stub the `Date` object: + +```typescript +describe("DatePicker", () => { + beforeEach(() => { + cy.clock(new Date("Jan 15, 2024").getTime(), ["Date"]); + }); + + it("renders the fixed value", () => { + cy.mount(); + // today's date now resolves to Jan 15, 2024 everywhere in the component + }); +}); +``` + +Rules: +- Pass `["Date"]` as the second argument so only `Date` is faked — faking `setTimeout`/`setInterval` (the default) can freeze the component's own async rendering and hang the test. +- Set the clock **before** `cy.mount()` so the component reads the frozen time during its first render. +- Reuse a single `FIXED_VALUE` constant for the value and the clock date so they never drift apart. +- Never assert against "today" computed at runtime — assert against the frozen date literal. + +### Viewport sizing for responsive tests + +`cy.ui5SimulateDevice("phone")` only flips the `isPhone` flag — it does **not** resize the window. To test overflow, breakpoints, or layout that reacts to the actual window size (e.g. `Toolbar`, `Carousel`, `Dialog`, `Tokenizer`, `Popover`), set the real viewport with `cy.viewport(width, height)`: + +```typescript +it("overflows items into the menu below 400px", () => { + cy.viewport(300, 600); + cy.mount( + + + + + + ); + + cy.get("[ui5-toolbar]") + .shadow() + .find(".ui5-tb-overflow-btn") + .should("be.visible"); +}); +``` + +Rules: +- Call `cy.viewport()` **before** `cy.mount()` when the first render must already reflect the size. +- To restore the configured default within a test, use `cy.viewport(Cypress.config("viewportWidth"), Cypress.config("viewportHeight"))` rather than a hard-coded size. +- Use `cy.viewport()` for pixel-size / overflow behavior; use `cy.ui5SimulateDevice("phone")` for phone-specific rendering paths. They are independent — combine them when a test needs both. + +### Disabling animations + +Use `setAnimationMode("none")` in a `before()` hook when testing components that have animations, to prevent timing-dependent failures: + +```typescript +import { setAnimationMode } from "@ui5/webcomponents-base/dist/config/AnimationMode.js"; + +before(() => { + cy.wrap({ setAnimationMode }) + .then(async ({ setAnimationMode }) => { + await setAnimationMode("none"); + }); +}); +``` + +### Wrapper elements for layout testing + +When testing responsive or layout-dependent behavior, wrap the component in a `div` with inline styles: + +```typescript +cy.mount( +
+ + Link 1 + Link 2 + +
+); +``` + +### Form validity testing + +For form components, test `validity`, `formValidity`, `checkValidity()`, `reportValidity()`, and the `:invalid` CSS pseudo-class: + +```typescript +cy.get("#cb") + .then($el => { + const checkbox = $el[0] as CheckBox; + expect(checkbox.validity.valueMissing).to.be.true; + expect(checkbox.validity.valid).to.be.false; + expect(checkbox.checkValidity()).to.be.false; + }); + +cy.get("#cb:invalid") + .should("exist"); +``` + +### Available framework commands + +| Command | Behaviour | +|---------|-----------| +| `cy.mount(jsx)` | Mount, wait for render, wait for `document.fonts.ready` | +| `cy.waitRenderFinished()` | Drain the render queue — use instead of `cy.wait()` | +| `cy.ui5SimulateDevice("phone")` | Force phone behaviour; `"phone"` is the only valid device | +| `cy.ui5AssertValidityState(partial)` | Assert any subset of form validity state | +| `realClick`, `realHover`, `realPress`, `realType` | Wait for render before dispatching real events | +| `cy.screenshot` | Honoured with `SCREENSHOT_DELAY` env var | + +**`cy.ui5DOMRef()` is declared in `support/commands.ts` but never implemented — it will fail at runtime. Do not call it.** + +**Import every icon you use.** The test bundle contains all icons, so a missing import passes locally and breaks in a real application. + +--- + +## Mount Helper Functions + +When a spec needs the same component configuration in many `it()` blocks, extract it into a named helper function at the top of the file rather than repeating the JSX inline. This keeps each `it()` block focused on the assertion, not the setup. + +```typescript +// Define helpers at the top of the spec file, before describe() +const getDefaultCalendar = (date: Date) => { + const day = String(date.getDate()).padStart(2, "0"); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const year = date.getFullYear(); + + return ( + + + + ); +}; + +const getCalendarWithDisabledDates = (id: string, formatPattern: string, ranges: DateRange[]) => ( + + {ranges.map((range, idx) => ( + + ))} + +); + +// Use in tests +describe("Calendar", () => { + it("navigates to the current day", () => { + cy.mount(getDefaultCalendar(new Date(Date.UTC(2000, 10, 22)))); + // ... + }); +}); +``` + +Use fragment wrappers (`<>...`) when a helper needs to render multiple sibling components: + +```typescript +const getCalendarsWithWeekNumbers = () => (<> + + + + + + +); +``` + +--- + +## `beforeEach` and `afterEach` + +Use `beforeEach` and `afterEach` at the `describe` level to share setup and teardown across every test in that block. Do not use them for things that only one test needs — keep those inline. + +### `beforeEach` — shared mount or shared state + +**Shared component mount:** When every test in a `describe` block uses the same component tree, mount it in `beforeEach` rather than repeating `cy.mount()` in every `it()`. + +```typescript +describe("ComboBox - keyboard navigation", () => { + beforeEach(() => { + cy.mount(<> + + + + + + ); + }); + + it("moves focus to the first link in the value state message", () => { + cy.get("[ui5-combobox]") + .realClick(); + // ... + }); + + it("moves focus back on Escape", () => { + cy.get("[ui5-combobox]") + .realClick(); + cy.realPress("Escape"); + // ... + }); +}); +``` + +**Shared device/environment setup:** When all tests in a block require the same device simulation or global config state, set it in `beforeEach`: + +```typescript +describe("ComboBox - mobile", () => { + beforeEach(() => { + cy.ui5SimulateDevice("phone"); + }); + + it("renders the mobile picker", () => { + cy.mount(); + // ... + }); +}); +``` + +**Shared language baseline:** When a `describe` block depends on a specific language being set, ensure it in `beforeEach` so tests don't rely on whatever the previous test left: + +```typescript +describe("Calendar accessibility", () => { + beforeEach(() => { + cy.wrap({ setLanguage }) + .then(async ({ setLanguage }) => { + await setLanguage("en"); + }); + }); + // ... +}); +``` + +### `afterEach` — mandatory cleanup for global state + +Any test that changes global configuration (language, theme) **must reset it in `afterEach`**. Without cleanup, a failing test corrupts state for every test that follows. + +**Language reset:** +```typescript +import { setLanguage } from "@ui5/webcomponents-base/dist/config/Language.js"; +import "../../src/Assets.js"; // required for non-English languages + +describe("DatePicker - language", () => { + afterEach(() => { + cy.wrap({ setLanguage }) + .then(async ({ setLanguage }) => { + await setLanguage("en"); + }); + }); + + it("displays Bulgarian month names", () => { + cy.wrap({ setLanguage }) + .then(async ({ setLanguage }) => { + await setLanguage("bg"); + }); + // ... + }); +}); +``` + +**Theme reset** — same pattern, reset to `"sap_horizon"`: +```typescript +afterEach(() => { + cy.wrap({ setTheme }) + .then(async ({ setTheme }) => { + await setTheme("sap_horizon"); + }); +}); +``` + +### When NOT to use beforeEach/afterEach + +- Do not mount in `beforeEach` when tests need different component configurations — use mount helper functions instead (see above). +- Do not use `afterEach` to reset state that the next `cy.mount()` will implicitly reset anyway (e.g. component-local state). +- Do not use `beforeEach` for setup that only one or two tests need — keep it inline. +