-
Notifications
You must be signed in to change notification settings - Fork 873
Add EuiComboBoxObject Playwright helper to @elastic/eui-test-helpers
#9644
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
steliosmavro
wants to merge
3
commits into
elastic:main
Choose a base branch
from
steliosmavro:feat/test-helpers-combobox-component
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from 1 commit
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
061ff0b
[@elastic/eui-test-helpers] Add EuiComboBox Playwright Component Object
steliosmavro c948849
[@elastic/eui-test-helpers] Rename selectOption to setSelectedOptions
steliosmavro 5d99211
[@elastic/eui-test-helpers] Unify equality check in setSelectedOptions
steliosmavro File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| test-results/ | ||
| playwright-report/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'] } }], | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| } |
85 changes: 85 additions & 0 deletions
85
packages/test-helpers/src/components/combo_box/object.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| /* | ||
| * 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('selectOption adds a pill for the chosen label', async () => { | ||
| await combo.selectOption('Item 2'); | ||
|
|
||
| expect(await combo.getSelectedOptions()).toEqual(['Item 2']); | ||
| }); | ||
|
|
||
| test('selectOption is idempotent when the label is already selected', async () => { | ||
| await combo.selectOption('Item 1'); | ||
|
|
||
| await expect(combo.selectOption('Item 1')).resolves.not.toThrow(); | ||
| expect(await combo.getSelectedOptions()).toEqual(['Item 1']); | ||
| }); | ||
|
|
||
| test('clear removes all selected options', async () => { | ||
| await combo.selectOption('Item 1'); | ||
| await combo.selectOption('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.selectOption('Item 5'); | ||
| await combo1.selectOption('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']); | ||
| }); | ||
| }); |
100 changes: 100 additions & 0 deletions
100
packages/test-helpers/src/components/combo_box/object.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| /* | ||
| * 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 | ||
| * `<EuiComboBox>` element (the outer `.euiComboBox` wrapper, not the inner | ||
| * `comboBoxInput`). | ||
| */ | ||
| export class EuiComboBoxObject extends BaseObject { | ||
| /** | ||
| * Select an option by its visible label. No-op if already selected. Throws | ||
| * with a descriptive message if the matching option never appears in the | ||
| * dropdown. | ||
| */ | ||
| async selectOption(label: string): Promise<void> { | ||
| if ((await this.getSelectedOptions()).includes(label)) { | ||
| return; | ||
| } | ||
|
steliosmavro marked this conversation as resolved.
|
||
|
|
||
| // 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(); | ||
|
|
||
| await expect | ||
| .poll(() => this.getSelectedOptions(), { | ||
| message: `EuiComboBox: option "${label}" did not appear as selected after click`, | ||
| }) | ||
| .toContain(label); | ||
|
|
||
| // Close the dropdown so subsequent interactions start clean. | ||
| await this.root.page().keyboard.press('Escape'); | ||
| } | ||
|
|
||
| /** | ||
| * Clear all selected options. No-op if nothing is selected. | ||
| */ | ||
| async clear(): Promise<void> { | ||
| 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<string[]> { | ||
| const pillCount = await this.pills.count(); | ||
| if (pillCount > 0) { | ||
| return this.pills.allInnerTexts(); | ||
| } | ||
| const inputValue = await this.searchInput.inputValue(); | ||
| return inputValue ? [inputValue] : []; | ||
| } | ||
|
|
||
| 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); | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.