Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 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
10 changes: 10 additions & 0 deletions packages/eui/src/components/combo_box/combo_box.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,16 @@ export const Playground: Story = {
render: (args) => <StatefulComboBox {...args} />,
};

export const MultipleInstances: Story = {
parameters: { loki: { skip: true } },
render: () => (
<>
<StatefulComboBox data-test-subj="combo1" options={options} />
<StatefulComboBox data-test-subj="combo2" options={options} />
</>
),
};

export const WithCustomOptionIds: Story = {
parameters: {
controls: {
Expand Down
2 changes: 2 additions & 0 deletions packages/test-helpers/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
test-results/
playwright-report/
62 changes: 62 additions & 0 deletions packages/test-helpers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<EuiComboBox>` 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/<name>/selectors.ts`
(single source of truth — never inline test-subj strings in helpers).
2. Add the Component Object class in `src/components/<name>/object.ts`,
extending `BaseObject` and importing constants from `selectors.ts`.
3. Add validation tests in `src/components/<name>/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
15 changes: 14 additions & 1 deletion packages/test-helpers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
37 changes: 37 additions & 0 deletions packages/test-helpers/playwright.config.ts
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'] } }],
});
51 changes: 51 additions & 0 deletions packages/test-helpers/src/components/base_object.ts
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;
}
}
95 changes: 95 additions & 0 deletions packages/test-helpers/src/components/combo_box/object.spec.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
Loading