--- name: cypress-agent-skill description: Production-grade Cypress E2E and component testing โ€” selectors, network stubbing, auth, CI parallelization, flake elimination, Page Object Model, and TypeScript support. The complete Cypress skill for AI agents. user-invocable: true metadata: {"openclaw":{"emoji":"๐Ÿงช","requires":{"anyBins":["cypress","npx"]},"install":[{"id":"npm","kind":"node","package":"cypress","label":"Install Cypress (npm)"}]}} --- # Cypress Expert Skill ## Quick Reference **When to use this skill:** - Writing or fixing Cypress E2E or component tests - Setting up Cypress in a new project - Debugging flaky tests - Adding network stubbing / API mocking - Configuring CI pipelines for Cypress - Implementing auth patterns (`cy.session`) - Building Page Object Model architecture **Quick start:** 1. `npm install --save-dev cypress` โ€” install 2. `npx cypress open` โ€” interactive mode (first run generates config) 3. `npx cypress run` โ€” headless CI mode 4. Read full references in `{baseDir}/references/` for deep patterns --- ## Core Philosophy Cypress runs **inside the browser**. It has native access to the DOM, network requests, and application state. Every command is automatically retried until it passes or times out. This means: - **Never use `cy.wait(3000)`** โ€” use aliases + `cy.wait('@alias')` instead - **Never query DOM immediately after an action** โ€” Cypress retries automatically - **Always assert on outcomes, not implementation** โ€” test user-visible behavior - **Use `data-testid` attributes** โ€” decouple tests from styling/structure --- ## 1. Installation & Configuration ### Install ```bash npm install --save-dev cypress # or yarn add -D cypress # or pnpm add -D cypress ``` ### cypress.config.js (JavaScript) ```js const { defineConfig } = require('cypress') module.exports = defineConfig({ e2e: { baseUrl: 'http://localhost:3000', viewportWidth: 1280, viewportHeight: 720, video: false, screenshotOnRunFailure: true, defaultCommandTimeout: 8000, requestTimeout: 10000, responseTimeout: 10000, retries: { runMode: 2, openMode: 0, }, // v15.10.0+ โ€” enforce new cy.env() / Cypress.expose() APIs // set after migrating all Cypress.env() calls allowCypressEnv: false, // v15.x โ€” faster visibility checks experimentalFastVisibility: true, // v15.9.0+ โ€” run all specs without --parallel flag; now works for component tests too experimentalRunAllSpecs: true, setupNodeEvents(on, config) { return config }, }, component: { devServer: { framework: 'react', bundler: 'vite', }, experimentalRunAllSpecs: true, }, }) ``` ### cypress.config.ts (TypeScript) ```ts import { defineConfig } from 'cypress' export default defineConfig({ e2e: { baseUrl: 'http://localhost:3000', specPattern: 'cypress/e2e/**/*.cy.ts', setupNodeEvents(on, config) { return config }, }, }) ``` ### tsconfig for Cypress ```json { "compilerOptions": { "target": "es5", "lib": ["es5", "dom"], "types": ["cypress", "node"] }, "include": ["**/*.ts"] } ``` --- ## 2. Selectors (Stability Hierarchy) Use the most stable selector available. Prefer in this order: ```js // โœ… BEST โ€” semantic, decoupled from style/structure cy.get('[data-testid="submit-button"]') cy.get('[data-cy="login-form"]') cy.get('[data-test="user-email"]') // โœ… GOOD โ€” ARIA/accessibility selectors cy.get('[role="dialog"]') cy.get('[aria-label="Close modal"]') cy.get('button[type="submit"]') // โœ… GOOD โ€” cy.contains for text-driven queries cy.contains('button', 'Submit') cy.contains('[data-testid="nav"]', 'Dashboard') // โš ๏ธ FRAGILE โ€” CSS classes tied to styling cy.get('.btn-primary') // avoid cy.get('.MuiButton-root') // avoid // โŒ WORST โ€” absolute XPath / positional cy.get('div > ul > li:nth-child(3) > a') // never ``` ### Scoped Queries ```js cy.get('[data-testid="user-card"]').within(() => { cy.get('[data-testid="user-name"]').should('contain', 'Alice') cy.get('[data-testid="user-role"]').should('contain', 'Admin') }) cy.get('table').find('tr').should('have.length', 5) ``` --- ## 3. Assertions ### Should / Expect ```js // Chainable assertions cy.get('[data-testid="title"]').should('be.visible') cy.get('[data-testid="title"]').should('have.text', 'Dashboard') cy.get('[data-testid="title"]').should('contain.text', 'Dash') // Multiple assertions (all retry together) cy.get('[data-testid="btn"]') .should('be.visible') .and('not.be.disabled') .and('have.attr', 'type', 'submit') // Value cy.get('input[name="email"]').should('have.value', 'user@example.com') // Length assertions cy.get('[data-testid="item"]').should('have.length', 3) cy.get('[data-testid="item"]').should('have.length.greaterThan', 0) // Negative assertions (use carefully โ€” can pass too early) cy.get('[data-testid="error"]').should('not.exist') cy.get('[data-testid="spinner"]').should('not.be.visible') // BDD expect style cy.get('[data-testid="count"]').invoke('text').then((text) => { expect(parseInt(text)).to.be.greaterThan(0) }) // URL assertions cy.url().should('include', '/dashboard') cy.url().should('eq', 'http://localhost:3000/dashboard') // Alias + should cy.get('[data-testid="price"]').invoke('text').as('price') cy.get('@price').should('match', /\$\d+\.\d{2}/) ``` ### Async State Assertions ```js // Wait for element to appear (retries automatically) cy.get('[data-testid="success-message"]', { timeout: 10000 }) .should('be.visible') // Wait for element to disappear cy.get('[data-testid="loading-spinner"]').should('not.exist') ``` --- ## 4. Network Stubbing with cy.intercept ```js // Basic stub cy.intercept('GET', '/api/users', { statusCode: 200, body: [ { id: 1, name: 'Alice', role: 'admin' }, { id: 2, name: 'Bob', role: 'user' }, ], }).as('getUsers') cy.visit('/users') cy.wait('@getUsers') cy.get('[data-testid="user-row"]').should('have.length', 2) // Fixture file cy.intercept('GET', '/api/users', { fixture: 'users.json' }).as('getUsers') // Glob/regex patterns cy.intercept('GET', '/api/users/*').as('getUser') cy.intercept('GET', /\/api\/products\/\d+/).as('getProduct') // Dynamic handler cy.intercept('POST', '/api/orders', (req) => { req.reply({ statusCode: 201, body: { id: 999, ...req.body } }) }).as('createOrder') // Modify real server response (spy + transform) cy.intercept('GET', '/api/config', (req) => { req.reply((res) => { res.body.featureFlag = true return res }) }).as('getConfig') // Error simulation cy.intercept('GET', '/api/critical', { forceNetworkError: true }).as('networkError') cy.intercept('GET', '/api/data', { statusCode: 500, body: { error: 'Server Error' } }).as('serverError') // Delay (for loading state tests) cy.intercept('GET', '/api/data', (req) => { req.reply({ delay: 1000, body: { data: [] } }) }).as('slowRequest') // Assert request details cy.wait('@createOrder').then((interception) => { expect(interception.request.body).to.deep.include({ quantity: 2 }) expect(interception.response.statusCode).to.equal(201) }) ``` --- ## 5. Authentication Patterns ### cy.session โ€” Cache Auth State (Recommended) ```js Cypress.Commands.add('loginByUI', (email, password) => { cy.session( [email, password], () => { cy.visit('/login') cy.get('[data-testid="email"]').type(email) cy.get('[data-testid="password"]').type(password) cy.get('[data-testid="submit"]').click() cy.url().should('include', '/dashboard') }, { validate() { cy.getCookie('session_token').should('exist') }, cacheAcrossSpecs: true, } ) }) ``` ### API-Based Auth (Faster) ```js Cypress.Commands.add('loginByApi', (email, password) => { cy.session( ['api', email, password], () => { cy.request({ method: 'POST', url: '/api/auth/login', body: { email, password }, }).then(({ body }) => { window.localStorage.setItem('auth_token', body.token) cy.setCookie('session', body.sessionId) }) }, { validate() { cy.window().its('localStorage').invoke('getItem', 'auth_token').should('exist') }, } ) }) // Usage โ€” cy.env() for secrets (v15.10.0+, replaces deprecated Cypress.env()) beforeEach(() => { cy.env(['adminPassword']).then(({ adminPassword }) => { cy.loginByApi('admin@example.com', adminPassword) cy.visit('/dashboard') }) }) ``` --- ## 6. Custom Commands ```js // cypress/support/commands.js Cypress.Commands.add('getByTestId', (testId, options) => { return cy.get(`[data-testid="${testId}"]`, options) }) Cypress.Commands.add('waitForToast', (message) => { const selector = '[data-testid="toast"], [role="status"]' if (message) { cy.get(selector, { timeout: 10000 }).should('contain', message) } else { cy.get(selector, { timeout: 10000 }).should('be.visible') } }) Cypress.Commands.add('fillForm', (data) => { Object.entries(data).forEach(([field, value]) => { cy.get(`[name="${field}"]`).clear().type(String(value)) }) }) // TypeScript โ€” cypress/support/index.d.ts declare global { namespace Cypress { interface Chainable { getByTestId(testId: string, options?: Partial): Chainable loginByApi(email: string, password: string): Chainable loginByUI(email: string, password: string): Chainable waitForToast(message?: string): Chainable fillForm(data: Record): Chainable } } } ``` --- ## 7. Page Object Model ```js // cypress/pages/LoginPage.js class LoginPage { visit() { cy.visit('/login'); return this } getEmailInput() { return cy.get('[data-testid="email-input"]') } getPasswordInput() { return cy.get('[data-testid="password-input"]') } getSubmitButton() { return cy.get('[data-testid="submit-button"]') } getErrorMessage() { return cy.get('[data-testid="error-message"]') } login(email, password) { this.getEmailInput().clear().type(email) this.getPasswordInput().clear().type(password) this.getSubmitButton().click() return this } assertLoggedIn() { cy.url().should('include', '/dashboard'); return this } assertError(message) { this.getErrorMessage().should('contain', message); return this } } export default new LoginPage() // Usage import loginPage from '../pages/LoginPage' it('logs in successfully', () => { loginPage.visit().login('admin@example.com', 'password123').assertLoggedIn() }) ``` --- ## 8. Component Testing ```js // cypress/component/Button.cy.jsx import { mount } from 'cypress/react' import Button from '../../src/components/Button' describe('Button', () => { it('calls onClick when clicked', () => { const onClick = cy.stub().as('clickHandler') mount(