diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c55889eea30..98345b69f36 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,6 +9,42 @@ on: required: false jobs: + playwright: + name: Playwright + runs-on: ubuntu-latest + strategy: + matrix: + react: ['18', '19'] + fail-fast: false + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version-file: '.nvmrc' + cache: 'yarn' + + - name: Install + run: yarn install --immutable + + - name: Install React 18 + if: ${{ matrix.react == '18' }} + run: | + yarn add "@types/react@18" "@types/react-dom@18" --dev + yarn add react@18 react-dom@18 + + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: build-${{ matrix.react }} + path: packages + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Run Playwright tests + run: yarn test:pw playwright/test/ packages/main/src/components/SelectDialog/test/ --project chromium + cypress: name: Cypress runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index c84316b4463..5875388016e 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,10 @@ debug-storybook.log .vscode .cursor/rules/nx-rules.mdc .github/instructions/nx.instructions.md + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ diff --git a/eslint.config.mjs b/eslint.config.mjs index bdfff080cc6..cc3edac8738 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -206,7 +206,7 @@ const config = tseslint.config( }, }, { - files: ['**/*.cy.ts', '**/*.cy.tsx'], + files: ['**/*.cy.ts', '**/*.cy.tsx', '**/*.spec.ts', '**/*.spec.tsx', 'playwright/**/*'], plugins: { 'no-only-tests': noOnlyTests, diff --git a/package.json b/package.json index 9175553367a..0cc5b5c4ef7 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "test:prepare": "rimraf temp && lerna run build", "test:open": "CYPRESS_COVERAGE=false cypress open --component --browser chrome", "test": "yarn test:prepare && cypress run --component --browser chrome --spec packages", + "test:pw": "playwright test -c playwright-ct.config.ts", + "test:pw:open": "playwright test -c playwright-ct.config.ts --ui", "clean": "tsc --build --clean && tsc --build tsconfig.build.json --clean && rimraf temp .out && lerna run clean", "clean:remove-modules": "yarn clean && rimraf node_modules", "prettier:all": "prettier --write --config ./prettier.config.js \"**/*\"", @@ -60,6 +62,8 @@ "@cypress/code-coverage": "4.0.0", "@eslint/compat": "2.0.2", "@eslint/js": "9.39.3", + "@playwright/experimental-ct-react": "1.58.2", + "@playwright/test": "1.58.2", "@semantic-release/github": "12.0.6", "@testing-library/cypress": "10.1.0", "@types/jscodeshift": "17.3.0", diff --git a/packages/main/src/components/SelectDialog/test/SelectDialog.spec.tsx b/packages/main/src/components/SelectDialog/test/SelectDialog.spec.tsx new file mode 100644 index 00000000000..9fbaef68c02 --- /dev/null +++ b/packages/main/src/components/SelectDialog/test/SelectDialog.spec.tsx @@ -0,0 +1,202 @@ +import { expect } from '@playwright/experimental-ct-react'; +import { test } from '../../../../../../playwright/ui5-fixtures.js'; +import { + SelectDialogBasicTestComp, + SelectDialogHeaderTestComp, + SelectDialogSelectionWithToggleTestComp, + SelectDialogSearchTestComp, + SelectDialogConfirmButtonTextTestComp, + SelectDialogNumberOfSelectedItemsTestComp, + SelectDialogCancelWithToggleTestComp, + SelectDialogConfirmButtonPropsTestComp, +} from './SelectDialogTestComponents.js'; + +test.describe('SelectDialog', () => { + test('Basic', async ({ mount, page }) => { + await mount(); + await expect(page.locator('[ui5-dialog]')).toBeVisible(); + await expect(page.locator('[ui5-input][placeholder="Search"]')).toBeVisible(); + await page.getByText('Cancel').click(); + await expect(page.locator('[ui5-dialog]')).not.toBeVisible(); + }); + + test('with headerText', async ({ mount, page, ui5wc }) => { + await mount(); + const header = page.getByText('Select Dialog'); + await expect(header).toHaveCSS('grid-column-start', 'titleStart'); + await expect(header).toHaveCSS('grid-column-end', 'titleCenter'); + await expect(header).toHaveAttribute('level', 'H1'); + + await ui5wc.closePopupWithEsc(); + await page.getByTestId('toggle-center').click(); + await page.getByTestId('open-btn').click(); + await expect(header).toHaveCSS('grid-area', 'titleCenter'); + await expect(header).toHaveAttribute('level', 'H1'); + + await ui5wc.closePopupWithEsc(); + await page.getByTestId('set-h2').click(); + await page.getByTestId('open-btn').click(); + await expect(header).toHaveAttribute('level', 'H2'); + }); + + test('selection', async ({ mount, page, ui5wc }) => { + await mount(); + const list = page.locator('[ui5-list]'); + + // Single mode - no rememberSelections + await expect(page.locator('[ui5-dialog]')).toBeVisible(); + await list.getByText('Product1').click(); + await expect(page.locator('[ui5-dialog]')).not.toBeVisible(); + await expect(page.getByTestId('selected-items')).toHaveText('Last Selected Item: Product1'); + await page.getByTestId('open-btn').click(); + const listItems = page.locator('[ui5-li]'); + for (let i = 0; i < 5; i++) { + await expect(listItems.nth(i)).not.toHaveAttribute('selected'); + } + await ui5wc.closePopupWithEsc(); + + // Single mode - with rememberSelections + await page.getByTestId('toggle-remember').click(); + await page.getByTestId('open-btn').click(); + await list.getByText('Product1').click(); + await expect(page.locator('[ui5-dialog]')).not.toBeVisible(); + await expect(page.getByTestId('selected-items')).toHaveText('Last Selected Item: Product1'); + await page.getByTestId('open-btn').click(); + await expect(list.locator('[ui5-li][text="Product1"]')).toHaveAttribute('selected'); + for (const text of ['Product0', 'Product2', 'Product3', 'Product4']) { + await expect(list.locator(`[ui5-li][text="${text}"]`)).not.toHaveAttribute('selected'); + } + await ui5wc.closePopupWithEsc(); + + await expect(page.getByTestId('close-count')).toHaveText('4'); + await expect(page.getByTestId('confirm-count')).toHaveText('2'); + await expect(page.getByTestId('change-count')).toHaveText('2'); + + // Close via Cancel and Escape + await page.getByTestId('reset').click(); + await page.getByTestId('open-btn').click(); + await page.getByText('Cancel').click(); + await expect(page.locator('[ui5-dialog]')).not.toBeVisible(); + await expect(page.getByTestId('close-count')).toHaveText('5'); + + await page.getByTestId('open-btn').click(); + await expect(page.locator('[ui5-dialog]')).toBeVisible(); + await ui5wc.closePopupWithEsc(); + + await expect(page.getByTestId('close-count')).toHaveText('6'); + await expect(page.getByTestId('confirm-count')).toHaveText('2'); + await expect(page.getByTestId('change-count')).toHaveText('2'); + + // Multiple mode - no rememberSelections + await page.getByTestId('reset').click(); + await page.getByTestId('set-multiple').click(); + await page.getByTestId('open-btn').click(); + await expect(page.locator('[ui5-dialog]')).toBeVisible(); + await list.getByText('Product1').click(); + await list.getByText('Product3').click(); + await page.getByRole('button', { name: 'Select' }).click(); + await expect(page.getByTestId('selected-items')).toHaveText('Last Selected Item: Product1Product3'); + + await page.getByTestId('open-btn').click(); + for (let i = 0; i < 5; i++) { + await expect(listItems.nth(i)).not.toHaveAttribute('selected'); + } + + // Multiple mode - with rememberSelections + await page.getByText('Cancel').click(); + await page.getByTestId('toggle-remember').click(); + await page.getByTestId('open-btn').click(); + await list.getByText('Product1').click(); + await list.getByText('Product3').click(); + await expect(page.locator('[ui5-dialog]')).toBeVisible(); + await page.getByRole('button', { name: 'Select' }).click(); + await expect(page.getByTestId('selected-items')).toHaveText('Last Selected Item: Product1Product3'); + await page.getByTestId('open-btn').click(); + await expect(list.locator('[ui5-li][text="Product1"]')).toHaveAttribute('selected'); + await expect(list.locator('[ui5-li][text="Product3"]')).toHaveAttribute('selected'); + for (const text of ['Product0', 'Product2', 'Product4']) { + await expect(list.locator(`[ui5-li][text="${text}"]`)).not.toHaveAttribute('selected'); + } + await ui5wc.closePopupWithEsc(); + + await expect(page.getByTestId('close-count')).toHaveText('10'); + await expect(page.getByTestId('confirm-count')).toHaveText('4'); + await expect(page.getByTestId('change-count')).toHaveText('6'); + }); + + test('Search', async ({ mount, page, ui5wc }) => { + await mount(); + await expect(page.locator('[accessible-name="Reset"][ui5-icon]')).not.toBeVisible(); + + const input = page.locator('[ui5-input]'); + await ui5wc.typeIntoInput(input, 'Test'); + await expect(page.getByTestId('input-val')).toHaveText('input: Test'); + await expect(page.getByTestId('search-count')).toHaveText('0'); + await expect(page.getByTestId('input-count')).toHaveText('1'); + await expect(page.getByTestId('reset-count')).toHaveText('0'); + + await input.locator('input').press('Enter'); + await expect(page.getByTestId('search-val')).toHaveText('search: Test'); + await expect(page.getByTestId('search-count')).toHaveText('1'); + await expect(page.getByTestId('input-count')).toHaveText('1'); + await expect(page.getByTestId('reset-count')).toHaveText('0'); + + await page.locator('[accessible-name="Search"][ui5-icon]').click(); + await expect(page.getByTestId('search-count')).toHaveText('2'); + await expect(page.getByTestId('input-count')).toHaveText('1'); + await expect(page.getByTestId('reset-count')).toHaveText('0'); + + await page.locator('[part="clear-icon"][ui5-icon]').click(); + await expect(page.getByTestId('search-count')).toHaveText('2'); + // clearing the input via clear button fires input event as well + await expect(page.getByTestId('input-count')).toHaveText('2'); + await expect(page.getByTestId('reset-count')).toHaveText('1'); + await expect(page.locator('[accessible-name="Reset"][ui5-icon]')).not.toBeVisible(); + }); + + test('confirmButtonText', async ({ mount, page }) => { + await mount(); + await expect(page.locator('[ui5-dialog]')).toBeVisible(); + await page.getByText('Exterminate').click(); + await expect(page.getByTestId('confirm-count')).toHaveText('1'); + await expect(page.locator('[ui5-dialog]')).not.toBeVisible(); + }); + + test('numberOfSelectedItems', async ({ mount, page }) => { + await mount(); + await expect(page.getByText('Selected: 1337')).toBeVisible(); + }); + + test('onCancel', async ({ mount, page, ui5wc }) => { + await mount(); + + // Single mode + await page.getByTestId('open-btn').click(); + await page.getByText('Cancel').click(); + await expect(page.getByTestId('cancel-count')).toHaveText('1'); + + await page.getByTestId('open-btn').click(); + await expect(page.locator('[ui5-dialog]')).toBeVisible(); + await ui5wc.closePopupWithEsc(); + await expect(page.getByTestId('cancel-count')).toHaveText('2'); + + // Multiple mode + await page.getByTestId('set-multiple').click(); + await page.getByTestId('open-btn').click(); + await page.getByText('Cancel').click(); + await expect(page.getByTestId('cancel-count')).toHaveText('3'); + + await page.getByTestId('open-btn').click(); + await expect(page.locator('[ui5-dialog]')).toBeVisible(); + await ui5wc.closePopupWithEsc(); + await expect(page.getByTestId('cancel-count')).toHaveText('4'); + }); + + test('confirmButtonProps', async ({ mount, page }) => { + await mount(); + const btn = page.getByTestId('confirmBtn'); + await expect(btn).toBeVisible(); + await expect(btn).toHaveAttribute('disabled'); + await expect(btn).toHaveAttribute('design', 'Emphasized'); + }); +}); diff --git a/packages/main/src/components/SelectDialog/test/SelectDialogTestComponents.tsx b/packages/main/src/components/SelectDialog/test/SelectDialogTestComponents.tsx new file mode 100644 index 00000000000..3aa2b5c20e1 --- /dev/null +++ b/packages/main/src/components/SelectDialog/test/SelectDialogTestComponents.tsx @@ -0,0 +1,200 @@ +import ButtonDesign from '@ui5/webcomponents/dist/types/ButtonDesign.js'; +import ListSelectionMode from '@ui5/webcomponents/dist/types/ListSelectionMode.js'; +import { useState } from 'react'; +import { Button } from '../../../webComponents/Button/index.js'; +import { ListItemStandard, type ListItemStandardDomRef } from '../../../webComponents/ListItemStandard/index.js'; +import { SelectDialog } from '../index.js'; +import type { SelectDialogPropTypes } from '../index.js'; + +const listItems = new Array(5) + .fill('') + .map((_, index) => ( + + )); + +export const SelectDialogBasicTestComp = () => { + return {listItems}; +}; + +export const SelectDialogHeaderTestComp = () => { + const [open, setOpen] = useState(true); + const [headerTextAlignCenter, setHeaderTextAlignCenter] = useState(false); + const [headerTextLevel, setHeaderTextLevel] = useState('H1'); + + return ( +
+ + + + setOpen(false)} + > + {listItems} + +
+ ); +}; + +export const SelectDialogSelectionWithToggleTestComp = () => { + const [open, setOpen] = useState(true); + const [rememberSelections, setRememberSelections] = useState(false); + const [selectionMode, setSelectionMode] = useState(ListSelectionMode.Single); + const [selectedItems, setSelectedItems] = useState([]); + const [closeCount, setCloseCount] = useState(0); + const [confirmCount, setConfirmCount] = useState(0); + const [changeCount, setChangeCount] = useState(0); + + return ( + <> + + + + + + { + setSelectedItems(e.detail.selectedItems.map((item) => (item as unknown as ListItemStandardDomRef).text)); + setConfirmCount((c) => c + 1); + }} + onClose={() => { + console.log('close'); + setOpen(false); + setCloseCount((c) => c + 1); + }} + listProps={{ + onSelectionChange: () => setChangeCount((c) => c + 1), + }} + > + {listItems} + + Last Selected Item: {selectedItems.join('')} + {closeCount} + {confirmCount} + {changeCount} + {rememberSelections ? 'true' : 'false'} + {selectionMode} + + ); +}; + +// Tracks search/input/reset values and counts via DOM +export const SelectDialogSearchTestComp = () => { + const [inputVal, setInputVal] = useState(''); + const [searchVal, setSearchVal] = useState(''); + const [searchCount, setSearchCount] = useState(0); + const [inputCount, setInputCount] = useState(0); + const [resetCount, setResetCount] = useState(0); + + return ( + <> + { + setSearchVal(e.detail.value); + setSearchCount((c) => c + 1); + }} + onSearchInput={(e) => { + setInputVal(e.detail.value); + setInputCount((c) => c + 1); + }} + onSearchReset={() => setResetCount((c) => c + 1)} + open + > + {listItems} + + input: {inputVal} + search: {searchVal} + {searchCount} + {inputCount} + {resetCount} + + ); +}; + +export const SelectDialogConfirmButtonTextTestComp = () => { + const [confirmCount, setConfirmCount] = useState(0); + return ( + <> + setConfirmCount((c) => c + 1)} + open + /> + {confirmCount} + + ); +}; + +export const SelectDialogNumberOfSelectedItemsTestComp = () => { + return ; +}; + +export const SelectDialogCancelWithToggleTestComp = () => { + const [open, setOpen] = useState(false); + const [cancelCount, setCancelCount] = useState(0); + const [selectionMode, setSelectionMode] = useState(ListSelectionMode.Single); + + return ( + <> + + + + setCancelCount((c) => c + 1)} + onClose={() => setOpen(false)} + selectionMode={selectionMode} + > + {listItems} + + {cancelCount} + + ); +}; + +export const SelectDialogConfirmButtonPropsTestComp = () => { + return ( + + ); +}; diff --git a/playwright-ct.config.ts b/playwright-ct.config.ts new file mode 100644 index 00000000000..1bdca3c4655 --- /dev/null +++ b/playwright-ct.config.ts @@ -0,0 +1,45 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig, devices } from '@playwright/experimental-ct-react'; +import react from '@vitejs/plugin-react'; +import tsconfigPaths from 'vite-tsconfig-paths'; + +export default defineConfig({ + testDir: '.', + testMatch: ['**/packages/main/src/components/**/test/*.spec.tsx', '**/playwright/test/*.spec.tsx'], + testIgnore: ['**/*.cy.tsx', '**/*.cy.ts', '**/*.stories.tsx', '**/*.mdx'], + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: process.env.CI ? '100%' : undefined, + reporter: 'html', + timeout: 10_000, + expect: { timeout: 4000 }, + use: { + trace: 'on-first-retry', + ctViteConfig: { + plugins: [ + react(), + tsconfigPaths({ + projects: [fileURLToPath(new URL('tsconfig.base.json', import.meta.url))], + }), + ], + optimizeDeps: { + esbuildOptions: { + target: 'esnext', + }, + exclude: ['**/*.cy.tsx', '**/*.cy.ts', '**/*.stories.tsx'], + }, + build: { + target: 'esnext', + rollupOptions: { + external: [/\.cy\.tsx$/, /\.cy\.ts$/, /\.stories\.tsx$/], + }, + }, + }, + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, + { name: 'webkit', use: { ...devices['Desktop Safari'] } }, + ], +}); diff --git a/playwright/index.html b/playwright/index.html new file mode 100644 index 00000000000..d11d6018986 --- /dev/null +++ b/playwright/index.html @@ -0,0 +1,17 @@ + + + + + + Playwright Component Tests + + + + +
+ + diff --git a/playwright/index.tsx b/playwright/index.tsx new file mode 100644 index 00000000000..135f5a79c4f --- /dev/null +++ b/playwright/index.tsx @@ -0,0 +1 @@ +import '@ui5/webcomponents-react/dist/Assets.js'; diff --git a/playwright/test/UI5FixturesTestComponents.tsx b/playwright/test/UI5FixturesTestComponents.tsx new file mode 100644 index 00000000000..f477b09df8d --- /dev/null +++ b/playwright/test/UI5FixturesTestComponents.tsx @@ -0,0 +1,251 @@ +import { Button } from '@ui5/webcomponents-react/Button'; +import { CheckBox } from '@ui5/webcomponents-react/CheckBox'; +import { ComboBox } from '@ui5/webcomponents-react/ComboBox'; +import { ComboBoxItem } from '@ui5/webcomponents-react/ComboBoxItem'; +import { Dialog } from '@ui5/webcomponents-react/Dialog'; +import { Input } from '@ui5/webcomponents-react/Input'; +import { List } from '@ui5/webcomponents-react/List'; +import { ListItemStandard } from '@ui5/webcomponents-react/ListItemStandard'; +import { MultiComboBox } from '@ui5/webcomponents-react/MultiComboBox'; +import { MultiComboBoxItem } from '@ui5/webcomponents-react/MultiComboBoxItem'; +import { MultiInput } from '@ui5/webcomponents-react/MultiInput'; +import { Option } from '@ui5/webcomponents-react/Option'; +import { RadioButton } from '@ui5/webcomponents-react/RadioButton'; +import { Select } from '@ui5/webcomponents-react/Select'; +import { SuggestionItem } from '@ui5/webcomponents-react/SuggestionItem'; +import { Switch } from '@ui5/webcomponents-react/Switch'; +import { Tab } from '@ui5/webcomponents-react/Tab'; +import { TabContainer } from '@ui5/webcomponents-react/TabContainer'; +import { TextArea } from '@ui5/webcomponents-react/TextArea'; +import { Toolbar } from '@ui5/webcomponents-react/Toolbar'; +import { ToolbarButton } from '@ui5/webcomponents-react/ToolbarButton'; +import { useState } from 'react'; + +export const InputTestComp = () => { + const [value, setValue] = useState(''); + return ( + <> + setValue(e.target.value)} /> + {value} + + ); +}; + +export const ClearInputTestComp = () => { + const [value, setValue] = useState('initial value'); + return ( + <> + setValue(e.target.value)} /> + {value} + + ); +}; + +export const CheckboxTestComp = () => { + const [checked, setChecked] = useState(false); + return ( +
+ setChecked(e.target.checked)} /> + {checked ? 'checked' : 'unchecked'} +
+ ); +}; + +export const SwitchTestComp = () => { + const [checked, setChecked] = useState(false); + return ( +
+ setChecked(e.target.checked)} /> + {checked ? 'on' : 'off'} +
+ ); +}; + +export const RadioButtonTestComp = () => { + const [selected, setSelected] = useState(''); + return ( +
+ setSelected('option1')} /> + setSelected('option2')} /> + {selected} +
+ ); +}; + +export const TextAreaTestComp = () => { + const [value, setValue] = useState(''); + return ( +
+