diff --git a/packages/eui/src/components/combo_box/combo_box.stories.tsx b/packages/eui/src/components/combo_box/combo_box.stories.tsx index f8b14dc7c672..29490adab43d 100644 --- a/packages/eui/src/components/combo_box/combo_box.stories.tsx +++ b/packages/eui/src/components/combo_box/combo_box.stories.tsx @@ -80,6 +80,16 @@ export const Playground: Story = { render: (args) => , }; +export const MultipleInstances: Story = { + parameters: { loki: { skip: true } }, + render: () => ( + <> + + + + ), +}; + export const WithCustomOptionIds: Story = { parameters: { controls: { diff --git a/packages/test-helpers/.gitignore b/packages/test-helpers/.gitignore new file mode 100644 index 000000000000..aaa9103e44aa --- /dev/null +++ b/packages/test-helpers/.gitignore @@ -0,0 +1,2 @@ +test-results/ +playwright-report/ diff --git a/packages/test-helpers/README.md b/packages/test-helpers/README.md index f0a099041efe..c2302042f984 100644 --- a/packages/test-helpers/README.md +++ b/packages/test-helpers/README.md @@ -20,6 +20,68 @@ This library's versioning is following `@elastic/eui` and must be kept in sync t yarn add --dev @elastic/eui-test-helpers ``` +## Playwright Component Objects + +For Playwright/Scout consumers, this package ships **Component Objects** — +semantic wrappers around a single Playwright `Locator` that encapsulate +user-like interactions for a specific EUI component. + +```ts +import { EuiComboBoxObject } from '@elastic/eui-test-helpers/playwright'; + +const combo = new EuiComboBoxObject(page, 'dataViewSelector'); +await combo.setSelectedOptions(['logs-*']); +expect(await combo.getSelectedOptions()).toEqual(['logs-*']); +``` + +The constructor's second argument is the `data-test-subj` set by the +consumer on the `` element (the outer `.euiComboBox` wrapper). + +`@playwright/test` is declared as a `peerDependency` — consumers provide +their own version at runtime. + +### Adding a new Component Object + +1. Add `data-test-subj` constants to `src/components//selectors.ts` + (single source of truth — never inline test-subj strings in helpers). +2. Add the Component Object class in `src/components//object.ts`, + extending `BaseObject` and importing constants from `selectors.ts`. +3. Add validation tests in `src/components//object.spec.ts` against + the component's Storybook story. +4. Re-export the new class from `src/index.ts`. + +### Running validation tests locally + +The validation tests run against EUI's Storybook. Start it in a separate +terminal first: + +```shell +cd packages/eui +yarn build:workspaces # one-time, builds eui-theme-common + eui-theme-borealis +yarn start # starts Storybook on http://localhost:6006 +``` + +Wait until Storybook finishes compiling. Then, from the repository root: + +```shell +yarn workspace @elastic/eui-test-helpers test +``` + +This runs `tsc --noEmit` (lint) followed by `playwright test` (the actual +tests). To run only the tests, use `test-unit`. On a fresh checkout you'll +also need to install Playwright's browsers once: + +```shell +yarn workspace @elastic/eui-test-helpers exec playwright install chromium +``` + +After a failed run, open the HTML report (with traces, screenshots, and +the full call log) via: + +```shell +yarn workspace @elastic/eui-test-helpers show-report +``` + [Cypress]: https://github.com/cypress-io/cypress [React Testing Library]: https://github.com/testing-library/react-testing-library [Scout]: https://github.com/elastic/kibana/tree/main/src/platform/packages/shared/kbn-scout diff --git a/packages/test-helpers/package.json b/packages/test-helpers/package.json index 9e50cf5cedb6..47c927d6c918 100644 --- a/packages/test-helpers/package.json +++ b/packages/test-helpers/package.json @@ -6,5 +6,18 @@ "type": "git", "url": "https://github.com/elastic/eui.git" }, - "private": true + "private": true, + "scripts": { + "lint": "tsc --noEmit", + "test": "yarn lint && yarn test-unit", + "test-unit": "playwright test", + "show-report": "playwright show-report" + }, + "peerDependencies": { + "@playwright/test": "^1.50.0" + }, + "devDependencies": { + "@playwright/test": "1.59.1", + "typescript": "^5.7.3" + } } diff --git a/packages/test-helpers/playwright.config.ts b/packages/test-helpers/playwright.config.ts new file mode 100644 index 000000000000..efba2d4318a8 --- /dev/null +++ b/packages/test-helpers/playwright.config.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { defineConfig, devices } from '@playwright/test'; + +const STORYBOOK_PORT = 6006; +const STORYBOOK_URL = `http://localhost:${STORYBOOK_PORT}`; + +/** + * Tests expect Storybook to already be running on `STORYBOOK_PORT`; see the + * package README for the local workflow. Defaults track kbn-scout's config + * (test-id attribute, timeouts, no auto-retries) for cross-team consistency. + */ +export default defineConfig({ + testDir: './src', + testMatch: /.*\.spec\.ts$/, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: 0, + reporter: process.env.CI ? 'github' : [['list'], ['html', { open: 'never' }]], + timeout: 60_000, + expect: { timeout: 10_000 }, + use: { + baseURL: STORYBOOK_URL, + testIdAttribute: 'data-test-subj', + actionTimeout: 10_000, + navigationTimeout: 20_000, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/packages/test-helpers/src/components/base_object.ts b/packages/test-helpers/src/components/base_object.ts new file mode 100644 index 000000000000..a89ef8c68765 --- /dev/null +++ b/packages/test-helpers/src/components/base_object.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { Locator, Page } from '@playwright/test'; + +export type ObjectScope = Page | Locator | BaseObject; + +/** + * Base class for Playwright Component Objects — semantic wrappers around a + * single root `Locator` resolved from a `data-test-subj` inside the given + * scope. Subclasses compose: pass another Component Object as `scope` to + * nest one inside the other's DOM subtree. + * + * Requires `testIdAttribute: 'data-test-subj'` in the Playwright config. + */ +export abstract class BaseObject { + /** + * Available to subclasses for queries outside `root`'s subtree but within + * the original scope (e.g. siblings, related controls). + */ + protected readonly scope: Page | Locator; + + protected readonly root: Locator; + + /** + * Retained so subclasses can disambiguate portal-rendered content per + * instance — e.g. EUI propagates this as `${testSubj}-optionsList` to a + * combo box's options list, letting us scope queries to the right combo + * when several exist on a page. + */ + protected readonly testSubj: string; + + constructor(scope: ObjectScope, testSubj: string) { + this.scope = scope instanceof BaseObject ? scope.locator : scope; + this.root = this.scope.getByTestId(testSubj); + this.testSubj = testSubj; + } + + /** + * Underlying `Locator` — escape hatch for assertions or scoping the + * Component Object's API doesn't cover. + */ + get locator(): Locator { + return this.root; + } +} diff --git a/packages/test-helpers/src/components/combo_box/object.spec.ts b/packages/test-helpers/src/components/combo_box/object.spec.ts new file mode 100644 index 000000000000..b8aff308549c --- /dev/null +++ b/packages/test-helpers/src/components/combo_box/object.spec.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { test, expect } from '@playwright/test'; + +import { EuiComboBoxObject } from './object'; + +/** + * Validates `EuiComboBoxObject` against the live component in EUI Storybook. + * + * `data-test-subj` is injected via Storybook's `args` URL parameter so the + * helper can scope to the outer `.euiComboBox` wrapper — the clear button is + * rendered as a sibling of `comboBoxInput`, not a descendant. + */ + +const TEST_SUBJ = 'testComboBox'; +const PLAYGROUND_STORY_URL = + `/iframe.html?id=forms-euicombobox--playground&viewMode=story&args=data-test-subj:${TEST_SUBJ}`; + +test.describe('EuiComboBoxObject', () => { + let combo: EuiComboBoxObject; + + test.beforeEach(async ({ page }) => { + await page.goto(PLAYGROUND_STORY_URL); + await page.getByTestId(TEST_SUBJ).waitFor({ state: 'visible' }); + combo = new EuiComboBoxObject(page, TEST_SUBJ); + await combo.clear(); + }); + + test('setSelectedOptions sets the selection to the provided labels', async () => { + await combo.setSelectedOptions(['Item 2']); + + expect(await combo.getSelectedOptions()).toEqual(['Item 2']); + }); + + test('setSelectedOptions replaces the existing selection', async () => { + await combo.setSelectedOptions(['Item 1', 'Item 2']); + expect(await combo.getSelectedOptions()).toEqual(['Item 1', 'Item 2']); + + // Replace, don't add. + await combo.setSelectedOptions(['Item 3']); + expect(await combo.getSelectedOptions()).toEqual(['Item 3']); + }); + + test('setSelectedOptions is idempotent when the selection already matches', async () => { + await combo.setSelectedOptions(['Item 1', 'Item 2']); + + await expect( + combo.setSelectedOptions(['Item 1', 'Item 2']) + ).resolves.not.toThrow(); + expect(await combo.getSelectedOptions()).toEqual(['Item 1', 'Item 2']); + }); + + test('clear removes all selected options', async () => { + await combo.setSelectedOptions(['Item 1', 'Item 2']); + + await combo.clear(); + + expect(await combo.getSelectedOptions()).toEqual([]); + }); + + test('clear is a no-op when nothing is selected', async () => { + await expect(combo.clear()).resolves.not.toThrow(); + expect(await combo.getSelectedOptions()).toEqual([]); + }); + + test('two combo boxes on the same page are operated independently', async ({ + page, + }) => { + await page.goto('/iframe.html?id=forms-euicombobox--multiple-instances'); + const combo1 = new EuiComboBoxObject(page, 'combo1'); + const combo2 = new EuiComboBoxObject(page, 'combo2'); + await combo1.clear(); + await combo2.clear(); + + // Distinct labels + reverse order so a regression that mis-routed a + // selection across combos would surface. + await combo2.setSelectedOptions(['Item 5']); + await combo1.setSelectedOptions(['Item 2']); + + expect(await combo1.getSelectedOptions()).toEqual(['Item 2']); + expect(await combo2.getSelectedOptions()).toEqual(['Item 5']); + + // Clearing combo1 must not affect combo2 — guards the `clearButton` + // getter against a page-level scoping regression. + await combo1.clear(); + expect(await combo1.getSelectedOptions()).toEqual([]); + expect(await combo2.getSelectedOptions()).toEqual(['Item 5']); + }); +}); diff --git a/packages/test-helpers/src/components/combo_box/object.ts b/packages/test-helpers/src/components/combo_box/object.ts new file mode 100644 index 000000000000..e41baea518fe --- /dev/null +++ b/packages/test-helpers/src/components/combo_box/object.ts @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { Locator } from '@playwright/test'; +import { expect } from '@playwright/test'; + +import { BaseObject } from '../base_object'; +import { EuiComboBoxSelectors } from './selectors'; + +/** + * Playwright Component Object for {@link + * https://eui.elastic.co/docs/components/forms/selection/combo-box/ EuiComboBox}. + * + * `testSubj` must match the `data-test-subj` set by the consumer on the + * `` element (the outer `.euiComboBox` wrapper, not the inner + * `comboBoxInput`). + */ +export class EuiComboBoxObject extends BaseObject { + /** + * Replace the current selection with `labels`. Set-semantics: order- + * independent — already-selected labels are kept, missing ones are added, + * extras are removed. No-op if the current selection already matches. + * + * Throws with a descriptive message if any label never appears in the + * dropdown (catches test/data drift early). + */ + async setSelectedOptions(labels: string[]): Promise { + // Dedupe while preserving order. + const targetLabels = [...new Set(labels)]; + // `[...arr].sort()` (not `arr.sort()`) — sort mutates in place; the copy + // avoids mutating either the consumer's input or our internal state. + const sortedTarget = [...targetLabels].sort(); + + const sortedCurrent = [...(await this.getSelectedOptions())].sort(); + + // Set-equality short-circuit (any order). + if ( + sortedCurrent.length === sortedTarget.length && + sortedCurrent.every((label, i) => label === sortedTarget[i]) + ) { + return; + } + + // Naive replace — clear, then add each. A diff-based approach would do + // less DOM work but require a per-pill remove primitive we don't ship yet. + await this.clear(); + + for (const label of targetLabels) { + await this.addOption(label); + } + + if (targetLabels.length > 0) { + // Close the dropdown so subsequent interactions start clean. + await this.root.page().keyboard.press('Escape'); + } + + await expect + .poll( + async () => [...(await this.getSelectedOptions())].sort(), + { + message: `EuiComboBox: selection did not match after setSelectedOptions(${JSON.stringify(labels)})`, + } + ) + .toEqual(sortedTarget); + } + + /** + * Clear all selected options. No-op if nothing is selected. + */ + async clear(): Promise { + if ((await this.getSelectedOptions()).length === 0) { + return; + } + await this.clearButton.click(); + await expect + .poll(() => this.getSelectedOptions(), { + message: 'EuiComboBox: clear button was clicked but selected options remain', + }) + .toEqual([]); + } + + /** + * Currently selected option labels — pill texts in multi-select, the input + * value wrapped in an array in single-select, `[]` if nothing is selected. + */ + async getSelectedOptions(): Promise { + const pillCount = await this.pills.count(); + if (pillCount > 0) { + return this.pills.allInnerTexts(); + } + const inputValue = await this.searchInput.inputValue(); + return inputValue ? [inputValue] : []; + } + + private async addOption(label: string): Promise { + // Clicking the outer wrapper does not reliably open the dropdown; the + // inner `comboBoxInput` element does. + await this.input.click(); + await this.searchInput.pressSequentially(label, { delay: 50 }); + + // Options list is rendered in a portal outside `this.root`, so locate + // from page level. + const option = this.root + .page() + .locator(EuiComboBoxSelectors.optionFor(this.testSubj, label)); + await option.waitFor({ state: 'visible' }); + await option.click(); + } + + private get input(): Locator { + return this.root.getByTestId(EuiComboBoxSelectors.TEST_SUBJ); + } + + private get searchInput(): Locator { + return this.root.getByTestId(EuiComboBoxSelectors.SEARCH_INPUT_TEST_SUBJ); + } + + private get clearButton(): Locator { + return this.root.getByTestId(EuiComboBoxSelectors.CLEAR_BUTTON_TEST_SUBJ); + } + + private get pills(): Locator { + return this.root.getByTestId(EuiComboBoxSelectors.SELECTED_OPTIONS_TEST_SUBJ); + } +} diff --git a/packages/test-helpers/src/components/combo_box/selectors.ts b/packages/test-helpers/src/components/combo_box/selectors.ts index be4dd264051f..9458f709abae 100644 --- a/packages/test-helpers/src/components/combo_box/selectors.ts +++ b/packages/test-helpers/src/components/combo_box/selectors.ts @@ -42,21 +42,31 @@ export const EuiComboBoxSelectors = { SELECTED_OPTIONS_TEST_SUBJ: 'euiComboBoxPill', /** - * CSS selector to find all options (currently rendered on the screen). + * CSS selector for options in a specific combo box's dropdown — all + * options if `label` is omitted, or the option matching that label. * - * Note: Because the list of options might be virtualized, when searching - * for a specific option, type in the searched string into the search input - * before running any assertions to ensure the option is in DOM. + * `testSubj` is the consumer's `data-test-subj` on ``. EUI + * propagates this to the options list as `${testSubj}-optionsList`, + * letting us disambiguate when multiple combo boxes coexist on one page. + * + * `title` is set by EUI to the exact label string and avoids + * accessible-name mismatches caused by option icons. + * + * Note: the list may be virtualized — type the search string into the + * input before asserting on a specific option to ensure it is in DOM. */ - OPTION: '[data-test-subj="comboBoxOptionsList"] button[role="option"]', + optionFor: (testSubj: string, label?: string): string => { + const base = `[data-test-subj~="${testSubj}-optionsList"] button[role="option"]`; + return label ? `${base}[title="${label}"]` : base; + }, /** - * CSS selector to find all selected options (currently rendered on the screen) - * - * Note: Because the list of options might be virtualized, when searching - * for a specific option, type in the searched string into the search input - * before running any assertions to ensure the option is in DOM. + * CSS selector for selected options in a specific combo box's dropdown — + * all selected if `label` is omitted, or the selected option matching + * that label. See `optionFor` for `testSubj` and `title` rationale. */ - SELECTED_OPTION: - '[data-test-subj="comboBoxOptionsList"] button[role="option"][aria-selected="true"]', + selectedOptionFor: (testSubj: string, label?: string): string => { + const base = `[data-test-subj~="${testSubj}-optionsList"] button[role="option"][aria-selected="true"]`; + return label ? `${base}[title="${label}"]` : base; + }, }; diff --git a/packages/test-helpers/src/index.ts b/packages/test-helpers/src/index.ts new file mode 100644 index 000000000000..6d93d029399c --- /dev/null +++ b/packages/test-helpers/src/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { BaseObject, type ObjectScope } from './components/base_object'; +export { EuiComboBoxObject } from './components/combo_box/object'; diff --git a/packages/test-helpers/tsconfig.json b/packages/test-helpers/tsconfig.json new file mode 100644 index 000000000000..863d821d0cbd --- /dev/null +++ b/packages/test-helpers/tsconfig.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "rootDir": ".", + "target": "ES2020", + "module": "ESNext", + "lib": ["ESNext", "DOM"], + "moduleResolution": "Node", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src", "playwright.config.ts"], + "exclude": ["node_modules"] +} diff --git a/yarn.lock b/yarn.lock index 9e1c36c8c28a..2bbefd16793a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7194,6 +7194,11 @@ __metadata: "@elastic/eui-test-helpers@workspace:packages/test-helpers": version: 0.0.0-use.local resolution: "@elastic/eui-test-helpers@workspace:packages/test-helpers" + dependencies: + "@playwright/test": "npm:1.59.1" + typescript: "npm:^5.7.3" + peerDependencies: + "@playwright/test": ^1.50.0 languageName: unknown linkType: soft @@ -9341,6 +9346,17 @@ __metadata: languageName: node linkType: hard +"@playwright/test@npm:1.59.1": + version: 1.59.1 + resolution: "@playwright/test@npm:1.59.1" + dependencies: + playwright: "npm:1.59.1" + bin: + playwright: cli.js + checksum: 10c0/8c2d94a860d3c254a0b114df2f888ad0a0e9310f45b6059bd5d4da196d965cadf6922267cef0881cfa9784d4bef6d78363d2c2d94caa64be67ff644c41162137 + languageName: node + linkType: hard + "@pmmmwh/react-refresh-webpack-plugin@npm:^0.5.3": version: 0.5.3 resolution: "@pmmmwh/react-refresh-webpack-plugin@npm:0.5.3" @@ -22565,6 +22581,16 @@ __metadata: languageName: node linkType: hard +"fsevents@npm:2.3.2": + version: 2.3.2 + resolution: "fsevents@npm:2.3.2" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/be78a3efa3e181cda3cf7a4637cb527bcebb0bd0ea0440105a3bb45b86f9245b307dc10a2507e8f4498a7d4ec349d1910f4d73e4d4495b16103106e07eee735b + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@npm:^2.3.2, fsevents@npm:~2.3.2": version: 2.3.3 resolution: "fsevents@npm:2.3.3" @@ -22575,6 +22601,15 @@ __metadata: languageName: node linkType: hard +"fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin": + version: 2.3.2 + resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@patch:fsevents@npm%3A^2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin": version: 2.3.3 resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" @@ -32292,6 +32327,30 @@ __metadata: languageName: node linkType: hard +"playwright-core@npm:1.59.1": + version: 1.59.1 + resolution: "playwright-core@npm:1.59.1" + bin: + playwright-core: cli.js + checksum: 10c0/d41a74d9681ce3beb3d5239e9ed577710b4ad099a6ca2476219c6599d51e9cb4b80bd72ed82c528da6a5d929c18ae3b872cf02bb83f78fa1c2cb9199c501abee + languageName: node + linkType: hard + +"playwright@npm:1.59.1": + version: 1.59.1 + resolution: "playwright@npm:1.59.1" + dependencies: + fsevents: "npm:2.3.2" + playwright-core: "npm:1.59.1" + dependenciesMeta: + fsevents: + optional: true + bin: + playwright: cli.js + checksum: 10c0/dfe38396e616e5c4f98825ce90037bb96e477c5a2bd9258a24854f8ce72a8a41427b19098863866f85aa0216e70287dd537c4438d761aca93995e31ae099c533 + languageName: node + linkType: hard + "pngjs@npm:^3.3.3": version: 3.4.0 resolution: "pngjs@npm:3.4.0"