diff --git a/.github/workflows/testcafe_tests.yml b/.github/workflows/testcafe_tests.yml index 61df1f26ef14..e4d51eb84cd5 100644 --- a/.github/workflows/testcafe_tests.yml +++ b/.github/workflows/testcafe_tests.yml @@ -212,33 +212,33 @@ jobs: run: | base_matrix_start=$(cat <<'JSON' [ - { "componentFolder": "accessibility", "name": "accessibility" } + { "componentFolder": "accessibility", "name": "accessibility", "concurrency": 6 } ] JSON ) base_matrix_end=$(cat <<'JSON' [ - { "componentFolder": "common", "name": "common", "concurrency": 2 }, + { "componentFolder": "common", "name": "common", "concurrency": 3 }, { "name": "generic", "theme": "generic.light", "concurrency": 2 }, { "name": "material", "theme": "material.blue.light", "concurrency": 2 }, { "name": "material - compact", "theme": "material.blue.light.compact", "concurrency": 2 }, { "componentFolder": "cardView", "name": "cardView" }, - { "componentFolder": "dataGrid", "name": "dataGrid (1/4)", "indices": "1/4", "cache": true, "concurrency": 6 }, - { "componentFolder": "dataGrid", "name": "dataGrid (2/4)", "indices": "2/4", "cache": true, "concurrency": 6 }, - { "componentFolder": "dataGrid", "name": "dataGrid (3/4)", "indices": "3/4", "cache": true, "concurrency": 6 }, - { "componentFolder": "dataGrid", "name": "dataGrid (4/4)", "indices": "4/4", "cache": true, "concurrency": 6 }, + { "componentFolder": "dataGrid", "name": "dataGrid (1/4)", "indices": "1/4", "cache": true, "concurrency": 5 }, + { "componentFolder": "dataGrid", "name": "dataGrid (2/4)", "indices": "2/4", "cache": true, "concurrency": 5 }, + { "componentFolder": "dataGrid", "name": "dataGrid (3/4)", "indices": "3/4", "cache": true, "concurrency": 5 }, + { "componentFolder": "dataGrid", "name": "dataGrid (4/4)", "indices": "4/4", "cache": true, "concurrency": 5 }, { "componentFolder": "editors", "name": "editors" }, { "componentFolder": "navigation", "name": "navigation", "concurrency": 6 }, - { "componentFolder": "scheduler/common", "name": "scheduler / common (1/3)", "indices": "1/3" }, - { "componentFolder": "scheduler/common", "name": "scheduler / common (2/3)", "indices": "2/3" }, - { "componentFolder": "scheduler/common", "name": "scheduler / common (3/3)", "indices": "3/3" }, - { "componentFolder": "scheduler/viewOffset", "name": "scheduler / viewOffset (1/2)", "indices": "1/2" }, - { "componentFolder": "scheduler/viewOffset", "name": "scheduler / viewOffset (2/2)", "indices": "2/2" }, + { "componentFolder": "scheduler/common", "name": "scheduler / common (1/3)", "indices": "1/3", "concurrency": 6 }, + { "componentFolder": "scheduler/common", "name": "scheduler / common (2/3)", "indices": "2/3", "concurrency": 6 }, + { "componentFolder": "scheduler/common", "name": "scheduler / common (3/3)", "indices": "3/3", "concurrency": 6 }, + { "componentFolder": "scheduler/viewOffset", "name": "scheduler / viewOffset (1/2)", "indices": "1/2", "concurrency": 4 }, + { "componentFolder": "scheduler/viewOffset", "name": "scheduler / viewOffset (2/2)", "indices": "2/2", "concurrency": 4 }, { "componentFolder": "scheduler/timezones", "name": "scheduler / timezones (Europe/Berlin)", "timezone": "Europe/Berlin" }, { "componentFolder": "scheduler/timezones", "name": "scheduler / timezones (America/Los_Angeles)", "timezone": "America/Los_Angeles" } ] @@ -247,10 +247,10 @@ jobs: style_dependent_matrix=$(cat <<'JSON' [ - { "componentFolder": "accessibility", "name": "accessibility - fluent.dark", "theme": "fluent.blue.dark" }, - { "componentFolder": "accessibility", "name": "accessibility - material.light", "theme": "material.blue.light" }, - { "componentFolder": "accessibility", "name": "accessibility - material.dark", "theme": "material.blue.dark" } - ] + { "componentFolder": "accessibility", "name": "accessibility - fluent.dark", "theme": "fluent.blue.dark", "concurrency": 6 }, + { "componentFolder": "accessibility", "name": "accessibility - material.light", "theme": "material.blue.light", "concurrency": 6 }, + { "componentFolder": "accessibility", "name": "accessibility - material.dark", "theme": "material.blue.dark", "concurrency": 6 } + ] JSON ) @@ -346,7 +346,8 @@ jobs: [ "${{ matrix.concurrency }}" != "" ] && CONCURRENCY="--concurrency ${{ matrix.concurrency }}" [ "${{ matrix.platform }}" != "" ] && PLATFORM="--platform ${{ matrix.platform }}" [ "${{ matrix.cache }}" == "true" ] && CACHE="--cache true" - all_args="--browsers=chrome:devextreme-shr2 $COMPONENT_FOLDER $CONCURRENCY $INDICES $PLATFORM $THEME $CACHE" + REPORT_UNSTABLE="--reportUnstable ./artifacts/unstable-report/report.json" + all_args="--browsers=chrome:devextreme-shr2 $COMPONENT_FOLDER $CONCURRENCY $INDICES $PLATFORM $THEME $CACHE $REPORT_UNSTABLE" echo "$all_args" pnpm run test $all_args @@ -354,6 +355,29 @@ jobs: if: ${{ always() }} run: echo "JOB_NAME=$(echo "${{ matrix.name }}" | tr '/' '-')" >> $GITHUB_ENV + - name: Add run config to unstable test report + if: ${{ always() }} + working-directory: ./e2e/testcafe-devextreme + run: | + report=artifacts/unstable-report/report.json + if [ -f "$report" ]; then + jq --arg theme "${{ matrix.theme }}" \ + --arg componentFolder "${{ matrix.componentFolder }}" \ + --arg timezone "${{ matrix.timezone }}" \ + --arg platform "${{ matrix.platform }}" \ + '. + { theme: $theme, componentFolder: $componentFolder, timezone: $timezone, platform: $platform }' \ + "$report" > "$report.tmp" && mv "$report.tmp" "$report" + cat "$report" + fi + + - name: Upload unstable test report + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: unstable-report-${{ env.JOB_NAME }} + path: ${{ github.workspace }}/e2e/testcafe-devextreme/artifacts/unstable-report/report.json + if-no-files-found: ignore + - name: Copy compared screenshot artifacts if: ${{ always() }} uses: actions/upload-artifact@v7 @@ -370,9 +394,211 @@ jobs: path: ${{ github.workspace }}/e2e/testcafe-devextreme/artifacts/failedtests/**/* if-no-files-found: ignore + testcafe-unstable: + name: unstable + needs: [check-should-run, build, testcafe] + if: | + !cancelled() && + needs.check-should-run.outputs.should-run == 'true' && + needs.build.result == 'success' + runs-on: devextreme-shr2 + timeout-minutes: 45 + + steps: + - name: Set machine timezone + run: | + sudo ln -sf /usr/share/zoneinfo/GMT /etc/localtime + date + + - name: Get sources + uses: actions/checkout@v6 + + - name: Download artifacts + uses: actions/download-artifact@v8 + with: + name: devextreme-artifacts + path: ./packages/devextreme + + - name: Unpack artifacts + working-directory: ./packages/devextreme + run: 7z x artifacts.zip -aoa + + - name: Setup Chrome + uses: ./.github/actions/setup-chrome + with: + chrome-version: '149.0.7827.114' + + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version-file: '.node-version' + + - uses: pnpm/action-setup@v6 + with: + run_install: false + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - uses: actions/cache/restore@v5 + name: Restore pnpm cache + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-cache-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-cache + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Download unstable test reports + uses: actions/download-artifact@v8 + with: + pattern: unstable-report-* + path: ./e2e/testcafe-devextreme/unstable-reports + + - name: Run unstable tests + working-directory: ./e2e/testcafe-devextreme + env: + NODE_OPTIONS: --max-old-space-size=8192 + run: | + last_failed=0 + rerun_failed=0 + unstable_failed=0 + result=./artifacts/unstable-run-result.json + new_flakes_summary=./artifacts/new-flakes-summary.txt + mkdir -p ./artifacts + : > "$new_flakes_summary" + + # Every test gets up to 3 attempts (initial run + 2 retries) and passes + # if at least one attempt succeeds. A reported failure means all attempts failed. + run_tests() { + local rc=0 + rm -f "$result" + pnpm run test --browsers=chrome:devextreme-shr2 --concurrency 1 --retryAttempts 2 --forcePageReloads true --reportFailures "$result" "$@" || rc=$? + if [ ! -f "$result" ]; then + echo "Test runner crashed (exit code $rc)" + exit 1 + fi + last_failed=$(jq '.failedCount // (.tests | length)' "$result") + echo " > tests that failed all attempts in this run: $last_failed" + } + + shopt -s nullglob + for report in unstable-reports/*/report.json; do + job_name=$(basename "$(dirname "$report")") + job_name=${job_name#unstable-report-} + + echo "" + echo "============================================================" + echo " [NEW FLAKY] Rerun of test(s) that failed once in job '$job_name'" + echo "============================================================" + cat "$report" + + theme=$(jq -r '.theme // empty' "$report") + componentFolder=$(jq -r '.componentFolder // empty' "$report") + timezone=$(jq -r '.timezone // empty' "$report") + platform=$(jq -r '.platform // empty' "$report") + + sudo ln -sf "/usr/share/zoneinfo/${timezone:-GMT}" /etc/localtime + + extra_args=(--testsFile "$report" --theme "${theme:-fluent.blue.light}") + [ -n "$componentFolder" ] && extra_args+=(--componentFolder "$componentFolder") + [ -n "$platform" ] && extra_args+=(--platform "$platform") + + run_tests "${extra_args[@]}" + rerun_failed=$((rerun_failed + last_failed)) + + while IFS= read -r name; do + if jq -e --arg n "$name" '.tests | index($n)' "$result" > /dev/null; then + echo "FAILED | $name | $job_name" >> "$new_flakes_summary" + else + echo "passed | $name | $job_name" >> "$new_flakes_summary" + fi + done < <(jq -r '.tests[]' "$report") + done + + sudo ln -sf /usr/share/zoneinfo/GMT /etc/localtime + + echo "" + echo "============================================================" + echo " [KNOWN UNSTABLE] Run of all tests marked with test.meta({ unstable: true })" + echo "============================================================" + run_tests --onlyUnstable true + unstable_failed=$last_failed + meta_failed_names=$(jq -r '.tests[]' "$result") + + echo "" + echo "============================================================" + echo " UNSTABLE JOB SUMMARY" + echo "============================================================" + echo " NEW flaky tests (failed once in a regular job, rerun here):" + if [ -s "$new_flakes_summary" ]; then + sed 's/^/ /' "$new_flakes_summary" + else + echo " (none)" + fi + echo "" + echo " KNOWN unstable tests (test.meta({ unstable: true })), failed all attempts: $unstable_failed" + if [ -n "$meta_failed_names" ]; then + echo "$meta_failed_names" | sed 's/^/ FAILED | /' + fi + echo "============================================================" + + { + echo "## Unstable job summary" + echo "" + echo "### New flaky tests (forwarded from regular jobs)" + if [ -s "$new_flakes_summary" ]; then + echo "" + echo "| Result | Test | Source job |" + echo "|---|---|---|" + awk -F' \\| ' '{ printf "| %s | %s | %s |\n", $1, $2, $3 }' "$new_flakes_summary" + else + echo "" + echo "_none_" + fi + echo "" + echo "### Known unstable tests (\`test.meta({ unstable: true })\`)" + echo "" + echo "Failed all attempts: **$unstable_failed**" + if [ -n "$meta_failed_names" ]; then + echo "" + echo "$meta_failed_names" | sed 's/^/- /' + fi + } >> "$GITHUB_STEP_SUMMARY" + + if [ "$rerun_failed" -gt 0 ]; then + echo "A NEW flaky test failed all attempts." + echo "This is a persistent failure, not a flake - marking the job as failed." + exit 1 + fi + if [ "$unstable_failed" -gt 1 ]; then + echo "More than one KNOWN unstable test failed - marking the job as failed." + exit 1 + fi + + - name: Copy compared screenshot artifacts + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: compared-screenshots-unstable + path: ${{ github.workspace }}/e2e/testcafe-devextreme/artifacts/compared-screenshots/**/* + if-no-files-found: ignore + + - name: Copy failed test artifacts + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: failed-tests-unstable + path: ${{ github.workspace }}/e2e/testcafe-devextreme/artifacts/failedtests/**/* + if-no-files-found: ignore + merge-artifacts: runs-on: ubuntu-latest - needs: testcafe + needs: [testcafe, testcafe-unstable] if: ${{ always() }} steps: @@ -392,10 +618,19 @@ jobs: pattern: failed-tests-* delete-merged: true + - name: Merge unstable test reports + uses: actions/upload-artifact/merge@v7 + continue-on-error: true + with: + name: unstable-reports + pattern: unstable-report-* + separate-directories: true + delete-merged: true + notify: runs-on: ubuntu-latest name: Send notifications - needs: [build, testcafe] + needs: [build, testcafe, testcafe-unstable] if: github.event_name != 'pull_request' && contains(needs.*.result, 'failure') steps: diff --git a/apps/demos/utils/visual-tests/testcafe-runner.ts b/apps/demos/utils/visual-tests/testcafe-runner.ts index 8481d6b760cd..869255d77d3d 100644 --- a/apps/demos/utils/visual-tests/testcafe-runner.ts +++ b/apps/demos/utils/visual-tests/testcafe-runner.ts @@ -91,7 +91,7 @@ async function main() { .browsers(process.env.BROWSERS || 'chrome --no-sandbox --disable-dev-shm-usage --disable-partial-raster --disable-skia-runtime-opts --run-all-compositor-stages-before-draw --disable-new-content-rendering-timeout --disable-threaded-animation --disable-threaded-scrolling --disable-checker-imaging --disable-image-animation-resync --use-gl=swiftshader --disable-features=PaintHolding --js-flags=--random-seed=2147483647 --font-render-hinting=none --disable-font-subpixel-positioning') .concurrency(concurrency || 1) .run({ - quarantineMode: { successThreshold: 1, attemptLimit: 2 }, + quarantineMode: { successThreshold: 1, attemptLimit: 3 }, // @ts-expect-error ts-error hooks: { test: { diff --git a/e2e/testcafe-devextreme/runner.ts b/e2e/testcafe-devextreme/runner.ts index 9db32f845c24..f5345762373c 100644 --- a/e2e/testcafe-devextreme/runner.ts +++ b/e2e/testcafe-devextreme/runner.ts @@ -1,6 +1,7 @@ /* eslint-disable spellcheck/spell-checker */ import createTestCafe, { ClientFunction } from 'testcafe'; import * as fs from 'fs'; +import * as path from 'path'; import * as process from 'process'; import parseArgs from 'minimist'; import { DEFAULT_BROWSER_SIZE } from './helpers/const'; @@ -54,8 +55,14 @@ interface ParsedArgs { theme: string; shadowDom: boolean; skipUnstable: boolean; + onlyUnstable: boolean; disableScreenshots: boolean; retryFailed: boolean; + testsFile: string; + reportUnstable: string; + reportFailures: string; + retryAttempts: number; + forcePageReloads: boolean; } const getTestCafeConfig = (cache: boolean): Partial => ({ @@ -113,12 +120,28 @@ function getArgs(): ParsedArgs { theme: '', shadowDom: false, skipUnstable: true, + onlyUnstable: false, disableScreenshots: false, retryFailed: true, + testsFile: '', + reportUnstable: '', + reportFailures: '', + retryAttempts: FAILED_TESTS_RETRY_ATTEMPTS, + forcePageReloads: false, }, }) as ParsedArgs; } +function writeTestsReport(reportPath: string, tests: Set, failedCount?: number): void { + fs.mkdirSync(path.dirname(reportPath), { recursive: true }); + fs.writeFileSync( + reportPath, + // failedCount can exceed tests.size: failures in test.before hooks + // don't reach the test.after hook, so their names are unknown + JSON.stringify({ tests: [...tests], failedCount: failedCount ?? tests.size }, null, 2), + ); +} + async function main() { let testCafe: Awaited> | null = null; @@ -138,6 +161,13 @@ async function main() { const componentFolderArg = typeof args.componentFolder === 'string' ? args.componentFolder.trim() : ''; const componentFolder = componentFolderArg ? `${componentFolderArg}/**` : '**'; + const onlyUnstable = `${args.onlyUnstable}` === 'true'; + const retryAttempts = Number(args.retryAttempts) || 0; + const forcePageReloads = `${args.forcePageReloads}` === 'true'; + const testsFromFile = new Set( + args.testsFile ? JSON.parse(fs.readFileSync(args.testsFile, 'utf8')).tests : [], + ); + if (fs.existsSync('./screenshots')) { fs.rmSync('./screenshots', { recursive: true }); } @@ -190,11 +220,22 @@ async function main() { filters.push((name: string) => name === testName); } + if (testsFromFile.size > 0) { + filters.push((name: string) => testsFromFile.has(name)); + } + if (filterByFailedTests && testsToFilter && testsToFilter.size > 0) { filters.push((name: string) => testsToFilter.has(name)); } - if (args.skipUnstable) { + if (onlyUnstable) { + filters.push(( + _testName: string, + _fixtureName: string, + _fixturePath: string, + testMeta?: any, + ) => !!(testMeta)?.unstable); + } else if (args.skipUnstable) { filters.push(( _testName: string, _fixtureName: string, @@ -255,6 +296,17 @@ async function main() { hooks: { test: { before: async (t: TestController) => { + if (forcePageReloads) { + // Fresh page per test even for fixtures with disablePageReloads: + // leftover DOM/window state from a previous test is a common + // source of cascading failures in the unstable run + const href = await ClientFunction( + () => window.location.href, + ).with({ boundTestRun: t })(); + + await t.navigateTo(href); + } + if (args.shadowDom) { await loadShadowDomExtension(t); await addShadowRootTree(t); @@ -290,12 +342,10 @@ async function main() { after: async (t: TestController) => { await clearTestPage(t); - if (args.retryFailed) { - // @ts-expect-error ts-errors - const { test, errs } = t.testRun; - if (errs && errs.length > 0) { - failedTests.add(test.name); - } + // @ts-expect-error ts-errors + const { test, errs } = t.testRun; + if (errs && errs.length > 0) { + failedTests.add(test.name); } }, }, @@ -313,15 +363,23 @@ async function main() { // Retry failed tests multiple times if enabled and there are failures if (args.retryFailed && failedTests.size > 0 && failedCount > 0) { const initialFailedCount = failedTests.size; - let attemptsLeft = FAILED_TESTS_RETRY_ATTEMPTS; + let attemptsLeft = retryAttempts; while (attemptsLeft > 0 && failedCount > 0) { - const attemptNumber = FAILED_TESTS_RETRY_ATTEMPTS - attemptsLeft + 1; + if (failedTests.size === 0) { + // Failures in test.before hooks don't reach the test.after hook, + // so their names are unknown - retrying would rerun the whole suite. + // eslint-disable-next-line no-console + console.info('Failed tests could not be identified (hook failures) - skipping retries.'); + break; + } + + const attemptNumber = retryAttempts - attemptsLeft + 1; /* eslint-disable no-console */ console.info('\n'); console.info('='.repeat(60)); - console.info(`RETRY ATTEMPT ${attemptNumber}/${FAILED_TESTS_RETRY_ATTEMPTS}`); + console.info(`RETRY ATTEMPT ${attemptNumber}/${retryAttempts}`); console.info(`Retrying ${failedTests.size} failed test(s)`); console.info('='.repeat(60)); console.info('Failed tests:'); @@ -369,7 +427,7 @@ async function main() { console.info('FINAL RETRY RESULTS'); console.info('='.repeat(60)); console.info(`Initially failed: ${initialFailedCount}`); - console.info(`Total retry attempts used: ${FAILED_TESTS_RETRY_ATTEMPTS - attemptsLeft}`); + console.info(`Total retry attempts used: ${retryAttempts - attemptsLeft}`); console.info(`Final failing count: ${failedCount}`); console.info(`Successfully recovered: ${initialFailedCount - failedCount}`); console.info('='.repeat(60)); @@ -377,6 +435,25 @@ async function main() { /* eslint-enable no-console */ } + if (args.reportFailures) { + writeTestsReport(args.reportFailures, failedTests, failedCount); + } + + if (args.reportUnstable && failedCount === 1 && failedTests.size === 1) { + writeTestsReport(args.reportUnstable, failedTests); + + /* eslint-disable no-console */ + console.info('\n'); + console.info('='.repeat(60)); + console.info(`Single failed test "${[...failedTests][0]}" is treated as unstable.`); + console.info('It will be rerun in the "unstable" job.'); + console.info('='.repeat(60)); + console.info('\n'); + /* eslint-enable no-console */ + + failedCount = 0; + } + await testCafe.close(); process.exit(failedCount); diff --git a/e2e/testcafe-devextreme/tests/common/pivotGrid/fieldPanel/T1283238_OLAP_drag_and_drop_field.ts b/e2e/testcafe-devextreme/tests/common/pivotGrid/fieldPanel/T1283238_OLAP_drag_and_drop_field.ts index 35d637e02022..439a843a37b7 100644 --- a/e2e/testcafe-devextreme/tests/common/pivotGrid/fieldPanel/T1283238_OLAP_drag_and_drop_field.ts +++ b/e2e/testcafe-devextreme/tests/common/pivotGrid/fieldPanel/T1283238_OLAP_drag_and_drop_field.ts @@ -1,12 +1,23 @@ import { createScreenshotsComparer } from 'devextreme-screenshot-comparer'; import PivotGrid from 'devextreme-testcafe-models/pivotGrid'; +import { ClientFunction } from 'testcafe'; import { createWidget } from '../../../../helpers/createWidget'; import url from '../../../../helpers/getPageUrl'; import { testScreenshot } from '../../../../helpers/themeUtils'; import { OLAPApiMock } from '../apiMocks/OLAP_api.mock'; +// After drag-n-drop the dragged field chip may keep focus depending on timing, +// and its focus ring does not match the etalon (unstable in CI) +const blurActiveElement = ClientFunction(() => { + (document.activeElement as HTMLElement | null)?.blur(); +}); + fixture.disablePageReloads`pivotGrid_olap_drag-n-drop` - .page(url(__dirname, '../../container.html')); + // NOTE: '../../container.html' pointed to a nonexistent tests/common/container.html; + // the fixture only worked when a page from a previous fixture was already loaded + // (disablePageReloads) and failed when it ran first in a fresh browser session + .page(url(__dirname, '../../../container.html')) + .requestHooks(OLAPApiMock); const PIVOT_GRID_SELECTOR = '#container'; @@ -28,6 +39,8 @@ const PIVOT_GRID_SELECTOR = '#container'; ); await t.expect(loadPanel.isInvisible()).ok(); + await blurActiveElement(); + await testScreenshot( t, takeScreenshot, @@ -37,8 +50,7 @@ const PIVOT_GRID_SELECTOR = '#container'; await t.expect(compareResults.isValid()) .ok(compareResults.errorMessages()); - }).before(async (t) => { - await t.addRequestHooks(OLAPApiMock); + }).before(async () => { await createWidget('dxPivotGrid', { height: 500, fieldPanel: { visible: true }, diff --git a/e2e/testcafe-devextreme/tests/editors/dateRangeBox/keyboard.ts b/e2e/testcafe-devextreme/tests/editors/dateRangeBox/keyboard.ts index 972c4e280576..193caf54d307 100644 --- a/e2e/testcafe-devextreme/tests/editors/dateRangeBox/keyboard.ts +++ b/e2e/testcafe-devextreme/tests/editors/dateRangeBox/keyboard.ts @@ -424,8 +424,8 @@ test('DateRangeBox should be closed by press esc key when views wrapper in popup applyValueMode: 'useButtons', })); -test.meta({ unstable: true })('DateRangeBox should not be closed by press tab key on startDate input', async (t) => { - const dateRangeBox = new DateRangeBox('#container'); +test('DateRangeBox should not be closed by press tab key on startDate input', async (t) => { + const dateRangeBox = new DateRangeBox('#dateRangeBox'); await t .click(dateRangeBox.getStartDateBox().input); @@ -451,20 +451,31 @@ test.meta({ unstable: true })('DateRangeBox should not be closed by press tab ke await t .expect(dateRangeBox.option('opened')) .eql(false) + .expect(Selector('#lastFocusableElement').hasClass('dx-state-focused')) + .ok() .expect(dateRangeBox.isFocused) .notOk(); -}).before(async () => createWidget('dxDateRangeBox', { - value: ['2021/09/17', '2021/10/24'], - openOnFieldClick: true, - opened: true, - width: 500, - dropDownOptions: { - hideOnOutsideClick: false, - }, - calendarOptions: { - focusStateEnabled: false, - }, -})); +}).before(async () => { + await appendElementTo('#container', 'div', 'dateRangeBox'); + await appendElementTo('#container', 'div', 'lastFocusableElement'); + + await createWidget('dxButton', { + text: 'Last Focusable Element', + }, '#lastFocusableElement'); + + return createWidget('dxDateRangeBox', { + value: ['2021/09/17', '2021/10/24'], + openOnFieldClick: true, + opened: true, + width: 500, + dropDownOptions: { + hideOnOutsideClick: false, + }, + calendarOptions: { + focusStateEnabled: false, + }, + }, '#dateRangeBox'); +}); test('DateRangeBox keyboard navigation via `tab` key if applyValueMode is useButtons, start -> end -> prev -> caption -> next -> views -> today -> apply -> cancel -> start -> end', async (t) => { const dateRangeBox = new DateRangeBox('#dateRangeBox');